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..c2f58ef --- /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() + first, 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, first, changed, asJSON) +} + +// followTaskStatuses scans again on each signal and reports every task whose +// 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(): + 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 tracker.Update(next) { + emitTaskChange(change, asJSON) + } + } +} + +// 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..d0c2167 --- /dev/null +++ b/internal/cli/task_watch_test.go @@ -0,0 +1,347 @@ +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) + + // 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, "the checkbox task's change", func() bool { return printed() >= 2 }) + changed <- struct{}{} + changed <- struct{}{} + 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/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/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..4758e91 --- /dev/null +++ b/internal/vault/taskwatch.go @@ -0,0 +1,108 @@ +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) +} + +// 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{}} + var changes []TaskStatusChange + 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 new file mode 100644 index 0000000..abe9a36 --- /dev/null +++ b/internal/vault/taskwatch_test.go @@ -0,0 +1,144 @@ +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) + } +} + +// 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)) + } +}