From 76577163870173f87f26b0c7c94414c1e9b0885b Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 14 Oct 2026 10:50:46 -0500 Subject: [PATCH 1/2] Feat(tasks): zn task watch prints each task status change as it happens ZenNotes/zennotes#941 asks for a script to run when a task changes status, for example starting a coding agent when its card moves from Backlog to Ready. ZenNotes will not run commands itself: workflows live in the vault and sync to every device, so a step that executes would run on any desktop that opens it. What a script needs is the signal, and nothing gave it: `zn task list --json` left the custom status out, so a script had to parse the files and remember every status it had seen. `zn task watch` prints one line per task whose status changes, with the note path, title, task text and the previous and new status, and with `--json` each line is a JSON object a script can act on as it arrives. A status is what the Kanban board shows. A task note's is its frontmatter `status:` (open when it has none). A checkbox line is done, cancelled or forwarded once its box says so, since a board grouped by status drops those cards; until then it is its `@status:` or the status its note gives it, and otherwise waiting, in progress or open. Tasks are matched between scans by note and words, not by position, so adding a task above another, editing a task's words or saving a note without a status change prints nothing. The scans use the app's grammar, whose task text leaves the fields out, so a status change never reads as an edited task. A local vault is watched through the terminal app's file watcher, moved to internal/fswatch so both share it. A server pushes its changes over /api/watch; while that stream is unavailable (an older server, or a proxy without WebSockets) the server is asked every --interval seconds and the stream is tried again every 30 seconds. A failed scan is reported and the watch goes on. Ctrl-C ends it with exit 0, as `zn server run` does. `zn task list --json` now carries each task's `status`, read the way the app reads it, without changing the content or ids the desktop's CLI gives. `zn mcp`'s list_tasks shares that grammar and gains the same key; the desktop's own MCP server does not have it yet. What it deliberately does not do: run anything, report tasks that appear or disappear, or report a status change made together with an edit to the task's words, which reads as a different task. Tests cover the status diff (every way a status moves, edits that keep it, repeated tasks), one JSON line per change through the watch loop, the readable line, a local vault's file events, a server's pushed changes settling into one scan, polling without a stream, a bad --interval, and the status in `task list --json`. With the built binary on a scratch vault, an atomic save, an in-place edit and `zn task toggle` each printed one line and a body-only save printed none. Against the desktop app, dragging a task note from Backlog to Ready and a checkbox task from Backlog to Review on its Kanban board printed exactly those two changes. --- README.md | 36 +- internal/cli/commands_task_watch.go | 211 +++++++++++ internal/cli/format.go | 13 + internal/cli/help.go | 2 + internal/cli/main.go | 1 + internal/cli/task_watch_test.go | 339 ++++++++++++++++++ .../{tui/watcher.go => fswatch/fswatch.go} | 31 +- internal/tui/app.go | 9 +- internal/tui/commands_parity.go | 5 +- internal/tui/workspace_source_test.go | 2 +- internal/vault/tasks.go | 30 +- internal/vault/taskwatch.go | 91 +++++ internal/vault/taskwatch_test.go | 120 +++++++ 13 files changed, 866 insertions(+), 24 deletions(-) create mode 100644 internal/cli/commands_task_watch.go create mode 100644 internal/cli/task_watch_test.go rename internal/{tui/watcher.go => fswatch/fswatch.go} (66%) create mode 100644 internal/vault/taskwatch.go create mode 100644 internal/vault/taskwatch_test.go diff --git a/README.md b/README.md index dac08c5..06d59a7 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Use `--no-input` and structured errors for automation; see | Search | `search ` `search-title ` `backlinks ` | | Folders | `folder list\|create\|rename\|delete` | | Tags | `tag list\|find` | -| Tasks | `task list\|toggle` | +| Tasks | `task list\|toggle\|watch` | | Comments | `comment list\|add\|reply\|resolve`, threads on a note shared with the app and its MCP tools | | Databases | `base list\|create\|rows\|get\|add\|set\|convert`, for `.base` folders and loose `.csv` files | | Vaults | `setup` `init` `connect` `disconnect` `use` `vault info\|list\|mode\|add\|remove` | @@ -242,6 +242,39 @@ replies and resolve/reopen operations take a thread ID. `base add` and `base add --body` or `--page` also creates a record page. `base convert` turns a loose CSV into a `.base` folder. +### Watching task status + +`zn task watch` prints a line each time a task's status changes: a task +note's `status:` in its frontmatter, a checkbox task's `@status:`, or its +checkbox. Moving a card on a Kanban board, editing the note in any editor and +`zn task toggle` all count. Editing a task's words, or saving it with the +same status, does not. With `--json` each change is one line of JSON, so a +script can act on each one as it arrives: + +```bash +zn task watch --json | while IFS= read -r change; do + if [ "$(jq -r .status <<<"$change")" = ready ]; then + ./start-agent.sh "$(jq -r .path <<<"$change")" + fi +done +``` + +```json +{"at":"2026-10-14T15:47:24Z","id":"inbox/Parser.md#task","path":"inbox/Parser.md","link":"zennotes://open?path=inbox/Parser.md","title":"Parser","kind":"file","content":"Parser","previousStatus":"backlog","status":"ready","lineNumber":0} +``` + +`status` and `previousStatus` follow the Kanban board. A task note's status +is its own `status:` (`open` when it has none). A checkbox task is `done`, +`cancelled` or `forwarded` once its box says so; until then it is its +`@status:` or the status its note's frontmatter gives it, and otherwise +`waiting`, `in-progress` or `open`. `kind` is `file` for a task note. + +zn only reports: whatever runs on a change is your own script, so nothing +that executes lives in the vault. A local vault is watched through its +files. A server pushes its changes; one that cannot (an older server, or a +proxy without WebSockets) is asked every `--interval` seconds instead, 5 by +default. `zn task list --json` carries each task's `status` too. + The [online command reference](https://zennotes.org/tui/docs#commands) lists the command syntax and flags by area. @@ -847,6 +880,7 @@ internal/keymaps the bindable-action catalog and overrides internal/periodic daily, weekly and monthly note patterns internal/templates built-in and custom templates internal/search note search scoring +internal/fswatch batched file events under a vault: live reload, task watch ``` `go test ./...` runs the suite. The command tables, task grammar, database diff --git a/internal/cli/commands_task_watch.go b/internal/cli/commands_task_watch.go new file mode 100644 index 0000000..37d6ce7 --- /dev/null +++ b/internal/cli/commands_task_watch.go @@ -0,0 +1,211 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/fswatch" + "github.com/ZenNotes/tui/internal/remote" + "github.com/ZenNotes/tui/internal/vault" +) + +const ( + // taskWatchSettle lets a burst of server events settle into one scan, as + // the local watcher batches its file events. + taskWatchSettle = 300 * time.Millisecond + // taskWatchRetry is how long a server without a live stream is polled + // before the stream is tried again. + taskWatchRetry = 30 * time.Second +) + +// taskWatchEvent is one line of `zn task watch --json`. +type taskWatchEvent struct { + At string `json:"at"` + ID string `json:"id"` + Path string `json:"path"` + Link string `json:"link"` + Title string `json:"title"` + Kind string `json:"kind,omitempty"` + Content string `json:"content"` + PreviousStatus string `json:"previousStatus"` + Status string `json:"status"` + LineNumber int `json:"lineNumber"` +} + +// cmdTaskWatch prints each task whose status changes, as it changes: a task +// note's `status:`, a checkbox line's `@status:`, or its checkbox. It only +// reports. What runs on a change is the caller's own script, never ZenNotes, +// so nothing that executes lives in a synced vault (ZenNotes/zennotes#941). +// The scans use the app's grammar, whose task text leaves the fields out, so +// a status change is never mistaken for an edited task. +func cmdTaskWatch(ctx context.Context, b backend.Backend, args Args) error { + opts := vault.ParseTasksOptions{IncludeExcluded: args.Bool("include-excluded"), Dialect: vault.DialectApp} + interval := 5 + if raw, given := args.String("interval"); given { + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + return fmt.Errorf("--interval is a whole number of seconds, 1 or more, got %q", raw) + } + interval = n + } + // Watching starts before the first scan, so a change made meanwhile is + // either in that scan or wakes the next one, never reported late. + changed, stop, err := watchVaultChanges(ctx, b, time.Duration(interval)*time.Second) + if err != nil { + return err + } + defer stop() + previous, err := b.ScanTasks(ctx, opts) + if err != nil { + return err + } + asJSON := args.Bool("json") + if !asJSON { + fmt.Fprintf(stderr, "Watching %s for task status changes. Ctrl+C stops.\n", b.Label()) + } + return followTaskStatuses(ctx, b, opts, previous, changed, asJSON) +} + +// followTaskStatuses scans again on each signal and reports every task whose +// status moved since the scan before, until ctx ends or changed closes. A +// failed scan (a server briefly away) is reported and the watch goes on; the +// next scan compares against the last good one, so nothing is lost. +func followTaskStatuses(ctx context.Context, b backend.Backend, opts vault.ParseTasksOptions, previous []vault.Task, changed <-chan struct{}, asJSON bool) error { + for { + select { + case <-ctx.Done(): + return nil + case _, ok := <-changed: + if !ok { + return nil + } + } + next, err := b.ScanTasks(ctx, opts) + if err != nil { + if ctx.Err() != nil { + return nil + } + emitWatchError(err, asJSON) + continue + } + for _, change := range vault.DiffTaskStatuses(previous, next) { + emitTaskChange(change, asJSON) + } + previous = next + } +} + +// watchVaultChanges signals each time the vault may have changed, holding at +// most one signal while a scan runs. A local vault reports through its file +// events, batched 300 ms after the last one. A server pushes its changes over +// /api/watch; while that stream is down (a server or proxy without it) the +// server is asked every interval instead. +func watchVaultChanges(ctx context.Context, b backend.Backend, interval time.Duration) (<-chan struct{}, func(), error) { + changed := make(chan struct{}, 1) + signal := func() { + select { + case changed <- struct{}{}: + default: + } + } + if root := b.Root(); root != "" { + watcher, err := fswatch.Start(root) + if err != nil { + return nil, nil, err + } + go func() { + defer close(changed) + for { + if _, ok := watcher.Next(); !ok { + return + } + signal() + } + }() + return changed, watcher.Close, nil + } + server, ok := b.(*backend.Remote) + if !ok { + return nil, nil, errors.New("this vault cannot be watched") + } + ctx, cancel := context.WithCancel(ctx) + go followServerChanges(ctx, server.Client(), interval, signal) + return changed, cancel, nil +} + +// followServerChanges signals on each change the server pushes, once they +// settle, until ctx ends. Every (re)connection signals too, for whatever +// changed while the stream was down. Without a stream it signals every +// interval, and tries the stream again after taskWatchRetry. +func followServerChanges(ctx context.Context, client *remote.Client, interval time.Duration, signal func()) { + for ctx.Err() == nil { + if events, err := client.WatchChanges(ctx); err == nil { + signal() + var settle <-chan time.Time + live: + for { + select { + case <-ctx.Done(): + return + case event, ok := <-events: + if !ok || event.Err != nil { + break live + } + settle = time.After(taskWatchSettle) + case <-settle: + settle = nil + signal() + } + } + } + poll := time.NewTicker(interval) + retry := time.NewTimer(taskWatchRetry) + for polling := true; polling; { + select { + case <-ctx.Done(): + polling = false + case <-poll.C: + signal() + case <-retry.C: + polling = false + } + } + poll.Stop() + retry.Stop() + } +} + +func emitTaskChange(change vault.TaskStatusChange, asJSON bool) { + t := change.Task + if asJSON { + emitJSONLine(taskWatchEvent{ + At: time.Now().UTC().Format(time.RFC3339), + ID: t.ID, + Path: t.SourcePath, + Link: t.Link, + Title: t.NoteTitle, + Kind: t.Kind, + Content: t.Content, + PreviousStatus: change.PreviousStatus, + Status: change.Status, + LineNumber: t.LineNumber, + }) + return + } + emitLine(fmt.Sprintf("%s %s → %s %s %s", time.Now().Format("15:04:05"), change.PreviousStatus, change.Status, truncate(t.Content, 80), t.SourcePath)) +} + +// emitWatchError reports a failed scan without ending the watch, as JSON on +// stderr when the output is JSON, the way a failed command reports. +func emitWatchError(err error, asJSON bool) { + if !asJSON { + emitError(err.Error()) + return + } + _ = json.NewEncoder(stderr).Encode(map[string]any{"error": map[string]string{"code": "error", "message": err.Error()}}) +} diff --git a/internal/cli/format.go b/internal/cli/format.go index a3d5fd4..d218bfa 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -33,6 +33,19 @@ func emitJSON(value any) { _, _ = stdout.Write(buf.Bytes()) } +// emitJSONLine writes value as one line of JSON: the shape a stream of events +// takes, so a reader can act on each line as it arrives. +func emitJSONLine(value any) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(value); err != nil { + fmt.Fprintln(stderr, "zn: "+err.Error()) + return + } + _, _ = stdout.Write(buf.Bytes()) +} + func emitLine(line string) { fmt.Fprintln(stdout, line) } diff --git a/internal/cli/help.go b/internal/cli/help.go index ff110f7..9ae9fa9 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -66,6 +66,7 @@ var helpSections = []helpSection{ {"TASKS", []helpRow{ {"task list", "Open checkbox tasks across all notes", "--unchecked --all --tag --include-excluded --json"}, {"task toggle ", "Flip a task checkbox by stable id", ""}, + {"task watch", "Print task status changes as they happen. A server that cannot push them is asked every --interval seconds (default 5)", "--interval --include-excluded --json"}, }}, {"COMMENTS", []helpRow{ {"comment list ", "Comment threads on a note, with anchors and replies", "--all --json"}, @@ -163,6 +164,7 @@ var examples = []string{ `zn list --server home # a self-hosted ZenNotes server`, `zn capture "from CI" --server https://notes.example.com`, `zn task list --unchecked --tag work`, + `zn task watch --json | jq -c 'select(.status == "ready")'`, `zn tui # the whole app, in the terminal`, `zn open ~/Downloads/notes.md`, `zn open ~/code/project/docs # focus a folder as a session`, diff --git a/internal/cli/main.go b/internal/cli/main.go index de1f890..abf2ebe 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -44,6 +44,7 @@ func dispatchTable() map[string]Handler { "tag find": cmdTagFind, "task list": cmdTaskList, "task toggle": cmdTaskToggle, + "task watch": cmdTaskWatch, "comment list": cmdCommentList, "comment add": cmdCommentAdd, "comment reply": cmdCommentReply, diff --git a/internal/cli/task_watch_test.go b/internal/cli/task_watch_test.go new file mode 100644 index 0000000..fa470ab --- /dev/null +++ b/internal/cli/task_watch_test.go @@ -0,0 +1,339 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/remote" + "github.com/ZenNotes/tui/internal/vault" + "github.com/coder/websocket" +) + +// syncBuffer is output the watch writes while the test reads it. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.Write(p) +} + +func (s *syncBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.String() +} + +func writeVaultFile(t *testing.T, root, rel, body string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func localBackend(t *testing.T, root string) backend.Backend { + t.Helper() + b, err := backend.New(backend.Target{Kind: backend.KindLocal, Root: root}, backend.Options{}) + if err != nil { + t.Fatal(err) + } + return b +} + +func waitFor(t *testing.T, what string, done func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !done() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(10 * time.Millisecond) + } +} + +// watchTasks runs the watch loop over root with a hand-driven change signal, +// so each scan happens exactly when the test says. +func watchTasks(t *testing.T, root string, asJSON bool) (output *syncBuffer, changed chan<- struct{}, stop func()) { + t.Helper() + b := localBackend(t, root) + opts := vault.ParseTasksOptions{Dialect: vault.DialectApp} + previous, err := b.ScanTasks(context.Background(), opts) + if err != nil { + t.Fatal(err) + } + output = &syncBuffer{} + prevOut, prevErr := stdout, stderr + stdout, stderr = output, output + signals := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- followTaskStatuses(ctx, b, opts, previous, signals, asJSON) }() + return output, signals, func() { + cancel() + if err := <-done; err != nil { + t.Errorf("the watch ended with %v", err) + } + stdout, stderr = prevOut, prevErr + } +} + +func TestTaskWatchPrintsOneJSONLinePerStatusChange(t *testing.T) { + root := t.TempDir() + writeVaultFile(t, root, "inbox/Ship it.md", "---\ntags: [task]\nstatus: backlog\n---\nBuild the thing.\n") + writeVaultFile(t, root, "inbox/Plan.md", "# Plan\n\n- [ ] Write docs @status:backlog\n") + output, changed, stop := watchTasks(t, root, true) + + // Each send waits for the scan before it to finish. The first edit keeps + // every status, so it prints nothing. + writeVaultFile(t, root, "inbox/Ship it.md", "---\ntags: [task]\nstatus: backlog\n---\nBuild the thing, carefully.\n") + changed <- struct{}{} + writeVaultFile(t, root, "inbox/Ship it.md", "---\ntags: [task]\nstatus: ready\n---\nBuild the thing, carefully.\n") + changed <- struct{}{} + writeVaultFile(t, root, "inbox/Plan.md", "# Plan\n\n- [ ] Write docs @status:review\n") + changed <- struct{}{} + waitFor(t, "two changes", func() bool { return strings.Count(output.String(), "\n") >= 2 }) + stop() + + lines := strings.Split(strings.TrimSuffix(output.String(), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("want 2 lines, got %d:\n%s", len(lines), output.String()) + } + var events [2]map[string]any + for i, line := range lines { + if err := json.Unmarshal([]byte(line), &events[i]); err != nil { + t.Fatalf("line %d is not one JSON object: %v\n%s", i+1, err, line) + } + at, _ := events[i]["at"].(string) + if _, err := time.Parse(time.RFC3339, at); err != nil { + t.Errorf("line %d: at %q: %v", i+1, at, err) + } + if link, _ := events[i]["link"].(string); !strings.HasPrefix(link, "zennotes://open?path=") { + t.Errorf("line %d: link %q", i+1, link) + } + delete(events[i], "at") + delete(events[i], "link") + } + want := [2]map[string]any{ + {"id": "inbox/Ship it.md#task", "path": "inbox/Ship it.md", "title": "Ship it", "kind": "file", + "content": "Ship it", "previousStatus": "backlog", "status": "ready", "lineNumber": float64(0)}, + {"id": "inbox/Plan.md#0", "path": "inbox/Plan.md", "title": "Plan", + "content": "Write docs", "previousStatus": "backlog", "status": "review", "lineNumber": float64(2)}, + } + for i := range want { + if !mapsEqual(events[i], want[i]) { + t.Errorf("line %d:\n got %v\nwant %v", i+1, events[i], want[i]) + } + } +} + +func mapsEqual(a, b map[string]any) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +func TestTaskWatchPrintsAReadableLineWithoutJSON(t *testing.T) { + root := t.TempDir() + writeVaultFile(t, root, "inbox/Plan.md", "- [ ] Write docs\n") + output, changed, stop := watchTasks(t, root, false) + writeVaultFile(t, root, "inbox/Plan.md", "- [x] Write docs\n") + changed <- struct{}{} + waitFor(t, "the change", func() bool { return strings.Contains(output.String(), "\n") }) + stop() + if line := output.String(); !strings.HasSuffix(line, " open → done Write docs inbox/Plan.md\n") { + t.Fatalf("line: %q", line) + } +} + +func TestTaskWatchSignalsOnALocalVaultsFileChanges(t *testing.T) { + root := t.TempDir() + writeVaultFile(t, root, "inbox/Plan.md", "- [ ] Write docs\n") + changed, stop, err := watchVaultChanges(context.Background(), localBackend(t, root), time.Hour) + if err != nil { + t.Fatal(err) + } + writeVaultFile(t, root, "inbox/Plan.md", "- [x] Write docs\n") + select { + case <-changed: + case <-time.After(5 * time.Second): + t.Fatal("no signal after a note changed") + } + stop() + deadline := time.After(5 * time.Second) + for { + select { + case _, open := <-changed: + if !open { + return + } + case <-deadline: + t.Fatal("the signal did not close once the watch stopped") + } + } +} + +func TestTaskWatchFollowsAServersPushedChanges(t *testing.T) { + push := make(chan string) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/watch" { + http.NotFound(w, r) + return + } + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.CloseNow() + for path := range push { + if conn.Write(r.Context(), websocket.MessageText, []byte(`{"kind":"change","path":"`+path+`"}`)) != nil { + return + } + } + })) + defer server.Close() + defer close(push) + + signals := make(chan struct{}, 8) + ctx, cancel := context.WithCancel(context.Background()) + ended := make(chan struct{}) + go func() { + followServerChanges(ctx, remote.NewClient(server.URL, ""), time.Hour, func() { signals <- struct{}{} }) + close(ended) + }() + expect := func(what string) { + t.Helper() + select { + case <-signals: + case <-time.After(5 * time.Second): + t.Fatalf("no signal %s", what) + } + } + expect("on connecting, for whatever changed before") + push <- "inbox/Plan.md" + push <- "inbox/Plan.md" + expect("once the pushed changes settle") + select { + case <-signals: + t.Fatal("one burst of changes signalled twice") + case <-time.After(3 * taskWatchSettle): + } + cancel() + select { + case <-ended: + case <-time.After(5 * time.Second): + t.Fatal("the server watch did not end with its context") + } +} + +func TestTaskWatchPollsAServerWithoutAStream(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + signals := make(chan struct{}, 1) + ctx, cancel := context.WithCancel(context.Background()) + ended := make(chan struct{}) + go func() { + followServerChanges(ctx, remote.NewClient(server.URL, ""), 20*time.Millisecond, func() { + select { + case signals <- struct{}{}: + default: + } + }) + close(ended) + }() + for i := 0; i < 3; i++ { + select { + case <-signals: + case <-time.After(5 * time.Second): + t.Fatalf("poll %d never came", i+1) + } + } + cancel() + select { + case <-ended: + case <-time.After(5 * time.Second): + t.Fatal("polling did not end with its context") + } +} + +func TestTaskWatchRejectsABadInterval(t *testing.T) { + root := isolatedCLI(t) + for _, value := range []string{"0", "-1", "soon", "1.5"} { + out := captureOutput(t, func() { + if code := Main([]string{"task", "watch", "--interval", value, "--vault", root}); code == 0 { + t.Errorf("--interval %s was accepted", value) + } + }) + if !strings.Contains(out, "--interval") { + t.Errorf("--interval %s: %s", value, out) + } + } +} + +// `zn task list --json` says which status column a task is in, as the app +// does, without changing the content or ids the desktop's CLI gives +// (ZenNotes/zennotes#941). +func TestTaskListJSONCarriesTheStatus(t *testing.T) { + isolatedCLI(t) + root := t.TempDir() + writeVaultFile(t, root, "inbox/Ship it.md", "---\ntags: [task]\nstatus: Ready\n---\nBuild it.\n") + writeVaultFile(t, root, "inbox/Plan.md", "---\nstatus: backlog\n---\n- [ ] Write docs @status:review\n- [ ] Inherits\n") + writeVaultFile(t, root, "inbox/Loose.md", "- [ ] Plain\n") + out := captureOutput(t, func() { + if code := Main([]string{"task", "list", "--json", "--vault", root}); code != 0 { + t.Errorf("exit %d", code) + } + }) + var tasks []struct { + ID string `json:"id"` + Content string `json:"content"` + Status *string `json:"status"` + } + if err := json.Unmarshal([]byte(out), &tasks); err != nil { + t.Fatalf("%v\n%s", err, out) + } + got := map[string]string{} + for _, task := range tasks { + status := "(none)" + if task.Status != nil { + status = *task.Status + } + got[task.ID+" "+task.Content] = status + } + want := map[string]string{ + "inbox/Ship it.md#task Ship it": "ready", + "inbox/Plan.md#0 Write docs @status:review": "review", + "inbox/Plan.md#1 Inherits": "backlog", + "inbox/Loose.md#0 Plain": "(none)", + } + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for key, status := range want { + if got[key] != status { + t.Errorf("%s: status %q, want %q (all: %v)", key, got[key], status, got) + } + } +} diff --git a/internal/tui/watcher.go b/internal/fswatch/fswatch.go similarity index 66% rename from internal/tui/watcher.go rename to internal/fswatch/fswatch.go index 8e3b94d..2967c76 100644 --- a/internal/tui/watcher.go +++ b/internal/fswatch/fswatch.go @@ -1,4 +1,6 @@ -package tui +// Package fswatch batches file-system events under a vault root: the terminal +// UI's live reload and `zn task watch` (ZenNotes/zennotes#941) share it. +package fswatch import ( "os" @@ -10,8 +12,8 @@ import ( "github.com/fsnotify/fsnotify" ) -// vaultWatcher batches file-system events under the vault root. -type vaultWatcher struct { +// Watcher batches file-system events under the vault root. +type Watcher struct { root string w *fsnotify.Watcher batches chan []string @@ -19,18 +21,20 @@ type vaultWatcher struct { once sync.Once } -func startWatcher(root string) (*vaultWatcher, error) { +// Start watches root and every folder under it, folders created later +// included, and batches changes 300 ms after the last one. +func Start(root string) (*Watcher, error) { w, err := fsnotify.NewWatcher() if err != nil { return nil, err } - vw := &vaultWatcher{root: root, w: w, batches: make(chan []string, 4), closeCh: make(chan struct{})} + vw := &Watcher{root: root, w: w, batches: make(chan []string, 4), closeCh: make(chan struct{})} _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return nil } if d.IsDir() { - if skipDir(d.Name()) && path != root { + if SkipDir(d.Name()) && path != root { return filepath.SkipDir } _ = w.Add(path) @@ -41,11 +45,12 @@ func startWatcher(root string) (*vaultWatcher, error) { return vw, nil } -func skipDir(name string) bool { +// SkipDir names the folders never watched: tool and trash folders. +func SkipDir(name string) bool { return name == ".git" || name == "node_modules" || name == ".obsidian" || name == ".trash" } -func (vw *vaultWatcher) loop() { +func (vw *Watcher) loop() { pending := map[string]bool{} var timer *time.Timer var timerCh <-chan time.Time @@ -77,7 +82,7 @@ func (vw *vaultWatcher) loop() { continue } if ev.Has(fsnotify.Create) { - if info, err := os.Stat(name); err == nil && info.IsDir() && !skipDir(base) { + if info, err := os.Stat(name); err == nil && info.IsDir() && !SkipDir(base) { _ = vw.w.Add(name) } } @@ -104,8 +109,9 @@ func (vw *vaultWatcher) loop() { } } -// next blocks until a batch of changed vault-relative paths arrives. -func (vw *vaultWatcher) next() ([]string, bool) { +// Next blocks until a batch of changed vault-relative paths arrives; false +// once the watcher is closed. +func (vw *Watcher) Next() ([]string, bool) { select { case paths, ok := <-vw.batches: return paths, ok @@ -114,7 +120,8 @@ func (vw *vaultWatcher) next() ([]string, bool) { } } -func (vw *vaultWatcher) close() { +// Close stops watching; safe to call more than once. +func (vw *Watcher) Close() { vw.once.Do(func() { close(vw.closeCh) _ = vw.w.Close() diff --git a/internal/tui/app.go b/internal/tui/app.go index d222a90..a06e6ae 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -16,6 +16,7 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/fswatch" "github.com/ZenNotes/tui/internal/keymaps" "github.com/ZenNotes/tui/internal/themes" "github.com/ZenNotes/tui/internal/vault" @@ -124,7 +125,7 @@ type App struct { toasts []toast nextToastID int - watcher *vaultWatcher + watcher *fswatch.Watcher ignoredPaths map[string]time.Time sessionDirty bool sessionSaved time.Time @@ -249,7 +250,7 @@ func (a *App) Init() tea.Cmd { a.loadIndexCmd(), } if a.opts.Target.Kind == backend.KindLocal { - w, err := startWatcher(a.opts.Target.Root) + w, err := fswatch.Start(a.opts.Target.Root) if err == nil { a.watcher = w cmds = append(cmds, a.watchCmd()) @@ -265,7 +266,7 @@ func (a *App) watchCmd() tea.Cmd { return nil } return func() tea.Msg { - paths, ok := watcher.next() + paths, ok := watcher.Next() if !ok { return nil } @@ -286,7 +287,7 @@ func (a *App) shutdown() error { } a.saveSession() if a.watcher != nil { - a.watcher.close() + a.watcher.Close() } return err } diff --git a/internal/tui/commands_parity.go b/internal/tui/commands_parity.go index 03a4544..f041189 100644 --- a/internal/tui/commands_parity.go +++ b/internal/tui/commands_parity.go @@ -12,6 +12,7 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/fswatch" "github.com/ZenNotes/tui/internal/periodic" "github.com/ZenNotes/tui/internal/remote" "github.com/ZenNotes/tui/internal/templates" @@ -250,7 +251,7 @@ func (a *App) switchTarget(target backend.Target) { } a.saveSession() if a.watcher != nil { - a.watcher.close() + a.watcher.Close() a.watcher = nil } if a.remoteWatchCancel != nil { @@ -291,7 +292,7 @@ func (a *App) switchTarget(target backend.Target) { a.pendingOpen = "" a.layout() if target.Kind == backend.KindLocal { - if w, err := startWatcher(target.Root); err == nil { + if w, err := fswatch.Start(target.Root); err == nil { a.watcher = w a.queue(a.watchCmd()) } diff --git a/internal/tui/workspace_source_test.go b/internal/tui/workspace_source_test.go index 35adb7b..77c565e 100644 --- a/internal/tui/workspace_source_test.go +++ b/internal/tui/workspace_source_test.go @@ -47,7 +47,7 @@ func TestDesktopLauncherDoesNotChangeTUIVaultSwitches(t *testing.T) { a := newApp(context.Background(), Options{Backend: b, Target: target, WorkspaceSource: tc.source}, config.Prefs{}, true) t.Cleanup(func() { if a.watcher != nil { - a.watcher.close() + a.watcher.Close() } }) for _, cmd := range a.parityCommands() { diff --git a/internal/vault/tasks.go b/internal/vault/tasks.go index 6c2cc63..b011cdc 100644 --- a/internal/vault/tasks.go +++ b/internal/vault/tasks.go @@ -213,7 +213,9 @@ func parseTaskFile(path, title string, folder NoteFolder, body string, dialect T CompletedDate: normalizeDueDate(firstScalar(fm["completeddate"])), } if dialect == DialectCLI { - task.Fields, task.Status = nil, "" + // The CLI's JSON keeps the desktop CLI's key set, but says which status + // column the task is in, as the app does (ZenNotes/zennotes#941). + task.Fields = nil } return task, true } @@ -275,10 +277,18 @@ func ParseTasks(path, title string, folder NoteFolder, body string, opts ParseTa priority = defaults.Priority } fields := tokens.fields + status := "" if opts.Dialect == DialectCLI { + // The CLI leaves `@key:value` in the content and reports no fields, + // but its JSON still says which status column the task is in, read + // the way the app reads it (ZenNotes/zennotes#941). fields = nil - } else if _, hasStatus := fields["status"]; !hasStatus && defaults.Status != "" { - fields["status"] = defaults.Status + status = inlineStatus(tail, defaults.Status) + } else { + if _, hasStatus := fields["status"]; !hasStatus && defaults.Status != "" { + fields["status"] = defaults.Status + } + status = fields["status"] } content := tokens.stripped if content == "" { @@ -302,7 +312,7 @@ func ParseTasks(path, title string, folder NoteFolder, body string, opts ParseTa Priority: priority, Waiting: tokens.waiting, Fields: fields, - Status: fields["status"], + Status: status, Tags: tokens.tags, }) taskIndex++ @@ -318,6 +328,18 @@ type taskTokens struct { stripped string } +// inlineStatus is the `@status:` a checkbox line carries, or the note's +// frontmatter status when it carries none: the status the app's grammar gives +// the line, without stripping anything from the content. +func inlineStatus(tail, fallback string) string { + for _, fm := range inlineFieldRe.FindAllStringSubmatch(tail, -1) { + if strings.ToLower(fm[1]) == "status" { + return strings.ToLower(fm[2]) + } + } + return fallback +} + func extractTaskTokens(tail string, dialect TaskDialect) taskTokens { t := taskTokens{fields: map[string]string{}, tags: []string{}} stripped := tail diff --git a/internal/vault/taskwatch.go b/internal/vault/taskwatch.go new file mode 100644 index 0000000..ba96547 --- /dev/null +++ b/internal/vault/taskwatch.go @@ -0,0 +1,91 @@ +package vault + +import ( + "strconv" + "strings" +) + +// TaskStatusChange is one task whose status differs between two scans of the +// vault: what `zn task watch` reports (ZenNotes/zennotes#941). +type TaskStatusChange struct { + // Task is the task as the newer scan found it. + Task Task + PreviousStatus string + Status string +} + +// TaskStatusName is the status a task is in, read the way the app's boards +// place it. A task note's status is its frontmatter `status:` (open when it +// has none), done and cancelled included. A checkbox line is done, cancelled +// or forwarded once its box says so, since a board grouped by status drops +// those cards; until then it is its `@status:` (or the note's frontmatter +// status it inherits), the column it sits in on that board, and failing that +// waiting, in progress or open, as the built-in board has it (`@waiting` +// wins over `[/]`). +func TaskStatusName(t Task) string { + custom := strings.ToLower(strings.TrimSpace(t.Status)) + if t.Kind == "file" && custom != "" { + return custom + } + switch { + case t.Forwarded: + return "forwarded" + case t.Cancelled: + return "cancelled" + case t.Checked: + return "done" + case custom != "": + return custom + case t.Waiting: + return "waiting" + case t.InProgress: + return "in-progress" + default: + return "open" + } +} + +// taskIdentity says which task in one scan is which task in the next. A task +// note is its id (`path#task`). A checkbox task's id is its position in the +// note, which a line added above it shifts, so it is matched by its text +// instead: the same words in the same note, counted when they repeat. Scans +// must use the app's grammar, whose content leaves `@status:` and the other +// fields out, so that a status change never reads as a different task, and +// a change to the words does (it is an edit, not a status change). +type taskIdentity struct { + seen map[string]int +} + +func (k *taskIdentity) of(t Task) string { + if t.Kind == "file" { + return t.ID + } + base := t.SourcePath + "\x00" + strings.TrimSpace(t.Content) + n := k.seen[base] + k.seen[base] = n + 1 + return base + "\x00" + strconv.Itoa(n) +} + +// DiffTaskStatuses lists the tasks found in both scans whose status changed, +// in the order of the newer scan. Tasks that appear, disappear, move to +// another note or have their words edited are not status changes. +func DiffTaskStatuses(previous, next []Task) []TaskStatusChange { + before := map[string]Task{} + keys := taskIdentity{seen: map[string]int{}} + for _, t := range previous { + before[keys.of(t)] = t + } + var changes []TaskStatusChange + keys = taskIdentity{seen: map[string]int{}} + for _, t := range next { + old, ok := before[keys.of(t)] + if !ok { + continue + } + from, to := TaskStatusName(old), TaskStatusName(t) + if from != to { + changes = append(changes, TaskStatusChange{Task: t, PreviousStatus: from, Status: to}) + } + } + return changes +} diff --git a/internal/vault/taskwatch_test.go b/internal/vault/taskwatch_test.go new file mode 100644 index 0000000..6a420d3 --- /dev/null +++ b/internal/vault/taskwatch_test.go @@ -0,0 +1,120 @@ +package vault + +import ( + "reflect" + "testing" +) + +const watchedNote = "inbox/Plan.md" + +// watchScan reads a note the way `zn task watch` scans: in the app's grammar. +func watchScan(path, body string) []Task { + return ParseTasks(path, "Plan", FolderInbox, body, ParseTasksOptions{Dialect: DialectApp}) +} + +type statusMove struct{ ID, From, To string } + +func statusMoves(changes []TaskStatusChange) []statusMove { + out := []statusMove{} + for _, c := range changes { + out = append(out, statusMove{c.Task.ID, c.PreviousStatus, c.Status}) + } + return out +} + +func TestDiffTaskStatusesReportsEveryWayAStatusMoves(t *testing.T) { + const fileTask = watchedNote + "#task" + const firstLine = watchedNote + "#0" + for _, c := range []struct { + name string + before, after string + want statusMove + }{ + {"a task note moves column", + "---\ntags: [task]\nstatus: backlog\n---\nBuild it.\n", + "---\ntags: [task]\nstatus: ready\n---\nBuild it.\n", + statusMove{fileTask, "backlog", "ready"}}, + {"a task note gets its first status", + "---\ntags: [task]\n---\nBuild it.\n", + "---\ntags: [task]\nstatus: backlog\n---\nBuild it.\n", + statusMove{fileTask, "open", "backlog"}}, + {"a task note is checked off, keeping its own word for done", + "---\ntags: [task]\nstatus: ready for review\n---\nBuild it.\n", + "---\ntags: [task]\nstatus: completed\ncompletedDate: 2026-10-14\n---\nBuild it.\n", + statusMove{fileTask, "ready for review", "completed"}}, + {"a line's @status changes, read in lower case", + "- [ ] Write docs @status:backlog\n", + "- [ ] Write docs @status:Ready\n", + statusMove{firstLine, "backlog", "ready"}}, + {"a line is dropped into a status column", + "- [ ] Write docs\n", + "- [ ] Write docs @status:ready\n", + statusMove{firstLine, "open", "ready"}}, + {"a line follows its note's status", + "---\nstatus: backlog\n---\n- [ ] Write docs\n", + "---\nstatus: ready\n---\n- [ ] Write docs\n", + statusMove{firstLine, "backlog", "ready"}}, + {"a line is checked", + "- [ ] Write docs\n", + "- [x] Write docs\n", + statusMove{firstLine, "open", "done"}}, + {"a line in a status column is checked, which takes it off the board", + "- [ ] Write docs @status:review\n", + "- [x] Write docs @status:review\n", + statusMove{firstLine, "review", "done"}}, + {"a started line starts waiting", + "- [/] Write docs\n", + "- [/] Write docs @waiting\n", + statusMove{firstLine, "in-progress", "waiting"}}, + {"a line is cancelled", + "- [ ] Write docs\n", + "- [-] Write docs\n", + statusMove{firstLine, "open", "cancelled"}}, + } { + got := statusMoves(DiffTaskStatuses(watchScan(watchedNote, c.before), watchScan(watchedNote, c.after))) + if want := []statusMove{c.want}; !reflect.DeepEqual(got, want) { + t.Errorf("%s: got %+v, want %+v", c.name, got, want) + } + } +} + +// Saving a task is not a status change: issue #941 asks that only a real move +// start the user's script. +func TestDiffTaskStatusesIgnoresEditsThatKeepTheStatus(t *testing.T) { + for _, c := range []struct{ name, before, after string }{ + {"the task's words are edited", + "- [ ] Write docs @status:backlog\n", + "- [ ] Write the docs @status:backlog\n"}, + {"a task is added above, shifting its position", + "- [ ] Write docs @status:ready\n", + "- [ ] Plan first @status:backlog\n- [ ] Write docs @status:ready\n"}, + {"a task note's body is edited", + "---\ntags: [task]\nstatus: ready\n---\nBuild it.\n", + "---\ntags: [task]\nstatus: ready\n---\nBuild it, then ship it.\n"}, + {"other metadata changes", + "- [ ] Write docs @status:ready due:2026-10-20\n", + "- [ ] Write docs @status:ready due:2026-10-21 !high #docs\n"}, + {"a task is removed", + "- [ ] Write docs\n- [ ] Ship\n", + "- [ ] Ship\n"}, + {"the status is written in another case", + "- [ ] Write docs @status:Ready\n", + "- [ ] Write docs @status:ready\n"}, + } { + if got := DiffTaskStatuses(watchScan(watchedNote, c.before), watchScan(watchedNote, c.after)); len(got) != 0 { + t.Errorf("%s: reported %+v", c.name, statusMoves(got)) + } + } +} + +// A change is reported with the task as the newer scan has it, and tasks with +// the same words stay apart: by their order within a note, and by note. +func TestDiffTaskStatusesMatchesTasksByNoteAndWords(t *testing.T) { + before := append(watchScan(watchedNote, "- [ ] Call Sam\n- [ ] Call Sam\n"), watchScan("inbox/Other.md", "- [ ] Call Sam\n")...) + after := append(watchScan(watchedNote, "- [ ] New first\n- [ ] Call Sam\n- [x] Call Sam\n"), watchScan("inbox/Other.md", "- [ ] Call Sam\n")...) + got := statusMoves(DiffTaskStatuses(before, after)) + want := []statusMove{{watchedNote + "#2", "open", "done"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v, want %+v", got, want) + } +} From 58b0e5683bdc35049e7e592998a3f236093e7557 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 14 Oct 2026 11:26:02 -0500 Subject: [PATCH 2/2] Fix(tasks): zn task watch keeps a change made while a note is rewritten The first CI run of the task watch failed on Linux. Most of the failures were the test's own: when two notes changed before the watch scanned again, both changes came out of one scan in vault order, and the test expected them in the order it wrote them. One failure was real. A scan that read a note while it was being rewritten in place saw an empty file, so the task was missing from that scan, and the next scan compared against it: the task looked new, and its status change was never printed. An editor that saves in place, or a sync tool that deletes a file before it writes the new one, could hide a change the same way. The watch now keeps the last status of every task it has seen (vault.TaskStatusTracker), so a task that drops out of one scan and comes back with another status is still reported, once. A task seen for the first time is still only remembered. DiffTaskStatuses is the same comparison of two scans, now built on the tracker. The JSON-lines test waits for each change before it makes the next one, and a new test drops a task note from one scan and brings it back with a new status. On a Linux box the old test failed 54 times in 100; the watch tests now pass 300 times in a row there, and vet, the full suite and -race pass on macOS. zn mcp's list_tasks description now names the status field each task carries, and its status argument says it filters by state, not by that column. The desktop's own MCP server gets the same field and the same words in ZenNotes/zennotes. --- internal/cli/commands_task_watch.go | 16 ++++----- internal/cli/task_watch_test.go | 14 ++++++-- internal/mcp/server.go | 4 +-- internal/vault/taskwatch.go | 51 +++++++++++++++++++---------- internal/vault/taskwatch_test.go | 24 ++++++++++++++ 5 files changed, 79 insertions(+), 30 deletions(-) diff --git a/internal/cli/commands_task_watch.go b/internal/cli/commands_task_watch.go index 37d6ce7..c2f58ef 100644 --- a/internal/cli/commands_task_watch.go +++ b/internal/cli/commands_task_watch.go @@ -60,7 +60,7 @@ func cmdTaskWatch(ctx context.Context, b backend.Backend, args Args) error { return err } defer stop() - previous, err := b.ScanTasks(ctx, opts) + first, err := b.ScanTasks(ctx, opts) if err != nil { return err } @@ -68,14 +68,15 @@ func cmdTaskWatch(ctx context.Context, b backend.Backend, args Args) error { if !asJSON { fmt.Fprintf(stderr, "Watching %s for task status changes. Ctrl+C stops.\n", b.Label()) } - return followTaskStatuses(ctx, b, opts, previous, changed, asJSON) + return followTaskStatuses(ctx, b, opts, first, changed, asJSON) } // followTaskStatuses scans again on each signal and reports every task whose -// status moved since the scan before, until ctx ends or changed closes. A -// failed scan (a server briefly away) is reported and the watch goes on; the -// next scan compares against the last good one, so nothing is lost. -func followTaskStatuses(ctx context.Context, b backend.Backend, opts vault.ParseTasksOptions, previous []vault.Task, changed <-chan struct{}, asJSON bool) error { +// status moved since it was last seen, until ctx ends or changed closes. A +// failed scan (a server briefly away) is reported and the watch goes on, and +// a task a scan misses keeps its last status, so no change is lost. +func followTaskStatuses(ctx context.Context, b backend.Backend, opts vault.ParseTasksOptions, first []vault.Task, changed <-chan struct{}, asJSON bool) error { + tracker := vault.NewTaskStatusTracker(first) for { select { case <-ctx.Done(): @@ -93,10 +94,9 @@ func followTaskStatuses(ctx context.Context, b backend.Backend, opts vault.Parse emitWatchError(err, asJSON) continue } - for _, change := range vault.DiffTaskStatuses(previous, next) { + for _, change := range tracker.Update(next) { emitTaskChange(change, asJSON) } - previous = next } } diff --git a/internal/cli/task_watch_test.go b/internal/cli/task_watch_test.go index fa470ab..d0c2167 100644 --- a/internal/cli/task_watch_test.go +++ b/internal/cli/task_watch_test.go @@ -100,15 +100,23 @@ func TestTaskWatchPrintsOneJSONLinePerStatusChange(t *testing.T) { writeVaultFile(t, root, "inbox/Plan.md", "# Plan\n\n- [ ] Write docs @status:backlog\n") output, changed, stop := watchTasks(t, root, true) - // Each send waits for the scan before it to finish. The first edit keeps - // every status, so it prints nothing. + // A send returns once the loop has the signal, while its scan may still + // be reading, so a write after it can land mid-scan; the scan then sees + // the note empty or rewritten, and either way the change is printed once. + // Waiting for each line before the next write keeps their order. + printed := func() int { return strings.Count(output.String(), "\n") } + // An edit that keeps every status prints nothing. writeVaultFile(t, root, "inbox/Ship it.md", "---\ntags: [task]\nstatus: backlog\n---\nBuild the thing, carefully.\n") changed <- struct{}{} + changed <- struct{}{} writeVaultFile(t, root, "inbox/Ship it.md", "---\ntags: [task]\nstatus: ready\n---\nBuild the thing, carefully.\n") changed <- struct{}{} + waitFor(t, "the task note's change", func() bool { return printed() >= 1 }) writeVaultFile(t, root, "inbox/Plan.md", "# Plan\n\n- [ ] Write docs @status:review\n") changed <- struct{}{} - waitFor(t, "two changes", func() bool { return strings.Count(output.String(), "\n") >= 2 }) + waitFor(t, "the checkbox task's change", func() bool { return printed() >= 2 }) + changed <- struct{}{} + changed <- struct{}{} stop() lines := strings.Split(strings.TrimSuffix(output.String(), "\n"), "\n") diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 0756f7a..434aa45 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -727,9 +727,9 @@ func tools() []toolDef { }), { name: "list_tasks", - description: "Return every task (markdown checkbox) in live notes with parsed metadata: due date, priority, @waiting, tags. Filter by status, priority, date range, tag, or folder.", + description: "Return every task (markdown checkbox) in live notes with parsed metadata: due date, priority, @waiting, tags, and status, the column a Kanban board grouped by status shows it in (a task note’s `status:`, a line’s `@status:`, or the frontmatter `status:` of its note). Filter by state, priority, date range, tag, or folder.", schema: `{"type":"object","properties":{ -"status":{"type":"string","enum":["open","done","waiting","all"],"description":"open = not done, cancelled or waiting (default). done = checked. waiting = has @waiting. all = everything."}, +"status":{"type":"string","enum":["open","done","waiting","all"],"description":"The task’s state, not its status column. open = not done, cancelled or waiting (default). done = checked. waiting = has @waiting. all = everything."}, "priority":{"type":"string","enum":["high","med","low"],"description":"Only tasks at this priority."}, "dueBefore":{"type":"string","description":"YYYY-MM-DD exclusive upper bound."}, "dueAfter":{"type":"string","description":"YYYY-MM-DD inclusive lower bound."}, diff --git a/internal/vault/taskwatch.go b/internal/vault/taskwatch.go index ba96547..4758e91 100644 --- a/internal/vault/taskwatch.go +++ b/internal/vault/taskwatch.go @@ -66,26 +66,43 @@ func (k *taskIdentity) of(t Task) string { return base + "\x00" + strconv.Itoa(n) } -// DiffTaskStatuses lists the tasks found in both scans whose status changed, -// in the order of the newer scan. Tasks that appear, disappear, move to -// another note or have their words edited are not status changes. -func DiffTaskStatuses(previous, next []Task) []TaskStatusChange { - before := map[string]Task{} +// TaskStatusTracker remembers the last status of every task it has seen, so +// a task that drops out of one scan and comes back with another status is +// still reported. A scan can miss a task that is still there: a note read +// while an editor rewrites it in place is empty for that moment, and a sync +// tool may delete a file before it writes the new one. +type TaskStatusTracker struct { + last map[string]string +} + +// NewTaskStatusTracker starts from a first scan, whose tasks are known but +// not reported. +func NewTaskStatusTracker(tasks []Task) *TaskStatusTracker { + t := &TaskStatusTracker{last: map[string]string{}} + t.Update(tasks) + return t +} + +// Update takes the next scan and returns the tasks whose status differs from +// the last one seen for them, in the scan's order. A task never seen before +// is remembered, not reported; one missing from this scan keeps its status. +func (t *TaskStatusTracker) Update(tasks []Task) []TaskStatusChange { keys := taskIdentity{seen: map[string]int{}} - for _, t := range previous { - before[keys.of(t)] = t - } var changes []TaskStatusChange - keys = taskIdentity{seen: map[string]int{}} - for _, t := range next { - old, ok := before[keys.of(t)] - if !ok { - continue - } - from, to := TaskStatusName(old), TaskStatusName(t) - if from != to { - changes = append(changes, TaskStatusChange{Task: t, PreviousStatus: from, Status: to}) + for _, task := range tasks { + key := keys.of(task) + status := TaskStatusName(task) + if before, ok := t.last[key]; ok && before != status { + changes = append(changes, TaskStatusChange{Task: task, PreviousStatus: before, Status: status}) } + t.last[key] = status } return changes } + +// DiffTaskStatuses lists the tasks found in both scans whose status changed, +// in the order of the newer scan. Tasks that appear, disappear, move to +// another note or have their words edited are not status changes. +func DiffTaskStatuses(previous, next []Task) []TaskStatusChange { + return NewTaskStatusTracker(previous).Update(next) +} diff --git a/internal/vault/taskwatch_test.go b/internal/vault/taskwatch_test.go index 6a420d3..abe9a36 100644 --- a/internal/vault/taskwatch_test.go +++ b/internal/vault/taskwatch_test.go @@ -118,3 +118,27 @@ func TestDiffTaskStatusesMatchesTasksByNoteAndWords(t *testing.T) { t.Fatalf("got %+v, want %+v", got, want) } } + +// A scan can miss a task that is still there: a note read while an editor +// rewrites it in place is empty for that moment. The change the task carries +// when it comes back is still reported, and only once. +func TestTaskStatusTrackerRemembersTasksAScanMissed(t *testing.T) { + taskNote := func(status string) []Task { + return watchScan(watchedNote, "---\ntags: [task]\nstatus: "+status+"\n---\nBuild it.\n") + } + tracker := NewTaskStatusTracker(taskNote("backlog")) + if got := tracker.Update(watchScan(watchedNote, "")); len(got) != 0 { + t.Fatalf("an empty read reported %+v", statusMoves(got)) + } + got := statusMoves(tracker.Update(taskNote("ready"))) + if want := []statusMove{{watchedNote + "#task", "backlog", "ready"}}; !reflect.DeepEqual(got, want) { + t.Fatalf("after the empty read: got %+v, want %+v", got, want) + } + if got := tracker.Update(taskNote("ready")); len(got) != 0 { + t.Fatalf("the same change was reported twice: %+v", statusMoves(got)) + } + later := append(taskNote("ready"), watchScan("inbox/New.md", "- [ ] Fresh @status:ready\n")...) + if got := tracker.Update(later); len(got) != 0 { + t.Fatalf("a task seen for the first time was reported: %+v", statusMoves(got)) + } +}