From 140ca2d719c96a9b158e8924b71d5a79b1089b94 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 30 Oct 2026 13:48:56 -0500 Subject: [PATCH 1/4] Feat(vault): zn leaves the vault's template folder out of Tasks and text search ZenNotes 2.80 lets a vault name a template folder (ZenNotes/zennotes#1024): `templates.folder` in vault.json, a vault-relative path such as `Templates`, whose every note is a template. A template's `- [ ]` lines are placeholders for the note it will make, so the desktop and znserver keep them out of Tasks and keep the folder's notes out of text search. zn read neither, so `zn task list`, `zn task watch`, the TUI's Tasks and `list_tasks` in `zn mcp` listed every placeholder as work, and `zn search` and `search_text` spent their limit on templates. template_folder.go mirrors shared-domain's template-folder.ts and znserver's template_folder.go rule for rule: backslashes become slashes, empty segments drop, each segment is trimmed as JavaScript's trim() trims (NEL stays), a segment starting with a dot refuses the whole value, and the path is capped at 512 UTF-16 code units. `templates` that is not an object, or a folder that is not a string, is no folder, and keys match exactly. The path is the folder as it is on disk, so a remapped inbox needs no translation, and a folder that does not exist simply matches nothing. TasksFolderLists adds the folder to the excluded folders, inside a Task folder too, as tasksFolderListsForSettings does, for ScanTasks and ScanTasksForPath; IncludeExcluded (`--include-excluded`, `includeExcluded`) still scans every note. SearchText skips the folder before its limit, as the server does, so the matches go to real notes. A folder rename or move repoints the setting with the Tasks lists and keeps any other key under `templates`. On a server vault the server applies all of this itself. `zn mcp` serves the desktop's instructions verbatim again: the list_tasks paragraph names the template folder and the Task folders, and the includeExcluded description says the same. Left alone, as the desktop leaves them: the tag list and tag search, and title search (`zn search-title`, `search_by_title`), which is how a template is found to be edited. Tests, written red first: the normalizer's cases and the Tasks lists with the answers the TypeScript gives (znserver checked the same inputs under Node), the decode cases, the scan, the single-note rescan, IncludeExcluded and clearing the setting, a remapped inbox with a Task folder, a missing and an unusable folder, text search before and after clearing, a rename, a move and an unrelated rename; then `zn task list`, `zn search` and `zn task watch` through the CLI, and list_tasks and search_text through zn mcp. --- internal/cli/template_folder_test.go | 91 ++++++ internal/mcp/instructions.go | 3 +- internal/mcp/server.go | 2 +- internal/mcp/template_folder_test.go | 84 ++++++ internal/vault/settings.go | 21 +- internal/vault/template_folder.go | 115 ++++++++ internal/vault/template_folder_test.go | 378 +++++++++++++++++++++++++ internal/vault/vault.go | 36 ++- 8 files changed, 709 insertions(+), 21 deletions(-) create mode 100644 internal/cli/template_folder_test.go create mode 100644 internal/mcp/template_folder_test.go create mode 100644 internal/vault/template_folder.go create mode 100644 internal/vault/template_folder_test.go diff --git a/internal/cli/template_folder_test.go b/internal/cli/template_folder_test.go new file mode 100644 index 0000000..2db5499 --- /dev/null +++ b/internal/cli/template_folder_test.go @@ -0,0 +1,91 @@ +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()) + } +} 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..eb36b3d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -769,7 +769,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..db59291 --- /dev/null +++ b/internal/mcp/template_folder_test.go @@ -0,0 +1,84 @@ +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") + } +} 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 From 191a891c6c0b715b030ffe84dd5d2f2852b6aa66 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 30 Oct 2026 13:49:15 -0500 Subject: [PATCH 2/4] Feat(tui): Search notes hides templates until the query names their folder The desktop's Search Notes leaves the template folder's notes out (ZenNotes/zennotes#1024), since nobody searching their notes is looking for a placeholder, and brings them back once the query starts with the folder's path and a slash (`Templates/meet`), which is how a template is found to be edited. The TUI's Search notes (`Ctrl+P`, `Space f`) listed them like any other note, a `#tag` query included. search.Notes takes the folder and applies the desktop's rule: the query's free text, its `#tag` tokens set aside, is compared case-insensitively with the folder's path and a slash, and until it matches, the folder's notes are skipped before scoring, so the 50 results go to real notes. The palette's empty-query list (recent notes, then the inbox) skips them too, as the desktop's empty query does. The relation picker in databases and the `:open` fallback pass no folder and keep every note, since they are not searches. Settings lists `templates.folder` under the Tasks folder rows, because it takes a folder out of Tasks as they do and someone missing a folder's tasks should see why. An edit is cleaned by the shared rules before it is written, a value the rules refuse is turned away with a notice instead of being saved as no folder, and an empty edit removes the key (and an empty `templates` object with it). The terminal's template pickers do not read the folder yet; that is a separate change. Tests, written red first: Search notes with an empty query, a title, a `#tag`, the folder's name without a slash, `Templates/` and `templates/meet` with and without a tag; the Tasks view's scan; the Settings row; and the setter. Driven against a znserver built from ZenNotes/znserver#54: the TUI read `templates.folder` from the server's settings, Search notes hid the templates until `Templates/`, and Tasks listed only the real note. --- internal/search/notes.go | 27 +++++- internal/tui/commands.go | 2 +- internal/tui/noteops.go | 9 +- internal/tui/settings_controls.go | 31 +++++++ internal/tui/template_folder_test.go | 119 +++++++++++++++++++++++++++ internal/tui/views_database_edit.go | 2 +- 6 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 internal/tui/template_folder_test.go 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/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/template_folder_test.go b/internal/tui/template_folder_test.go new file mode 100644 index 0000000..c7d4de7 --- /dev/null +++ b/internal/tui/template_folder_test.go @@ -0,0 +1,119 @@ +package tui + +import ( + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" +) + +// 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]\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) + } +} diff --git a/internal/tui/views_database_edit.go b/internal/tui/views_database_edit.go index f09f4bb..9b50476 100644 --- a/internal/tui/views_database_edit.go +++ b/internal/tui/views_database_edit.go @@ -192,7 +192,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{} From 4d2b9d7e893bbaac6c6de69f015e2474e67e2439 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 30 Oct 2026 13:49:22 -0500 Subject: [PATCH 3/4] Docs: zn help and the README say what the template folder leaves out `zn help` names the template folder on `search` and `task list`, and the README says in one paragraph which commands leave its notes out, that `--include-excluded` lists their tasks, and how Search notes finds them. --- README.md | 6 ++++++ internal/cli/help.go | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c88a055..30cebce 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` 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/cli/help.go b/internal/cli/help.go index a0f1bfb..64c2cf6 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"}, }}, @@ -64,7 +64,7 @@ var helpSections = []helpSection{ {"tag find ", "Notes carrying this #tag", "--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"}, }}, From 2df43e938cd5567ba3a759cc5f81c08c9eca496d Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 30 Oct 2026 13:55:14 -0500 Subject: [PATCH 4/4] Feat(tags): a note in the template folder contributes no tags A template's tags are the tags the note it makes will carry, not tags the vault uses, so ZenNotes 2.80.0 leaves the template folder (ZenNotes/zennotes#1024) out of the tag list and tag search, as it already leaves it out of Tasks and text search. zn still counted a template's tags in `zn tag list`, `list_tags` and the TUI's Tags view, and returned templates from every tag search. A note inside the folder now contributes no tags anywhere zn counts or searches them: `zn tag list` and `list_tags` leave its tags out of names and counts; `zn tag find`, `zn list --tag`, `search_by_tag` and `list_notes` with a tag do not return it; and the TUI's tag list (the Tags view, the sidebar, the tag pickers), a tag's notes in the Tags view, tag completion and a field whose options come from a tag skip it. Search notes keeps the escape hatch it already had: a query that starts with the folder's path (`#work Templates/meet`) finds templates again. Title search and `zn list` without a tag are unchanged, and a template keeps its tags in its own note listing, so the hatch can match them. zn counts tags from the note listing on both kinds of vault, so backend.TemplateFolder reads the folder from the settings the backend reports, a server's included; a vault whose settings cannot be read keeps every tag. Renaming or deleting a tag from the Tags view still rewrites it in templates too, so they stay in step with the vault. `zn help` and the README say the tag list and tag searches leave the folder out. Tests, written red first: `zn tag list`, `zn tag find`, `zn list --tag` and `zn list`; list_tags, search_by_tag and list_notes with and without a tag; the TUI's tag list, the Tags view's notes for a tag, a tag-sourced field and tag completion. The built zn gave the same answers on a local vault and on a znserver built from ZenNotes/znserver#54. --- README.md | 8 ++-- internal/backend/backend.go | 14 ++++++ internal/cli/commands_notes.go | 7 ++- internal/cli/commands_tasks_tags.go | 14 +++--- internal/cli/help.go | 4 +- internal/cli/template_folder_test.go | 52 ++++++++++++++++++++++ internal/mcp/server.go | 15 +++++-- internal/mcp/template_folder_test.go | 65 ++++++++++++++++++++++++++++ internal/tui/data.go | 9 ++-- internal/tui/tagcomplete.go | 3 +- internal/tui/template_folder_test.go | 43 +++++++++++++++++- internal/tui/views_database_edit.go | 3 +- internal/tui/views_lists.go | 3 +- 13 files changed, 218 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 30cebce..b5a6785 100644 --- a/README.md +++ b/README.md @@ -243,10 +243,10 @@ 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` 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/`). +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 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 64c2cf6..141f6e2 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -60,8 +60,8 @@ 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 but the template folder", "--unchecked --all --tag --include-excluded --json"}, diff --git a/internal/cli/template_folder_test.go b/internal/cli/template_folder_test.go index 2db5499..28eb04a 100644 --- a/internal/cli/template_folder_test.go +++ b/internal/cli/template_folder_test.go @@ -89,3 +89,55 @@ func TestTaskWatchIgnoresTheTemplateFolder(t *testing.T) { 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/server.go b/internal/mcp/server.go index eb36b3d..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 { diff --git a/internal/mcp/template_folder_test.go b/internal/mcp/template_folder_test.go index db59291..3d86230 100644 --- a/internal/mcp/template_folder_test.go +++ b/internal/mcp/template_folder_test.go @@ -82,3 +82,68 @@ func TestInstructionsSayListTasksLeavesTheTemplateFolderOut(t *testing.T) { 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/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/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 index c7d4de7..2de3084 100644 --- a/internal/tui/template_folder_test.go +++ b/internal/tui/template_folder_test.go @@ -7,6 +7,8 @@ import ( "slices" "strings" "testing" + + "github.com/ZenNotes/tui/internal/database" ) // templateFolderApp opens a root-layout vault whose `Templates` folder is its @@ -17,7 +19,7 @@ func templateFolderApp(t *testing.T) *App { root := t.TempDir() for rel, body := range map[string]string{ ".zennotes/vault.json": `{"primaryNotesLocation":"root","templates":{"folder":"Templates"}}`, - "Templates/Meeting.md": "---\ntags: [work]\n---\n- [ ] Agenda item\n", + "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", } { @@ -117,3 +119,42 @@ func TestSetTemplateFolder(t *testing.T) { 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 9b50476..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 } } 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{}