diff --git a/README.md b/README.md index c88a055..b5a6785 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,12 @@ zn base rows "Reading list" --json zn base pages "Reading list" ``` +Notes in the vault's template folder (`templates.folder` in vault.json, set +in the app) are templates, so `task list`, `task watch`, `search`, the tag +list, tag searches and `zn mcp` leave them out; `--include-excluded` lists +their tasks. In the terminal app, Search notes finds them once the query +starts with the folder's path (`Templates/`). + Use the stable ID returned by `task list` with `task toggle `. Comment replies and resolve/reopen operations take a thread ID. `base add` and `base set` accept repeated `--set Field=Value` flags for existing fields; diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 868f5f1..639bc1c 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -77,6 +77,20 @@ type Backend interface { UpdateVaultSettings(ctx context.Context, patch func(raw map[string]any)) error } +// TemplateFolder is the vault's template folder (ZenNotes/zennotes#1024), "" +// when it has none or its settings cannot be read. A note inside it +// contributes no tags, so the tag list and every tag search leave it out +// (vault.IsPathInTemplateFolder). A server vault reports the folder in its +// settings like a local one, and the tag list is counted here from the note +// listing for both, so the same rule covers both. +func TemplateFolder(ctx context.Context, b Backend) string { + settings, err := b.VaultSettings(ctx) + if err != nil { + return "" + } + return settings.TemplateFolder +} + // Options tune a backend. type Options struct { // SyncTitleHeading is the desktop's "Sync title heading on rename" diff --git a/internal/cli/commands_notes.go b/internal/cli/commands_notes.go index 83a405f..f39d00b 100644 --- a/internal/cli/commands_notes.go +++ b/internal/cli/commands_notes.go @@ -59,6 +59,11 @@ func cmdList(ctx context.Context, b backend.Backend, args Args) error { if err != nil { return err } + // --tag is a tag search, which skips the template folder (#1024). + templateFolder := "" + if tag != "" { + templateFolder = backend.TemplateFolder(ctx, b) + } notes := []vault.NoteMeta{} for _, n := range all { if hasFolder { @@ -68,7 +73,7 @@ func cmdList(ctx context.Context, b backend.Backend, args Args) error { } else if n.Folder == vault.FolderTrash { continue } - if tag != "" && !hasTag(n.Tags, tag) { + if tag != "" && (!hasTag(n.Tags, tag) || vault.IsPathInTemplateFolder(n.Path, templateFolder)) { continue } notes = append(notes, n) diff --git a/internal/cli/commands_tasks_tags.go b/internal/cli/commands_tasks_tags.go index ff43a2b..917d543 100644 --- a/internal/cli/commands_tasks_tags.go +++ b/internal/cli/commands_tasks_tags.go @@ -112,11 +112,12 @@ type tagCount struct { Count int `json:"count"` } -// TagCounts tallies every tag across live notes, most used first. -func TagCounts(notes []vault.NoteMeta) []tagCount { +// TagCounts tallies every tag across live notes, most used first. A note in +// templateFolder contributes no tags (ZenNotes/zennotes#1024). +func TagCounts(notes []vault.NoteMeta, templateFolder string) []tagCount { counts := map[string]int{} for _, n := range notes { - if n.Folder == vault.FolderTrash { + if n.Folder == vault.FolderTrash || vault.IsPathInTemplateFolder(n.Path, templateFolder) { continue } for _, t := range n.Tags { @@ -141,7 +142,7 @@ func cmdTagList(ctx context.Context, b backend.Backend, args Args) error { if err != nil { return err } - ordered := TagCounts(all) + ordered := TagCounts(all, backend.TemplateFolder(ctx, b)) if args.Bool("json") { emitJSON(ordered) return nil @@ -174,9 +175,12 @@ func cmdTagFind(ctx context.Context, b backend.Backend, args Args) error { if err != nil { return err } + // A template's tags are placeholders, so tag search skips the template + // folder (ZenNotes/zennotes#1024). + templateFolder := backend.TemplateFolder(ctx, b) matches := []vault.NoteMeta{} for _, n := range all { - if n.Folder != vault.FolderTrash && hasTag(n.Tags, tag) { + if n.Folder != vault.FolderTrash && hasTag(n.Tags, tag) && !vault.IsPathInTemplateFolder(n.Path, templateFolder) { matches = append(matches, n) } } diff --git a/internal/cli/help.go b/internal/cli/help.go index a0f1bfb..141f6e2 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -49,7 +49,7 @@ var helpSections = []helpSection{ {"duplicate ", "Copy a note next to itself", ""}, }}, {"SEARCH", []helpRow{ - {"search ", "Full-text search across live notes", "--limit --json"}, + {"search ", "Full-text search across live notes, the template folder left out", "--limit --json"}, {"search-title ", "Match notes by title (substring)", "--json"}, {"backlinks ", "Notes linking to this one via [[wikilink]]", "--json"}, }}, @@ -60,11 +60,11 @@ var helpSections = []helpSection{ {"folder delete

", "Delete a subfolder and everything in it", "--yes"}, }}, {"TAGS", []helpRow{ - {"tag list", "Every #tag with its note count", "--json"}, - {"tag find ", "Notes carrying this #tag", "--limit --json"}, + {"tag list", "Every #tag with its note count, the template folder left out", "--json"}, + {"tag find ", "Notes carrying this #tag, the template folder left out", "--limit --json"}, }}, {"TASKS", []helpRow{ - {"task list", "Open checkbox tasks across all notes", "--unchecked --all --tag --include-excluded --json"}, + {"task list", "Open checkbox tasks across all notes but the template folder", "--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"}, }}, diff --git a/internal/cli/template_folder_test.go b/internal/cli/template_folder_test.go new file mode 100644 index 0000000..28eb04a --- /dev/null +++ b/internal/cli/template_folder_test.go @@ -0,0 +1,143 @@ +package cli + +import ( + "encoding/json" + "reflect" + "slices" + "strings" + "testing" +) + +// templateFolderVault is a root-layout vault whose `Templates` folder is its +// template folder (ZenNotes/zennotes#1024), with a real note beside it. +func templateFolderVault(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeVaultFile(t, root, ".zennotes/vault.json", `{"primaryNotesLocation":"root","templates":{"folder":"Templates"}}`) + writeVaultFile(t, root, "Templates/Meeting.md", "# {{title}}\n\n- [ ] Agenda item for the standup\n") + writeVaultFile(t, root, "Templates/Work/Retro.md", "- [ ] Action item\n") + writeVaultFile(t, root, "Notes/Monday.md", "- [ ] Book the room for the standup\n") + return root +} + +func runJSON(t *testing.T, args []string, into any) { + t.Helper() + out := captureOutput(t, func() { + if code := Main(args); code != 0 { + t.Errorf("zn %v exited %d", args, code) + } + }) + if err := json.Unmarshal([]byte(out), into); err != nil { + t.Fatalf("zn %v: %v\n%s", args, err, out) + } +} + +// A template's checkboxes are placeholders, so `zn task list` leaves the +// template folder out, as the desktop's Tasks do; --include-excluded lists +// them again. +func TestTaskListLeavesTheTemplateFolderOut(t *testing.T) { + isolatedCLI(t) + root := templateFolderVault(t) + paths := func(flags ...string) []string { + var tasks []struct { + Path string `json:"sourcePath"` + } + runJSON(t, append([]string{"task", "list", "--json", "--vault", root}, flags...), &tasks) + out := []string{} + for _, task := range tasks { + out = append(out, task.Path) + } + slices.Sort(out) + return out + } + if got := paths(); !reflect.DeepEqual(got, []string{"Notes/Monday.md"}) { + t.Fatalf("default: %v", got) + } + if got := paths("--include-excluded"); !reflect.DeepEqual(got, []string{"Notes/Monday.md", "Templates/Meeting.md", "Templates/Work/Retro.md"}) { + t.Fatalf("--include-excluded: %v", got) + } +} + +// `zn search` leaves template notes out, as the desktop's Search Text in +// Vault and the server's text search do. +func TestSearchLeavesTheTemplateFolderOut(t *testing.T) { + isolatedCLI(t) + root := templateFolderVault(t) + var matches []struct { + Path string `json:"path"` + } + runJSON(t, []string{"search", "standup", "--json", "--vault", root}, &matches) + if len(matches) != 1 || matches[0].Path != "Notes/Monday.md" { + t.Fatalf("search = %+v, want only Notes/Monday.md", matches) + } +} + +// `zn task watch` scans as `zn task list` does, so a template's checkbox +// changing is not a task changing. +func TestTaskWatchIgnoresTheTemplateFolder(t *testing.T) { + root := templateFolderVault(t) + output, changed, stop := watchTasks(t, root, true) + writeVaultFile(t, root, "Templates/Meeting.md", "# {{title}}\n\n- [x] Agenda item for the standup\n") + changed <- struct{}{} + writeVaultFile(t, root, "Notes/Monday.md", "- [x] Book the room for the standup\n") + changed <- struct{}{} + waitFor(t, "the real note's change", func() bool { return strings.Contains(output.String(), "\n") }) + changed <- struct{}{} + stop() + lines := strings.Split(strings.TrimSuffix(output.String(), "\n"), "\n") + if len(lines) != 1 || !strings.Contains(lines[0], `"path":"Notes/Monday.md"`) { + t.Fatalf("want one line for Notes/Monday.md, got:\n%s", output.String()) + } +} + +// templateTagsVault has a template carrying a tag of its own and a tag it +// shares with a real note. +func templateTagsVault(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeVaultFile(t, root, ".zennotes/vault.json", `{"primaryNotesLocation":"root","templates":{"folder":"Templates"}}`) + writeVaultFile(t, root, "Templates/Meeting.md", "---\ntags: [work, tpl-only]\n---\n# {{title}}\n") + writeVaultFile(t, root, "Notes/Monday.md", "# Monday\n\nPlanning #work\n") + return root +} + +// A note in the template folder contributes no tags: `zn tag list` leaves its +// tags out of names and counts, and `zn tag find` and `zn list --tag` do not +// return it, as the desktop 2.80.0 tag list and tag search do. `zn list` +// without a tag still lists it, since it is an ordinary note otherwise. +func TestTagsLeaveTheTemplateFolderOut(t *testing.T) { + isolatedCLI(t) + root := templateTagsVault(t) + var tags []struct { + Tag string `json:"tag"` + Count int `json:"count"` + } + runJSON(t, []string{"tag", "list", "--json", "--vault", root}, &tags) + if len(tags) != 1 || tags[0].Tag != "work" || tags[0].Count != 1 { + t.Fatalf("tag list = %+v, want only work with 1 note", tags) + } + notePaths := func(args ...string) []string { + var notes []struct { + Path string `json:"path"` + } + runJSON(t, append(args, "--json", "--vault", root), ¬es) + out := []string{} + for _, n := range notes { + out = append(out, n.Path) + } + slices.Sort(out) + return out + } + if got := notePaths("tag", "find", "work"); !reflect.DeepEqual(got, []string{"Notes/Monday.md"}) { + t.Fatalf("tag find work = %v", got) + } + if got := notePaths("tag", "find", "tpl-only"); len(got) != 0 { + t.Fatalf("tag find tpl-only = %v, want none", got) + } + if got := notePaths("list", "--tag", "work"); !reflect.DeepEqual(got, []string{"Notes/Monday.md"}) { + t.Fatalf("list --tag work = %v", got) + } + if got := notePaths("list"); !reflect.DeepEqual(got, []string{"Notes/Monday.md", "Templates/Meeting.md"}) { + t.Fatalf("list = %v, want both notes", got) + } +} diff --git a/internal/mcp/instructions.go b/internal/mcp/instructions.go index 12ab74c..d988e17 100644 --- a/internal/mcp/instructions.go +++ b/internal/mcp/instructions.go @@ -232,7 +232,8 @@ const DefaultInstructions = "You are connected to a user's ZenNotes vault: plain "- Task ids from list_tasks (`path#index`) are stable, so pass\n" + " them to toggle_task.\n" + "- list_tasks omits notes opted out of Tasks (frontmatter\n" + - " `tasks: false`/`note`, excluded folders). Pass\n" + + " `tasks: false`/`note`, excluded folders, the template folder,\n" + + " and notes outside the vault's Task folders when it has any). Pass\n" + " includeExcluded: true only when the user asks for everything.\n" + "\n" + "## Comments: reviewing a note together\n" + diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 438887d..3f90be9 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -292,6 +292,12 @@ func tools() []toolDef { if err != nil { return nil, err } + // A tag filter is a tag search, which skips the template + // folder (ZenNotes/zennotes#1024). + templateFolder := "" + if hasTag { + templateFolder = backend.TemplateFolder(ctx, b) + } notes := []vault.NoteMeta{} prefix := "" if hasSub && sub != "" { @@ -308,7 +314,7 @@ func tools() []toolDef { if prefix != "" && !strings.HasPrefix(n.Path, prefix) { continue } - if hasTag && !containsFold(n.Tags, strings.ToLower(tag)) { + if hasTag && (!containsFold(n.Tags, strings.ToLower(tag)) || vault.IsPathInTemplateFolder(n.Path, templateFolder)) { continue } if hasWiki && !containsFold(n.Wikilinks, strings.ToLower(wikilinkTo)) { @@ -707,9 +713,10 @@ func tools() []toolDef { if err != nil { return nil, err } + templateFolder := backend.TemplateFolder(ctx, b) matches := []vault.NoteMeta{} for _, n := range all { - if n.Folder != vault.FolderTrash && containsFold(n.Tags, tag) { + if n.Folder != vault.FolderTrash && containsFold(n.Tags, tag) && !vault.IsPathInTemplateFolder(n.Path, templateFolder) { matches = append(matches, n) } } @@ -729,9 +736,11 @@ func tools() []toolDef { if err != nil { return nil, err } + // A note in the template folder contributes no tags (#1024). + templateFolder := backend.TemplateFolder(ctx, b) counts := map[string]int{} for _, n := range all { - if n.Folder == vault.FolderTrash { + if n.Folder == vault.FolderTrash || vault.IsPathInTemplateFolder(n.Path, templateFolder) { continue } for _, t := range n.Tags { @@ -769,7 +778,7 @@ func tools() []toolDef { "tag":{"type":"string"}, "folder":{"type":"string","enum":["inbox","quick","archive"]}, "tags":{"type":"array","items":{"type":"string"},"description":"Alternative to tag: require ALL of these tags."}, -"includeExcluded":{"type":"boolean","description":"Also scan notes opted out of Tasks (frontmatter ` + "`tasks: false`" + ` / ` + "`tasks: note`" + `) and folders on the vault's excluded list. Default false: excluded tasks are invisible."}}}`, +"includeExcluded":{"type":"boolean","description":"Also scan notes opted out of Tasks (frontmatter ` + "`tasks: false`" + ` / ` + "`tasks: note`" + `), folders on the vault's excluded list, its template folder, and notes outside its Task folders. Default false: excluded tasks are invisible."}}}`, handler: func(ctx context.Context, a args, b Backend) (any, error) { status, has, err := a.optionalString("status") if err != nil { diff --git a/internal/mcp/template_folder_test.go b/internal/mcp/template_folder_test.go new file mode 100644 index 0000000..3d86230 --- /dev/null +++ b/internal/mcp/template_folder_test.go @@ -0,0 +1,149 @@ +package mcp + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/backend" +) + +// The vault's template folder (ZenNotes/zennotes#1024): list_tasks leaves its +// placeholders out as the desktop's MCP server does, includeExcluded reaches +// them, and search_text skips its notes as the app's text search and the +// server do. +func TestTemplateFolderStaysOutOfListTasksAndSearchText(t *testing.T) { + root := t.TempDir() + for rel, body := range map[string]string{ + ".zennotes/vault.json": `{"primaryNotesLocation":"root","templates":{"folder":"Templates"}}`, + "Templates/Meeting.md": "- [ ] Agenda item for the standup\n", + "Templates/Work/Retro.md": "- [ ] Action item\n", + "Notes/Monday.md": "- [ ] Book the room for the standup\n", + } { + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + cs := runServer(t, func() (backend.Target, error) { + return backend.Target{Kind: backend.KindLocal, Root: root}, nil + }) + sources := func(args map[string]any) []string { + text, isErr := callTool(t, cs, "list_tasks", args) + if isErr { + t.Fatalf("list_tasks %v: %s", args, text) + } + var tasks []struct { + SourcePath string `json:"sourcePath"` + } + if err := json.Unmarshal([]byte(text), &tasks); err != nil { + t.Fatalf("list_tasks %v: %v\n%s", args, err, text) + } + out := []string{} + for _, task := range tasks { + out = append(out, task.SourcePath) + } + slices.Sort(out) + return out + } + if got := sources(map[string]any{}); !reflect.DeepEqual(got, []string{"Notes/Monday.md"}) { + t.Fatalf("list_tasks: %v", got) + } + if got := sources(map[string]any{"includeExcluded": true}); len(got) != 3 { + t.Fatalf("list_tasks includeExcluded: %v", got) + } + + text, isErr := callTool(t, cs, "search_text", map[string]any{"query": "standup"}) + if isErr { + t.Fatalf("search_text: %s", text) + } + var matches []struct { + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(text), &matches); err != nil { + t.Fatalf("search_text: %v\n%s", err, text) + } + if len(matches) != 1 || matches[0].Path != "Notes/Monday.md" { + t.Fatalf("search_text = %+v, want only Notes/Monday.md", matches) + } +} + +// The served instructions stay the desktop's, which name the template folder +// among what list_tasks leaves out. +func TestInstructionsSayListTasksLeavesTheTemplateFolderOut(t *testing.T) { + if !strings.Contains(DefaultInstructions, "excluded folders, the template folder,\n and notes outside the vault's Task folders when it has any") { + t.Fatal("the list_tasks paragraph is not the desktop's") + } +} + +// A note in the template folder contributes no tags: list_tags leaves its +// tags out of names and counts, and search_by_tag and list_notes' tag filter +// do not return it. list_notes without a tag still lists it. +func TestTemplateFolderContributesNoTags(t *testing.T) { + root := t.TempDir() + for rel, body := range map[string]string{ + ".zennotes/vault.json": `{"primaryNotesLocation":"root","templates":{"folder":"Templates"}}`, + "Templates/Meeting.md": "---\ntags: [work, tpl-only]\n---\n# {{title}}\n", + "Notes/Monday.md": "# Monday\n\nPlanning #work\n", + } { + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + cs := runServer(t, func() (backend.Target, error) { + return backend.Target{Kind: backend.KindLocal, Root: root}, nil + }) + call := func(name string, args map[string]any, into any) { + t.Helper() + text, isErr := callTool(t, cs, name, args) + if isErr { + t.Fatalf("%s %v: %s", name, args, text) + } + if err := json.Unmarshal([]byte(text), into); err != nil { + t.Fatalf("%s %v: %v\n%s", name, args, err, text) + } + } + var tags []struct { + Tag string `json:"tag"` + Count int `json:"count"` + } + call("list_tags", map[string]any{}, &tags) + if len(tags) != 1 || tags[0].Tag != "work" || tags[0].Count != 1 { + t.Fatalf("list_tags = %+v, want only work with 1 note", tags) + } + paths := func(name string, args map[string]any) []string { + var notes []struct { + Path string `json:"path"` + } + call(name, args, ¬es) + out := []string{} + for _, n := range notes { + out = append(out, n.Path) + } + slices.Sort(out) + return out + } + if got := paths("search_by_tag", map[string]any{"tag": "#work"}); !reflect.DeepEqual(got, []string{"Notes/Monday.md"}) { + t.Fatalf("search_by_tag work = %v", got) + } + if got := paths("search_by_tag", map[string]any{"tag": "tpl-only"}); len(got) != 0 { + t.Fatalf("search_by_tag tpl-only = %v, want none", got) + } + if got := paths("list_notes", map[string]any{"tag": "work"}); !reflect.DeepEqual(got, []string{"Notes/Monday.md"}) { + t.Fatalf("list_notes tag work = %v", got) + } + if got := paths("list_notes", map[string]any{}); len(got) != 2 { + t.Fatalf("list_notes = %v, want both notes", got) + } +} diff --git a/internal/search/notes.go b/internal/search/notes.go index 0762690..6a3f5fb 100644 --- a/internal/search/notes.go +++ b/internal/search/notes.go @@ -148,18 +148,37 @@ type scored struct { score float64 } +// HidesTemplates reports whether a Search notes query leaves the template +// folder's notes out (ZenNotes/zennotes#1024). They are placeholders nobody +// is searching for, so they stay out until the query's free text starts with +// the folder's path and a slash (`Templates/meet`), case-insensitively, which +// is how a template is found to be edited. Mirrors queryNamesTemplateFolder +// in the desktop's note-search.ts. +func HidesTemplates(query, templateFolder string) bool { + if templateFolder == "" { + return false + } + freeText, _ := parseQuery(query) + return !strings.HasPrefix(strings.ToLower(freeText), strings.ToLower(templateFolder)+"/") +} + // Notes runs a query over the index. With no free text the live notes come // back in their given order, tag filters applied; quick notes first and most -// recent first when quickFirst is set. -func Notes(index []Entry, query string, limit int, quickFirst bool) []vault.NoteMeta { +// recent first when quickFirst is set. A templateFolder's notes are left out +// unless the query names the folder (HidesTemplates); "" leaves every note in. +func Notes(index []Entry, query string, limit int, quickFirst bool, templateFolder string) []vault.NoteMeta { freeText, tagTokens := parseQuery(query) if limit <= 0 { return nil } + hidden := "" + if HidesTemplates(query, templateFolder) { + hidden = templateFolder + } if freeText == "" { live := []Entry{} for _, e := range index { - if e.Note.Folder != vault.FolderTrash && e.matchesTags(tagTokens) { + if e.Note.Folder != vault.FolderTrash && e.matchesTags(tagTokens) && !vault.IsPathInTemplateFolder(e.Note.Path, hidden) { live = append(live, e) } } @@ -185,7 +204,7 @@ func Notes(index []Entry, query string, limit int, quickFirst bool) []vault.Note collect := func(allowFuzzy bool) []scored { matches := []scored{} for _, e := range index { - if e.Note.Folder == vault.FolderTrash || !e.matchesTags(tagTokens) { + if e.Note.Folder == vault.FolderTrash || !e.matchesTags(tagTokens) || vault.IsPathInTemplateFolder(e.Note.Path, hidden) { continue } s := e.score(prepared, allowFuzzy) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 881d17e..870cd38 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -993,7 +993,7 @@ func (a *App) openArgument(arg string) error { a.openNote(meta.Path, true) return nil } - if matches := search.Notes(a.idx.search, arg, 1, false); len(matches) > 0 { + if matches := search.Notes(a.idx.search, arg, 1, false, ""); len(matches) > 0 { a.openNote(matches[0].Path, true) return nil } diff --git a/internal/tui/data.go b/internal/tui/data.go index e009899..1e305cd 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -73,7 +73,7 @@ func (a *App) loadIndexCmd() tea.Cmd { idx.folders = folders idx.settings = settings idx.primary = desc.PrimaryNotesLocation - idx.tags = countTags(notes) + idx.tags = countTags(notes, settings.TemplateFolder) idx.search = search.Index(notes) idx.loadedAt = time.Now() return indexLoadedMsg{epoch: epoch, idx: idx} @@ -88,10 +88,13 @@ func (a *App) loadTasksCmd() tea.Cmd { } } -func countTags(notes []vault.NoteMeta) []tagCount { +// countTags tallies the tags of live notes. A note in the template folder +// contributes none (ZenNotes/zennotes#1024): the Tags view, the sidebar and +// every tag picker read this list. +func countTags(notes []vault.NoteMeta, templateFolder string) []tagCount { counts := map[string]int{} for _, n := range notes { - if n.Folder == vault.FolderTrash { + if n.Folder == vault.FolderTrash || vault.IsPathInTemplateFolder(n.Path, templateFolder) { continue } for _, t := range n.Tags { diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index 5deb6da..b013b50 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -838,11 +838,14 @@ func (a *App) openNoteSearch(initial string) { if a.idx == nil { return nil } + // The template folder's notes stay out, as in the desktop's Search + // notes, until the query starts with the folder's path (#1024). + templateFolder := a.idx.settings.TemplateFolder var notes []vault.NoteMeta if strings.TrimSpace(query) == "" { seen := map[string]bool{} for _, r := range a.recent { - if meta, ok := a.noteMeta(r); ok && !seen[r] { + if meta, ok := a.noteMeta(r); ok && !seen[r] && !vault.IsPathInTemplateFolder(r, templateFolder) { seen[r] = true notes = append(notes, meta) } @@ -851,13 +854,13 @@ func (a *App) openNoteSearch(initial string) { if len(notes) >= 30 { break } - if !seen[n.Path] { + if !seen[n.Path] && !vault.IsPathInTemplateFolder(n.Path, templateFolder) { seen[n.Path] = true notes = append(notes, n) } } } else { - notes = search.Notes(a.idx.search, query, 50, false) + notes = search.Notes(a.idx.search, query, 50, false, templateFolder) } items := make([]paletteItem, 0, len(notes)) for _, n := range notes { diff --git a/internal/tui/settings_controls.go b/internal/tui/settings_controls.go index 68a00fb..45f6646 100644 --- a/internal/tui/settings_controls.go +++ b/internal/tui/settings_controls.go @@ -67,6 +67,8 @@ func (v *settingsView) refresh(a *App) { } add(settingRow{"tasks", "includedFolders", strings.Join(a.idx.settings.TasksIncludedFolders, ", "), true}) add(settingRow{"tasks", "excludedFolders", strings.Join(a.idx.settings.TasksExcludedFolders, ", "), true}) + // The template folder takes its notes out of Tasks too (#1024). + add(settingRow{"templates", "folder", a.idx.settings.TemplateFolder, true}) } v.list.clamp(len(v.settings)) } @@ -118,6 +120,10 @@ var settingChoices = map[string][]string{ func (v *settingsView) edit(a *App, row settingRow) { save := func(value any) { + if text := strings.TrimSpace(fmt.Sprint(value)); row.section == "templates" && text != "" && vault.NormalizeTemplateFolder(text) == "" { + a.notifyError("Not a usable template folder: " + text) + return + } var err error if row.vault { err = a.backend.UpdateVaultSettings(a.ctx, func(raw map[string]any) { @@ -125,6 +131,10 @@ func (v *settingsView) edit(a *App, row settingRow) { setTasksFolderList(raw, row.key, fmt.Sprint(value)) return } + if row.section == "templates" { + setTemplateFolder(raw, fmt.Sprint(value)) + return + } m, _ := raw[row.section].(map[string]any) if m == nil { m = map[string]any{} @@ -187,6 +197,27 @@ func (v *settingsView) edit(a *App, row settingRow) { }) } +// setTemplateFolder writes the vault's template folder (`templates.folder`, +// ZenNotes/zennotes#1024) cleaned the way every runtime reads it, keeping any +// other key under `templates`. An empty edit removes it, and an empty +// `templates` object goes with it, so vault.json never keeps an empty stub. +func setTemplateFolder(raw map[string]any, value string) { + templates, _ := raw["templates"].(map[string]any) + if templates == nil { + templates = map[string]any{} + } + if folder := vault.NormalizeTemplateFolder(value); folder != "" { + templates["folder"] = folder + } else { + delete(templates, "folder") + } + if len(templates) == 0 { + delete(raw, "templates") + } else { + raw["templates"] = templates + } +} + // setTasksFolderList writes one of the vault's Tasks folder lists // (`tasks.excludedFolders`, or the Task folders in `tasks.includedFolders`, // #962) from a comma-separated edit, keeping the other keys under `tasks`. An diff --git a/internal/tui/tagcomplete.go b/internal/tui/tagcomplete.go index 992b59c..58457e6 100644 --- a/internal/tui/tagcomplete.go +++ b/internal/tui/tagcomplete.go @@ -204,7 +204,8 @@ func (a *App) tagPool(buf *noteBuffer) []tagCount { counts := map[string]int{} if a.idx != nil { for _, n := range a.idx.notes { - if n.Folder == vault.FolderTrash || n.Path == buf.path { + // A note in the template folder contributes no tags (#1024). + if n.Folder == vault.FolderTrash || n.Path == buf.path || vault.IsPathInTemplateFolder(n.Path, a.idx.settings.TemplateFolder) { continue } for _, t := range n.Tags { diff --git a/internal/tui/template_folder_test.go b/internal/tui/template_folder_test.go new file mode 100644 index 0000000..2de3084 --- /dev/null +++ b/internal/tui/template_folder_test.go @@ -0,0 +1,160 @@ +package tui + +import ( + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/database" +) + +// templateFolderApp opens a root-layout vault whose `Templates` folder is its +// template folder (ZenNotes/zennotes#1024). +func templateFolderApp(t *testing.T) *App { + t.Helper() + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + root := t.TempDir() + for rel, body := range map[string]string{ + ".zennotes/vault.json": `{"primaryNotesLocation":"root","templates":{"folder":"Templates"}}`, + "Templates/Meeting.md": "---\ntags: [work, tpl-only]\n---\n- [ ] Agenda item\n", + "Templates/Work/Retro.md": "- [ ] Action item\n", + "Notes/Meeting minutes.md": "---\ntags: [work]\n---\n- [ ] Book the room\n", + } { + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return newSessionParityApp(t, root) +} + +func noteSearchPaths(t *testing.T, a *App, query string) []string { + t.Helper() + a.openNoteSearch(query) + p, ok := a.overlay.(*palette) + if !ok { + t.Fatalf("Search notes did not open for %q", query) + } + out := []string{} + for _, it := range p.filtered { + out = append(out, it.id) + } + slices.Sort(out) + return out +} + +// Search notes hides template notes, as the desktop's does, until the query +// starts with the folder's path and a slash, which is how a template is found +// to be edited. +func TestSearchNotesHidesTheTemplateFolderUntilTheQueryNamesIt(t *testing.T) { + a := templateFolderApp(t) + cases := []struct { + query string + want []string + }{ + {"", []string{"Notes/Meeting minutes.md"}}, + {"meeting", []string{"Notes/Meeting minutes.md"}}, + {"#work", []string{"Notes/Meeting minutes.md"}}, + {"templates", []string{}}, + {"Templates/", []string{"Templates/Meeting.md", "Templates/Work/Retro.md"}}, + {"templates/meet", []string{"Templates/Meeting.md"}}, + {"#work Templates/meet", []string{"Templates/Meeting.md"}}, + } + for _, tc := range cases { + if got := noteSearchPaths(t, a, tc.query); !reflect.DeepEqual(got, tc.want) { + t.Errorf("Search notes %q = %v, want %v", tc.query, got, tc.want) + } + } +} + +// The Tasks view scans as `zn task list` does. +func TestTasksViewLeavesTheTemplateFolderOut(t *testing.T) { + a := templateFolderApp(t) + msg := a.loadTasksCmd()().(tasksLoadedMsg) + if msg.err != nil { + t.Fatal(msg.err) + } + sources := []string{} + for _, task := range msg.tasks { + sources = append(sources, task.SourcePath) + } + if !reflect.DeepEqual(sources, []string{"Notes/Meeting minutes.md"}) { + t.Fatalf("Tasks = %v, want only Notes/Meeting minutes.md", sources) + } +} + +// Settings shows the template folder next to the Tasks folder lists, since +// it takes a folder out of Tasks as they do. +func TestSettingsShowTheTemplateFolder(t *testing.T) { + a := templateFolderApp(t) + v := &settingsView{} + v.refresh(a) + if !slices.Contains(v.lines, "vault ยท templates.folder = Templates") { + t.Fatalf("settings rows:\n%s", strings.Join(v.lines, "\n")) + } +} + +// An edit writes `templates.folder` cleaned the way every runtime reads it, +// keeps whatever else is under `templates`, and an empty edit removes it. +func TestSetTemplateFolder(t *testing.T) { + raw := map[string]any{"templates": map[string]any{"later": true}} + setTemplateFolder(raw, " /Meta\\Templates/ ") + want := map[string]any{"templates": map[string]any{"later": true, "folder": "Meta/Templates"}} + if !reflect.DeepEqual(raw, want) { + t.Fatalf("after setting: %#v", raw) + } + setTemplateFolder(raw, " ") + if want := map[string]any{"templates": map[string]any{"later": true}}; !reflect.DeepEqual(raw, want) { + t.Fatalf("after clearing: %#v", raw) + } + raw = map[string]any{"templates": map[string]any{"folder": "Templates"}} + setTemplateFolder(raw, "") + if _, ok := raw["templates"]; ok { + t.Fatalf("clearing the only key should drop templates, got %#v", raw) + } +} + +// A note in the template folder contributes no tags, as in desktop 2.80.0: +// the Tags view, the sidebar and the tag pickers leave its tags out of names +// and counts, a tag's notes leave it out, and so do tag completion and a +// field whose options come from a tag. +func TestTemplateFolderContributesNoTags(t *testing.T) { + a := templateFolderApp(t) + if want := []tagCount{{"work", 1}}; !reflect.DeepEqual(a.idx.tags, want) { + t.Fatalf("tags = %v, want %v", a.idx.tags, want) + } + + a.openTags("work") + tv, ok := a.activeTab().view.(*tagsView) + if !ok { + t.Fatal("the Tags view did not open") + } + notes := []string{} + for _, n := range tv.notes { + notes = append(notes, n.Path) + } + if !reflect.DeepEqual(notes, []string{"Notes/Meeting minutes.md"}) { + t.Fatalf("notes tagged work = %v", notes) + } + + if got := a.optionsFromSource(&database.OptionsSource{Kind: "tag", Tag: "work"}); !reflect.DeepEqual(got, []string{"Meeting minutes"}) { + t.Fatalf("options from #work = %v", got) + } + + a.openNote("Notes/Meeting minutes.md", true) + buf := a.activeBuffer() + if buf == nil { + t.Fatal("Meeting minutes did not open") + } + for _, tc := range a.tagPool(buf) { + if tc.tag == "tpl-only" { + t.Fatalf("tag completion offers the template's tag: %v", a.tagPool(buf)) + } + } +} diff --git a/internal/tui/views_database_edit.go b/internal/tui/views_database_edit.go index f09f4bb..b30a8eb 100644 --- a/internal/tui/views_database_edit.go +++ b/internal/tui/views_database_edit.go @@ -98,7 +98,8 @@ func (a *App) optionsFromSource(src *database.OptionsSource) []string { continue } case "tag": - if !hasTag(n.Tags, src.Tag) { + // A tag source is a tag search, which skips the template folder (#1024). + if !hasTag(n.Tags, src.Tag) || vault.IsPathInTemplateFolder(n.Path, a.idx.settings.TemplateFolder) { continue } } @@ -192,7 +193,7 @@ func (a *App) noteCandidates(query, exclude string) []vault.NoteMeta { } } } else { - notes = search.Notes(a.idx.search, query, 40, false) + notes = search.Notes(a.idx.search, query, 40, false, "") } out := make([]vault.NoteMeta, 0, len(notes)) seen := map[string]bool{} diff --git a/internal/tui/views_lists.go b/internal/tui/views_lists.go index 34d9316..4ae35cd 100644 --- a/internal/tui/views_lists.go +++ b/internal/tui/views_lists.go @@ -287,7 +287,8 @@ func (v *tagsView) refresh(a *App) { } } for _, n := range a.idx.notes { - if n.Folder == vault.FolderTrash { + // A template's tags count for nothing (#1024), so a tag's notes skip it. + if n.Folder == vault.FolderTrash || vault.IsPathInTemplateFolder(n.Path, a.idx.settings.TemplateFolder) { continue } have := map[string]bool{} diff --git a/internal/vault/settings.go b/internal/vault/settings.go index 381b960..0ed18af 100644 --- a/internal/vault/settings.go +++ b/internal/vault/settings.go @@ -87,7 +87,11 @@ type VaultSettings struct { // TasksIncludedFolders, the Task folders (#962), narrow Tasks to the // notes inside them. Empty means the whole vault. TasksIncludedFolders []string - TypstPreambleFolder string + // TemplateFolder is `templates.folder` (#1024), a vault-relative path as + // it is on disk; "" when the vault has none. Its notes stay out of Tasks + // and text search; see template_folder.go. + TemplateFolder string + TypstPreambleFolder string // Raw is the decoded file, kept so a targeted write can preserve every // key this process does not model. Raw map[string]any @@ -188,6 +192,7 @@ func normalizeVaultSettings(raw map[string]any) VaultSettings { s.TasksExcludedFolders = NormalizeTasksExcludedFolders(stringsOf(tasks["excludedFolders"])) s.TasksIncludedFolders = NormalizeTasksExcludedFolders(stringsOf(tasks["includedFolders"])) } + s.TemplateFolder = templateFolderOf(raw) if typst := mapOf(raw["typstPreambles"]); typst != nil { if folder := normalizeTypstPreambleFolder(stringOf(typst["folder"])); folder != "" { s.TypstPreambleFolder = folder @@ -497,18 +502,20 @@ func rewriteFolderKeyedMap(m map[string]any, folder NoteFolder, oldSub, newSub s // rewriteSettingsForFolderRename keeps vault.json's path-keyed settings naming // the same folders after a rename or move: the Tasks folder lists, which hold // vault-relative paths (mirrors rewriteTasksFoldersForRename in -// tasks-excluded-folders.ts), and the folder icons and colors, keyed by bucket -// and subpath, as desktop main and the server rewrite them. vault.json is -// written only when an entry moves. +// tasks-excluded-folders.ts), the template folder, another such path +// (rewriteTemplateFolderForRename), and the folder icons and colors, keyed by +// bucket and subpath, as desktop main and the server rewrite them. vault.json +// is written only when an entry moves. func (v *Vault) rewriteSettingsForFolderRename(folder NoteFolder, oldSub, newSub, oldRel, newRel string) error { if oldRel == "" || newRel == "" || oldRel == newRel { return nil } s := v.Settings() tasksMove := listHasFolder(s.TasksExcludedFolders, oldRel) || listHasFolder(s.TasksIncludedFolders, oldRel) + templateFolder := rewriteTemplateFolderForRename(s.TemplateFolder, oldRel, newRel) _, iconsMove := rewriteFolderKeyedMap(mapOf(s.Raw["folderIcons"]), folder, oldSub, newSub) _, colorsMove := rewriteFolderKeyedMap(mapOf(s.Raw["folderColors"]), folder, oldSub, newSub) - if !tasksMove && !iconsMove && !colorsMove { + if !tasksMove && templateFolder == s.TemplateFolder && !iconsMove && !colorsMove { return nil } return v.UpdateSettings(func(raw map[string]any) { @@ -519,6 +526,10 @@ func (v *Vault) rewriteSettingsForFolderRename(folder NoteFolder, oldSub, newSub } } } + // Only the folder is replaced; anything else under `templates` stays. + if templates, _ := raw["templates"].(map[string]any); templates != nil && templateFolder != s.TemplateFolder { + templates["folder"] = templateFolder + } for _, key := range []string{"folderIcons", "folderColors"} { if next, changed := rewriteFolderKeyedMap(mapOf(raw[key]), folder, oldSub, newSub); changed { raw[key] = next diff --git a/internal/vault/template_folder.go b/internal/vault/template_folder.go new file mode 100644 index 0000000..74cc825 --- /dev/null +++ b/internal/vault/template_folder.go @@ -0,0 +1,115 @@ +package vault + +import ( + "strings" + "unicode" + "unicode/utf16" +) + +// --- template folder (mirrors shared-domain/template-folder.ts) --- +// +// The vault's template folder (ZenNotes/zennotes#1024, desktop 2.80): a folder +// of ordinary notes whose every note is a template. vault.json stores it as +// `templates.folder`, a vault-relative directory path exactly as it exists on +// disk (the shape of the Tasks folder lists), so a remapped system folder +// needs no translation. A template's `- [ ]` lines are placeholders for the +// note it will make, so its notes stay out of Tasks and out of text search. +// The rules are a byte-for-byte mirror of template-folder.ts in the desktop +// repo and internal/vault/template_folder.go in znserver: change all three +// together. + +// maxTemplateFolderLength caps the folder path, counted in UTF-16 code units +// as the TypeScript side's `String.length` counts it. +const maxTemplateFolderLength = 512 + +// NormalizeTemplateFolder validates a configured folder: backslashes become +// slashes, empty segments drop, each segment loses the whitespace +// JavaScript's trim() removes, and any segment starting with a dot (`.`, +// `..`, `.zennotes`, any hidden directory) refuses the whole value. Returns +// "" when the value is unusable, and the vault then has no template folder. +func NormalizeTemplateFolder(value string) string { + parts := []string{} + for _, seg := range strings.Split(strings.ReplaceAll(value, "\\", "/"), "/") { + s := strings.TrimFunc(seg, isJSWhitespace) + if s == "" { + continue + } + if strings.HasPrefix(s, ".") { + return "" + } + parts = append(parts, s) + } + if len(parts) == 0 { + return "" + } + joined := strings.Join(parts, "/") + if len(utf16.Encode([]rune(joined))) > maxTemplateFolderLength { + return "" + } + return joined +} + +// templateFolderOf reads `templates.folder` off a decoded vault.json the way +// the TypeScript reads an `unknown`: a `templates` that is not an object, or +// a folder that is not a string, is no folder. Keys match exactly. +func templateFolderOf(raw map[string]any) string { + return NormalizeTemplateFolder(stringOf(mapOf(raw["templates"])["folder"])) +} + +// IsPathInTemplateFolder reports whether a vault-relative POSIX path is inside +// the folder: a whole-segment prefix match, case-sensitive like the rest of +// the vault layer, so `Templates` never holds `Templates Old/a.md`. No folder +// matches nothing. +func IsPathInTemplateFolder(relPath, folder string) bool { + if folder == "" { + return false + } + return relPath == folder || strings.HasPrefix(relPath, folder+"/") +} + +// rewriteTemplateFolderForRename follows a folder rename or move: a template +// folder named by the renamed folder, or inside it, moves with it. Anything +// else comes back unchanged. +func rewriteTemplateFolderForRename(folder, oldRel, newRel string) string { + if folder == "" || oldRel == "" || newRel == "" || oldRel == newRel { + return folder + } + if folder != oldRel && !strings.HasPrefix(folder, oldRel+"/") { + return folder + } + moved := NormalizeTemplateFolder(newRel + folder[len(oldRel):]) + if moved == "" { + return folder + } + return moved +} + +// TasksFolderLists is the pair every task scan applies: the excluded folders +// and the Task folders, with the template folder added to the excluded ones, +// so its placeholders stay out of Tasks even inside a Task folder. Mirrors +// tasksFolderListsForSettings in tasks-excluded-folders.ts. A scan asked for +// everything (IncludeExcluded) skips the lists and so reaches the template +// folder too. The returned slice is a fresh copy. +func (s VaultSettings) TasksFolderLists() (excluded, included []string) { + excluded = append([]string{}, s.TasksExcludedFolders...) + included = s.TasksIncludedFolders + if s.TemplateFolder == "" { + return excluded, included + } + for _, entry := range excluded { + if entry == s.TemplateFolder { + return excluded, included + } + } + return append(excluded, s.TemplateFolder), included +} + +// isJSWhitespace is the set JavaScript's String.prototype.trim() removes: +// WhiteSpace and LineTerminator, so NEL (U+0085) is not trimmed. +func isJSWhitespace(r rune) bool { + switch r { + case '\t', '\n', '\v', '\f', '\r', ' ', 0x2028, 0x2029, 0xfeff: + return true + } + return unicode.Is(unicode.Zs, r) +} diff --git a/internal/vault/template_folder_test.go b/internal/vault/template_folder_test.go new file mode 100644 index 0000000..49120a0 --- /dev/null +++ b/internal/vault/template_folder_test.go @@ -0,0 +1,378 @@ +package vault + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +// The cases of shared-domain's template-folder.test.ts and the Go server's +// template_folder_test.go, with the answers the TypeScript +// normalizeTemplateFolder gives (null is ""). +func TestNormalizeTemplateFolder(t *testing.T) { + cases := []struct { + in, want string + }{ + {"Templates", "Templates"}, + {"inbox/Templates", "inbox/Templates"}, + {" Meta / My Templates ", "Meta/My Templates"}, + {"/Templates/", "Templates"}, + {`Meta\Templates`, "Meta/Templates"}, + {"Meta//Templates", "Meta/Templates"}, + {"", ""}, + {" ", ""}, + {"/", ""}, + {".", ""}, + {"..", ""}, + {"../Templates", ""}, + {"Templates/..", ""}, + {".zennotes/templates", ""}, + {"Meta/.hidden", ""}, + // JavaScript's trim(), not Go's TrimSpace: NBSP, BOM and the Unicode + // separators go; NEL (U+0085) is not JavaScript whitespace and stays. + {"\u00a0Templates\ufeff", "Templates"}, + {"\u0085Templates", "\u0085Templates"}, + {strings.Repeat("a", 512), strings.Repeat("a", 512)}, + {strings.Repeat("a", 513), ""}, + // Counted in UTF-16 code units: 256 astral characters are 512 units. + {strings.Repeat("๐Ÿ˜€", 256), strings.Repeat("๐Ÿ˜€", 256)}, + {strings.Repeat("๐Ÿ˜€", 257), ""}, + } + for _, tc := range cases { + if got := NormalizeTemplateFolder(tc.in); got != tc.want { + t.Errorf("NormalizeTemplateFolder(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestIsPathInTemplateFolder(t *testing.T) { + cases := []struct { + path, folder string + want bool + }{ + {"Templates/Meeting.md", "Templates", true}, + {"Templates/Work/Standup.md", "Templates", true}, + {"Templates Old/Meeting.md", "Templates", false}, + {"TemplatesX.md", "Templates", false}, + {"inbox/Templates/Meeting.md", "Templates", false}, + {"inbox/Templates/Meeting.md", "inbox/Templates", true}, + {"templates/Meeting.md", "Templates", false}, + {"Templates/Meeting.md", "", false}, + } + for _, tc := range cases { + if got := IsPathInTemplateFolder(tc.path, tc.folder); got != tc.want { + t.Errorf("IsPathInTemplateFolder(%q, %q) = %v, want %v", tc.path, tc.folder, got, tc.want) + } + } +} + +func TestRewriteTemplateFolderForRename(t *testing.T) { + cases := []struct { + folder, oldRel, newRel, want string + }{ + {"Templates", "Templates", "Blueprints", "Blueprints"}, + {"Meta/Templates", "Meta", "Archive/Meta", "Archive/Meta/Templates"}, + {"Templates", "Templates Old", "Old", "Templates"}, + {"Templates", "Templates/Work", "Templates/Jobs", "Templates"}, + {"Templates", "Templates", "Templates", "Templates"}, + {"Templates", "Templates", ".hidden", "Templates"}, + {"", "Templates", "X", ""}, + } + for _, tc := range cases { + if got := rewriteTemplateFolderForRename(tc.folder, tc.oldRel, tc.newRel); got != tc.want { + t.Errorf("rewriteTemplateFolderForRename(%q, %q, %q) = %q, want %q", tc.folder, tc.oldRel, tc.newRel, got, tc.want) + } + } +} + +// tasksFolderListsForSettings' cases: the template folder joins the excluded +// list once, wins inside a Task folder, and an unusable one adds nothing. +func TestTasksFolderListsAddTheTemplateFolder(t *testing.T) { + lists := func(raw string) ([]string, []string) { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatal(err) + } + return ParseSettings(m).TasksFolderLists() + } + excluded, included := lists(`{"tasks":{"excludedFolders":["inbox/Books"]},"templates":{"folder":"Templates"}}`) + if !reflect.DeepEqual(excluded, []string{"inbox/Books", "Templates"}) || len(included) != 0 { + t.Fatalf("lists = %v / %v", excluded, included) + } + if !IsPathExcludedFromTasks("Templates/Work/1on1.md", excluded, included) { + t.Error("a nested template note feeds Tasks") + } + + excluded, included = lists(`{"tasks":{"includedFolders":["Work"]},"templates":{"folder":"Work/Templates"}}`) + if IsPathExcludedFromTasks("Work/Plan.md", excluded, included) || + !IsPathExcludedFromTasks("Work/Templates/Plan.md", excluded, included) { + t.Errorf("template folder inside a Task folder: %v / %v", excluded, included) + } + + excluded, _ = lists(`{"tasks":{"excludedFolders":["Templates"]},"templates":{"folder":"Templates"}}`) + if !reflect.DeepEqual(excluded, []string{"Templates"}) { + t.Errorf("listed twice: %v", excluded) + } + excluded, _ = lists(`{"templates":{"folder":".zennotes/templates"}}`) + if len(excluded) != 0 { + t.Errorf("an unusable folder was excluded: %v", excluded) + } + + // The settings' own list is not grown in place: a second call, and the + // excluded list zn shows in Settings, stay as vault.json has them. + s := ParseSettings(map[string]any{ + "tasks": map[string]any{"excludedFolders": []any{"inbox/Books"}}, + "templates": map[string]any{"folder": "Templates"}, + }) + s.TasksFolderLists() + if !reflect.DeepEqual(s.TasksExcludedFolders, []string{"inbox/Books"}) { + t.Errorf("TasksFolderLists grew the settings' list: %v", s.TasksExcludedFolders) + } +} + +// What vault.json can hold under `templates`, read the way the TypeScript +// reads an `unknown`: anything it takes as absent is no folder, and never an +// error for the rest of the file. +func TestTemplateFolderSettingMatchesTypeScript(t *testing.T) { + cases := []struct { + name, raw, want string + }{ + {"absent", `{}`, ""}, + {"folder", `{"templates":{"folder":"Templates"}}`, "Templates"}, + {"cleaned", `{"templates":{"folder":" /Templates/ "}}`, "Templates"}, + {"empty", `{"templates":{"folder":""}}`, ""}, + {"traversal", `{"templates":{"folder":"../x"}}`, ""}, + {"hidden", `{"templates":{"folder":".zennotes"}}`, ""}, + {"not a string", `{"templates":{"folder":42}}`, ""}, + {"not an object", `{"templates":"Templates"}`, ""}, + {"an array", `{"templates":["Templates"]}`, ""}, + {"null", `{"templates":null}`, ""}, + {"keys match exactly", `{"templates":{"Folder":"Templates"}}`, ""}, + {"a stray key", `{"templates":{"folder":"Templates","extra":1}}`, "Templates"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var raw map[string]any + if err := json.Unmarshal([]byte(tc.raw), &raw); err != nil { + t.Fatal(err) + } + if got := ParseSettings(raw).TemplateFolder; got != tc.want { + t.Errorf("TemplateFolder = %q, want %q", got, tc.want) + } + }) + } +} + +func openTemplateFolderVault(t *testing.T, settings string, files map[string]string) *Vault { + t.Helper() + root := t.TempDir() + writeFile(t, filepath.Join(root, ".zennotes", "vault.json"), settings) + for rel, body := range files { + writeFile(t, filepath.Join(root, filepath.FromSlash(rel)), body) + } + v, err := Open(root) + if err != nil { + t.Fatal(err) + } + return v +} + +func taskSources(tasks []Task) []string { + out := []string{} + seen := map[string]bool{} + for _, tk := range tasks { + if !seen[tk.SourcePath] { + seen[tk.SourcePath] = true + out = append(out, tk.SourcePath) + } + } + sort.Strings(out) + return out +} + +// #1024 end to end: the scan and the single-note rescan skip the template +// folder, IncludeExcluded reaches it again, and clearing the setting brings +// its tasks back. +func TestTemplateFolderStaysOutOfTasks(t *testing.T) { + v := openTemplateFolderVault(t, `{"primaryNotesLocation":"root","templates":{"folder":"Templates"}}`, map[string]string{ + "Templates/Meeting.md": "# {{title}}\n\n- [ ] agenda item\n", + "Templates/Work/Retro.md": "- [ ] action item\n", + "Templates Old/Keep.md": "- [ ] still work\n", + "Notes/Plan.md": "- [ ] real work\n", + }) + tasks, err := v.ScanTasks(ParseTasksOptions{}) + if err != nil { + t.Fatal(err) + } + if got, want := taskSources(tasks), []string{"Notes/Plan.md", "Templates Old/Keep.md"}; !reflect.DeepEqual(got, want) { + t.Fatalf("scan = %v, want %v", got, want) + } + for _, rel := range []string{"Templates/Meeting.md", "Templates/Work/Retro.md"} { + one, err := v.ScanTasksForPath(rel, ParseTasksOptions{}) + if err != nil { + t.Fatal(err) + } + if len(one) != 0 { + t.Errorf("rescan of %s = %d tasks, want none", rel, len(one)) + } + forced, err := v.ScanTasksForPath(rel, ParseTasksOptions{IncludeExcluded: true}) + if err != nil { + t.Fatal(err) + } + if len(forced) != 1 { + t.Errorf("IncludeExcluded rescan of %s = %d tasks, want 1", rel, len(forced)) + } + } + all, err := v.ScanTasks(ParseTasksOptions{IncludeExcluded: true}) + if err != nil { + t.Fatal(err) + } + if got := taskSources(all); len(got) != 4 { + t.Fatalf("IncludeExcluded scan = %v, want all four notes", got) + } + + if err := v.UpdateSettings(func(raw map[string]any) { delete(raw, "templates") }); err != nil { + t.Fatal(err) + } + cleared, err := v.ScanTasks(ParseTasksOptions{}) + if err != nil { + t.Fatal(err) + } + if got := taskSources(cleared); len(got) != 4 { + t.Fatalf("scan after clearing the folder = %v, want all four notes", got) + } +} + +// The setting names the folder as it is on disk, so a remapped inbox needs +// no translation, and a template folder inside a Task folder still wins. +func TestTemplateFolderFollowsTheOnDiskPath(t *testing.T) { + v := openTemplateFolderVault(t, + `{"primaryNotesLocation":"inbox","systemFolderPaths":{"inbox":"Entry"},"tasks":{"includedFolders":["Entry"]},"templates":{"folder":"Entry/Templates"}}`, + map[string]string{ + "Entry/Templates/Meeting.md": "- [ ] agenda item\n", + "Entry/Plan.md": "- [ ] real work\n", + }) + tasks, err := v.ScanTasks(ParseTasksOptions{}) + if err != nil { + t.Fatal(err) + } + if got, want := taskSources(tasks), []string{"Entry/Plan.md"}; !reflect.DeepEqual(got, want) { + t.Fatalf("scan = %v, want %v", got, want) + } +} + +// A folder that does not exist, or one the rules refuse, leaves Tasks and +// search as they were. +func TestMissingOrUnusableTemplateFolderChangesNothing(t *testing.T) { + for _, folder := range []string{`"Gone"`, `".zennotes/templates"`, `"../Notes"`, `42`} { + v := openTemplateFolderVault(t, `{"primaryNotesLocation":"root","templates":{"folder":`+folder+`}}`, map[string]string{ + "Notes/Plan.md": "- [ ] real work\n", + "Templates/Meet.md": "- [ ] agenda item\n", + }) + tasks, err := v.ScanTasks(ParseTasksOptions{}) + if err != nil { + t.Fatal(err) + } + if got := taskSources(tasks); len(got) != 2 { + t.Errorf("folder %s: scan = %v, want both notes", folder, got) + } + matches, err := v.SearchText("item", 10) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 { + t.Errorf("folder %s: search = %v, want the template's line", folder, matches) + } + } +} + +// Text search skips the folder before the limit, so the matches it returns +// are spent on real notes, and clearing the setting brings them back. +func TestSearchTextSkipsTheTemplateFolder(t *testing.T) { + v := openTemplateFolderVault(t, `{"primaryNotesLocation":"root","templates":{"folder":"A Templates"}}`, map[string]string{ + "A Templates/Meeting.md": "Agenda for the standup\n", + "A Templates/Work/Standup.md": "standup notes go here\n", + "B Notes/Monday.md": "Monday standup went well\n", + }) + matches, err := v.SearchText("standup", 1) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 || matches[0].Path != "B Notes/Monday.md" { + t.Fatalf("search = %+v, want only B Notes/Monday.md", matches) + } + + if err := v.UpdateSettings(func(raw map[string]any) { delete(raw, "templates") }); err != nil { + t.Fatal(err) + } + matches, err = v.SearchText("standup", 10) + if err != nil { + t.Fatal(err) + } + if len(matches) != 3 { + t.Fatalf("search after clearing the folder = %+v, want three matches", matches) + } +} + +// A folder rename or move repoints the setting, as desktop main, MCP and the +// server do, and keeps whatever else vault.json holds under `templates`. +func TestRenameFolderRepointsTheTemplateFolder(t *testing.T) { + v := openTemplateFolderVault(t, + `{"primaryNotesLocation":"inbox","favorites":["x"],"templates":{"folder":"inbox/Meta/Templates","later":true}}`, + map[string]string{ + "inbox/Meta/Templates/Meeting.md": "- [ ] agenda item\n", + "inbox/Plan.md": "- [ ] real work\n", + }) + if _, err := v.RenameFolder(FolderInbox, "Meta", "Old/Meta"); err != nil { + t.Fatal(err) + } + s := v.Settings() + if s.TemplateFolder != "inbox/Old/Meta/Templates" { + t.Fatalf("template folder after the move = %q", s.TemplateFolder) + } + templates, _ := s.Raw["templates"].(map[string]any) + if templates["later"] != true || !reflect.DeepEqual(s.Favorites, []string{"x"}) { + t.Errorf("the rewrite lost other vault.json keys: %v", s.Raw) + } + tasks, err := v.ScanTasks(ParseTasksOptions{}) + if err != nil { + t.Fatal(err) + } + if got, want := taskSources(tasks), []string{"inbox/Plan.md"}; !reflect.DeepEqual(got, want) { + t.Fatalf("scan after the move = %v, want %v", got, want) + } + + if _, err := v.RenameFolder(FolderInbox, "Old/Meta/Templates", "Old/Meta/Blueprints"); err != nil { + t.Fatal(err) + } + if got := v.Settings().TemplateFolder; got != "inbox/Old/Meta/Blueprints" { + t.Fatalf("template folder after the rename = %q", got) + } +} + +// Renaming a folder the setting does not name leaves vault.json untouched. +func TestRenameOfAnotherFolderLeavesTheTemplateFolder(t *testing.T) { + v := openTemplateFolderVault(t, `{"primaryNotesLocation":"root","templates":{"folder":"Templates"}}`, map[string]string{ + "Templates/Meeting.md": "x\n", + "Templates Old/Keep.md": "y\n", + }) + before, err := os.ReadFile(v.settingsPath()) + if err != nil { + t.Fatal(err) + } + if _, err := v.RenameFolder(FolderInbox, "Templates Old", "Older"); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(v.settingsPath()) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatalf("vault.json was rewritten:\n%s", after) + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index ac55b2f..6cc502e 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -908,8 +908,8 @@ func (v *Vault) RenameFolder(folder NoteFolder, oldSubpath, newSubpath string) ( } v.invalidateLayout() // The Tasks folder lists name on-disk paths, so they follow the folder: a - // renamed Task folder left behind would empty Tasks (#962). Its icon and - // color follow it too. + // renamed Task folder left behind would empty Tasks (#962). So does the + // template folder (ZenNotes/zennotes#1024), and its icon and color. oldSub, _ := filepath.Rel(base, oldAbs) newSub, _ := filepath.Rel(base, newAbs) if err := v.rewriteSettingsForFolderRename(folder, ToPosix(oldSub), ToPosix(newSub), v.relPosix(oldAbs), v.relPosix(newAbs)); err != nil { @@ -944,7 +944,10 @@ func (v *Vault) DeleteFolder(folder NoteFolder, subpath string) error { // --- search --- // SearchText is a case-insensitive substring search over every line of -// every live note, capped at limit matches. +// every live note, capped at limit matches. The template folder's notes are +// placeholders nobody is searching for (ZenNotes/zennotes#1024), so they are +// skipped before the cap, as the app and the server skip them, and the limit +// is spent on real notes. func (v *Vault) SearchText(query string, limit int) ([]TextSearchMatch, error) { trimmed := strings.TrimSpace(query) if trimmed == "" { @@ -954,16 +957,20 @@ func (v *Vault) SearchText(query string, limit int) ([]TextSearchMatch, error) { limit = 80 } needle := strings.ToLower(trimmed) + templateFolder := v.Settings().TemplateFolder out := []TextSearchMatch{} v.walkNotes(LiveFolders, true, func(f noteFile) { if len(out) >= limit { return } + rel := v.relPosix(f.abs) + if IsPathInTemplateFolder(rel, templateFolder) { + return + } body, err := os.ReadFile(f.abs) if err != nil { return } - rel := v.relPosix(f.abs) title := strings.TrimSuffix(filepath.Base(f.abs), filepath.Ext(f.abs)) for i, line := range strings.Split(string(body), "\n") { if len(out) >= limit { @@ -992,12 +999,11 @@ func (v *Vault) SearchText(query string, limit int) ([]TextSearchMatch, error) { // --- tasks --- // ScanTasks parses every task in every live note, honoring the vault's -// excluded folders, its Task folders (#962) and each note's `tasks:` opt-out -// unless asked not to. +// excluded folders, its Task folders (#962), its template folder, whose +// checkboxes are placeholders (ZenNotes/zennotes#1024), and each note's +// `tasks:` opt-out unless asked not to. func (v *Vault) ScanTasks(opts ParseTasksOptions) ([]Task, error) { - settings := v.Settings() - excluded := settings.TasksExcludedFolders - included := settings.TasksIncludedFolders + excluded, included := v.Settings().TasksFolderLists() if opts.IncludeExcluded { excluded = nil included = nil @@ -1034,8 +1040,9 @@ func (v *Vault) ScanTasks(opts ParseTasksOptions) ([]Task, error) { } // ScanTasksForPath parses one note's tasks. A trashed or unclassifiable note -// contributes nothing, and neither does one under an excluded folder unless -// opts asks for it. +// contributes nothing, and neither does one the full scan's folder lists +// leave out (an excluded folder, outside the Task folders, or the template +// folder) unless opts asks for it. func (v *Vault) ScanTasksForPath(rel string, opts ParseTasksOptions) ([]Task, error) { abs, err := v.AbsPath(rel) if err != nil { @@ -1050,9 +1057,10 @@ func (v *Vault) ScanTasksForPath(rel string, opts ParseTasksOptions) ([]Task, er return []Task{}, nil } relPosix := v.relPosix(abs) - settings := v.Settings() - if !opts.IncludeExcluded && IsPathExcludedFromTasks(relPosix, settings.TasksExcludedFolders, settings.TasksIncludedFolders) { - return []Task{}, nil + if !opts.IncludeExcluded { + if excluded, included := v.Settings().TasksFolderLists(); IsPathExcludedFromTasks(relPosix, excluded, included) { + return []Task{}, nil + } } title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) return ParseTasks(relPosix, title, folder, string(body), opts), nil