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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`. Comment
replies and resolve/reopen operations take a thread ID. `base add` and
`base set` accept repeated `--set Field=Value` flags for existing fields;
Expand Down
14 changes: 14 additions & 0 deletions internal/backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion internal/cli/commands_notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
14 changes: 9 additions & 5 deletions internal/cli/commands_tasks_tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down
8 changes: 4 additions & 4 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ var helpSections = []helpSection{
{"duplicate <path>", "Copy a note next to itself", ""},
}},
{"SEARCH", []helpRow{
{"search <query>", "Full-text search across live notes", "--limit <n> --json"},
{"search <query>", "Full-text search across live notes, the template folder left out", "--limit <n> --json"},
{"search-title <q>", "Match notes by title (substring)", "--json"},
{"backlinks <path>", "Notes linking to this one via [[wikilink]]", "--json"},
}},
Expand All @@ -60,11 +60,11 @@ var helpSections = []helpSection{
{"folder delete <p>", "Delete a subfolder and everything in it", "--yes"},
}},
{"TAGS", []helpRow{
{"tag list", "Every #tag with its note count", "--json"},
{"tag find <tag>", "Notes carrying this #tag", "--limit <n> --json"},
{"tag list", "Every #tag with its note count, the template folder left out", "--json"},
{"tag find <tag>", "Notes carrying this #tag, the template folder left out", "--limit <n> --json"},
}},
{"TASKS", []helpRow{
{"task list", "Open checkbox tasks across all notes", "--unchecked --all --tag <t> --include-excluded --json"},
{"task list", "Open checkbox tasks across all notes but the template folder", "--unchecked --all --tag <t> --include-excluded --json"},
{"task toggle <id>", "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 <s> --include-excluded --json"},
}},
Expand Down
143 changes: 143 additions & 0 deletions internal/cli/template_folder_test.go
Original file line number Diff line number Diff line change
@@ -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), &notes)
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)
}
}
3 changes: 2 additions & 1 deletion internal/mcp/instructions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" +
Expand Down
17 changes: 13 additions & 4 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand All @@ -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)) {
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading