Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions internal/vault/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ import (
// `(?:>\s*)*` is a blockquote prefix, unrelated to the `>` state char. This
// is the app's TASK_LINE_RE, so the CLI counts task indexes the way the
// editor's toggles do and the two can never disagree about which line
// `inbox/Note.md#3` is.
// `inbox/Note.md#3` is. Both dialects read task lines with it: the desktop's
// MCP server matches these lines since 2.69.1, and `zn task` and `zn mcp`
// follow it so an assistant sees the same tasks through either server.
var (
taskFenceRe = regexp.MustCompile("^(\\s*)(```|~~~)")
taskLineRe = regexp.MustCompile(`^(\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+\[)( |x|X|>|-|/)(\].*)$`)
cliFenceRe = regexp.MustCompile("^(\\s{0,3})(`{3,}|~{3,})")
cliTaskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+\[)( |x|X|>|-|/)(\].*)$`)
inlineDueRe = regexp.MustCompile(`(?i)(?:^|\s)due:\s*(\S+)`)
inlinePriorityRe = regexp.MustCompile(`(?i)(?:^|\s)!(high|med|medium|low|h|m|l)\b`)
inlineWaitingRe = regexp.MustCompile(`(?i)(?:^|\s)@waiting\b`)
Expand All @@ -44,9 +45,6 @@ func (d TaskDialect) fenceRe() *regexp.Regexp {
}

func (d TaskDialect) lineRe() *regexp.Regexp {
if d == DialectCLI {
return cliTaskLineRe
}
return taskLineRe
}

Expand Down
14 changes: 8 additions & 6 deletions internal/vault/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,21 @@ type TaskGroups struct {
OverdueCount int
}

// TaskDialect picks which task-line grammar a scan uses. The desktop app
// and its CLI disagree on the edges (numbered-list and blockquoted checkboxes
// exist for the app only, and the CLI leaves `@key:value` fields in the
// content), and a task id is only stable within one grammar, so each surface
// keeps the grammar it always had.
// TaskDialect picks which task grammar a scan uses. Both read the same task
// lines, numbered-list and blockquoted checkboxes included (the CLI read only
// bullets until 0.7.2, when the desktop's MCP server began matching the app's
// lines). They still differ on the edges: the CLI leaves `@key:value` fields in
// the content and reads fences only up to three spaces in, and a task id is
// only stable within one grammar, so each surface keeps its own.
type TaskDialect int

const (
// DialectApp is the grammar of the app's Tasks views, which the terminal
// UI shares so a toggle there edits the line the app would.
DialectApp TaskDialect = iota
// DialectCLI is the grammar of the desktop's `zn task` commands and MCP
// tools, so scripts keep getting the ids they got before.
// tools, so `zn mcp` and the desktop's own MCP server list the same tasks
// under the same ids.
DialectCLI
)

Expand Down
57 changes: 52 additions & 5 deletions internal/vault/vault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,18 +425,65 @@ func TestCLIDialect(t *testing.T) {
body := "- [ ] one @project:x\n1. [ ] numbered\n> - [ ] quoted\n- [x] two\n"
app := ParseTasks("a.md", "a", FolderInbox, body, ParseTasksOptions{})
cli := ParseTasks("a.md", "a", FolderInbox, body, ParseTasksOptions{Dialect: DialectCLI})
if len(app) != 4 || len(cli) != 2 {
// Both read the same task lines, so a task has the same id in each; the
// CLI keeps `@key:value` fields in the content.
if len(app) != 4 || len(cli) != 4 {
t.Fatalf("app=%d cli=%d", len(app), len(cli))
}
for i := range app {
if app[i].ID != cli[i].ID {
t.Fatalf("task %d: app id %q, cli id %q", i, app[i].ID, cli[i].ID)
}
}
if app[0].Content != "one" || cli[0].Content != "one @project:x" || cli[0].Fields != nil {
t.Fatalf("content app=%q cli=%q fields=%v", app[0].Content, cli[0].Content, cli[0].Fields)
}
if cli[1].ID != "a.md#1" || !cli[1].Checked {
t.Fatalf("cli second = %+v", cli[1])
if cli[1].Content != "numbered" || cli[2].Content != "quoted" {
t.Fatalf("cli numbered=%q quoted=%q", cli[1].Content, cli[2].Content)
}
if cli[3].ID != "a.md#3" || !cli[3].Checked {
t.Fatalf("cli last = %+v", cli[3])
}
next, ok := ToggleTaskInBody(body, 1, DialectCLI)
next, ok := ToggleTaskInBody(body, 3, DialectCLI)
if !ok || !strings.Contains(next, "- [ ] two") {
t.Fatalf("cli toggle #1 should reopen 'two': %q", next)
t.Fatalf("cli toggle #3 should reopen 'two': %q", next)
}
next, ok = ToggleTaskInBody(body, 1, DialectCLI)
if !ok || !strings.Contains(next, "1. [x] numbered") {
t.Fatalf("cli toggle #1 should check off 'numbered': %q", next)
}
}

// The desktop's own MCP server reads task lines the same way since 2.69.1;
// its test "sees the task lines the app sees" (apps/desktop/src/mcp/
// vault-ops.test.ts) uses this note, so `zn mcp` and the desktop's server list
// the same tasks under the same ids.
func TestCLIDialectReadsEveryTaskShape(t *testing.T) {
body := strings.Join([]string{
"- [ ] bullet",
"1. [ ] ordered",
"2) [x] ordered done",
"> - [ ] quoted",
"> > 3. [/] deeply quoted",
"-[ ] not a task",
"text [ ] not a task",
"",
}, "\n")
cli := ParseTasks("inbox/Shapes.md", "Shapes", FolderInbox, body, ParseTasksOptions{Dialect: DialectCLI})
var got []string
for _, task := range cli {
got = append(got, task.Content)
}
want := []string{"bullet", "ordered", "ordered done", "quoted", "deeply quoted"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("cli tasks = %q, want %q", got, want)
}
if !cli[2].Checked || !cli[4].InProgress {
t.Fatalf("states: ordered done checked=%v, deeply quoted in progress=%v", cli[2].Checked, cli[4].InProgress)
}
next, ok := ToggleTaskInBody("1. [ ] first\n> - [ ] quoted\n", 1, DialectCLI)
if !ok || next != "1. [ ] first\n> - [x] quoted\n" {
t.Fatalf("toggle quoted = %q, %v", next, ok)
}
}

Expand Down
Loading