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
87 changes: 73 additions & 14 deletions internal/vault/tasks_exclude.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ package vault

import "strings"

// The vault-level "exclude this folder from Tasks" list (#458). Byte-for-byte
// mirror of packages/shared-domain/src/tasks-excluded-folders.ts: change both
// together. Entries are vault-relative directory paths exactly as they exist
// on disk, so remapped system folders (#115) need no translation.
// The vault-level folder lists for Tasks: the excluded folders (#458) and the
// Task folders that narrow Tasks to themselves (#962). Byte-for-byte mirror of
// packages/shared-domain/src/tasks-excluded-folders.ts: change both together.
// Entries are vault-relative directory paths exactly as they exist on disk, so
// remapped system folders (#115) need no translation.

// normalizeTasksExcludedFolder validates one entry: forward slashes, no empty
// or dot segments, no traversal. Returns "" when invalid.
Expand Down Expand Up @@ -51,17 +52,26 @@ func normalizeTasksExcludedFolders(values []string) []string {
}

// normalizeTasksSettings carries the Tasks-system settings through the
// settings round-trip: a validated exclusion list, or nil so vault.json stays
// free of empty stubs.
// settings round-trip: validated excluded and Task folder lists, each left out
// when empty, or nil so vault.json stays free of empty stubs. The Task folders
// are validated exactly like the excluded ones.
func normalizeTasksSettings(value *TasksSettings) *TasksSettings {
if value == nil {
return nil
}
excluded := normalizeTasksExcludedFolders(value.ExcludedFolders)
if len(excluded) == 0 {
included := normalizeTasksExcludedFolders(value.IncludedFolders)
if len(excluded) == 0 && len(included) == 0 {
return nil
}
return &TasksSettings{ExcludedFolders: excluded}
out := &TasksSettings{}
if len(excluded) > 0 {
out.ExcludedFolders = excluded
}
if len(included) > 0 {
out.IncludedFolders = included
}
return out
}

// tasksExcludedFolders reads the exclusion list off already-normalized
Expand All @@ -73,15 +83,64 @@ func tasksExcludedFolders(settings VaultSettings) []string {
return settings.Tasks.ExcludedFolders
}

// isPathExcludedFromTasks reports whether a vault-relative POSIX path lives
// inside any excluded folder. Segment-prefix match, case-sensitive like the
// rest of the vault layer: `inbox/Books` excludes `inbox/Books/x.md` and
// `inbox/Books/sub/y.md`, never `inbox/Bookshelf.md`.
func isPathExcludedFromTasks(relPath string, excluded []string) bool {
for _, folder := range excluded {
// tasksIncludedFolders reads the Task folders off already-normalized settings.
func tasksIncludedFolders(settings VaultSettings) []string {
if settings.Tasks == nil {
return nil
}
return settings.Tasks.IncludedFolders
}

// isPathExcludedFromTasks reports whether a vault-relative POSIX path is kept
// out of Tasks by the folder lists. Segment-prefix match, case-sensitive like
// the rest of the vault layer: `inbox/Books` excludes `inbox/Books/x.md` and
// `inbox/Books/sub/y.md`, never `inbox/Bookshelf.md`. Once included names
// folders, a path outside all of them is out as well; excluded still applies
// inside them, so `Projects` with `Projects/Archive` excluded leaves the
// archive out.
func isPathExcludedFromTasks(relPath string, excluded, included []string) bool {
if len(included) > 0 && !isInsideAnyFolder(relPath, included) {
return true
}
return isInsideAnyFolder(relPath, excluded)
}

func isInsideAnyFolder(relPath string, folders []string) bool {
for _, folder := range folders {
if relPath == folder || strings.HasPrefix(relPath, folder+"/") {
return true
}
}
return false
}

// rewriteFolderList repoints one list's entries after a folder rename or move:
// an entry naming the folder, or a folder inside it, follows it.
func rewriteFolderList(list []string, oldRel, newRel string) []string {
out := make([]string, 0, len(list))
for _, entry := range list {
if entry == oldRel || strings.HasPrefix(entry, oldRel+"/") {
entry = newRel + entry[len(oldRel):]
}
out = append(out, entry)
}
return normalizeTasksExcludedFolders(out)
}

// rewriteTasksFoldersForRename keeps both folder lists naming the same folders
// after a rename or move. A renamed Task folder (#962) left behind would match
// nothing, and once it was the only one, Tasks would go empty. Mirrors
// rewriteTasksFoldersForRename in tasks-excluded-folders.ts.
func rewriteTasksFoldersForRename(tasks *TasksSettings, oldRel, newRel string) *TasksSettings {
if tasks == nil || oldRel == "" || newRel == "" || oldRel == newRel {
return tasks
}
next := &TasksSettings{}
if len(tasks.ExcludedFolders) > 0 {
next.ExcludedFolders = rewriteFolderList(tasks.ExcludedFolders, oldRel, newRel)
}
if len(tasks.IncludedFolders) > 0 {
next.IncludedFolders = rewriteFolderList(tasks.IncludedFolders, oldRel, newRel)
}
return next
}
141 changes: 138 additions & 3 deletions internal/vault/tasks_exclude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ func TestIsPathExcludedFromTasks(t *testing.T) {
{"quick/note.md", false},
}
for _, tc := range cases {
if got := isPathExcludedFromTasks(tc.path, excluded); got != tc.want {
if got := isPathExcludedFromTasks(tc.path, excluded, nil); got != tc.want {
t.Errorf("isPathExcludedFromTasks(%q) = %v, want %v", tc.path, got, tc.want)
}
}
if isPathExcludedFromTasks("inbox/Books/dune.md", nil) {
if isPathExcludedFromTasks("inbox/Books/dune.md", nil, nil) {
t.Error("empty exclusion list must never match")
}
}
Expand Down Expand Up @@ -80,7 +80,7 @@ func TestScanTasksHonorsExcludedFolders(t *testing.T) {
t.Fatal(err)
}
for _, tk := range tasks {
if isPathExcludedFromTasks(tk.SourcePath, []string{"inbox/Books"}) {
if isPathExcludedFromTasks(tk.SourcePath, []string{"inbox/Books"}, nil) {
t.Errorf("excluded-folder task leaked into the default scan: %s", tk.ID)
}
}
Expand All @@ -96,3 +96,138 @@ func TestScanTasksHonorsExcludedFolders(t *testing.T) {
t.Fatalf("expected 3 tasks with IncludeExcluded, got %d", len(all))
}
}

// #962: the Task folders, mirroring the shared-domain cases.
func TestIsPathExcludedFromTasksWithTaskFolders(t *testing.T) {
cases := []struct {
path string
excluded []string
included []string
want bool
}{
{"inbox/Idea.md", nil, nil, false},
{"Tasks/Today.md", nil, []string{"Tasks"}, false},
{"Tasks/Work/Q4.md", nil, []string{"Tasks"}, false},
{"inbox/Books.md", nil, []string{"Tasks"}, true},
{"TasksArchive/Old.md", nil, []string{"Tasks"}, true},
{"inbox/Projects/Site.md", nil, []string{"Tasks", "inbox/Projects"}, false},
{"Projects/Archive/Old.md", []string{"Projects/Archive"}, []string{"Projects"}, true},
{"Projects/Live.md", []string{"Projects/Archive"}, []string{"Projects"}, false},
}
for _, tc := range cases {
if got := isPathExcludedFromTasks(tc.path, tc.excluded, tc.included); got != tc.want {
t.Errorf("isPathExcludedFromTasks(%q, %v, %v) = %v, want %v", tc.path, tc.excluded, tc.included, got, tc.want)
}
}
}

// End to end through New, GetSettings (cache plus cloneSettings, the copy
// that once dropped Tasks) and both scans.
func TestScanTasksHonorsTaskFolders(t *testing.T) {
root := t.TempDir()
mustWrite := func(rel, body string) {
t.Helper()
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)
}
}
mustWrite(".zennotes/vault.json", `{"tasks":{"includedFolders":["inbox/Tasks"],"excludedFolders":["inbox/Tasks/Someday"]}}`)
mustWrite("inbox/Tasks/Today.md", "- [ ] ship it\n")
mustWrite("inbox/Tasks/Someday/Maybe.md", "- [ ] later\n")
mustWrite("inbox/Reading.md", "- [ ] Excession\n- [ ] Player of Games\n")

v, err := New(root, Options{})
if err != nil {
t.Fatal(err)
}
settings, err := v.GetSettings()
if err != nil {
t.Fatal(err)
}
if got := tasksIncludedFolders(settings); !reflect.DeepEqual(got, []string{"inbox/Tasks"}) {
t.Fatalf("GetSettings dropped the Task folders: %v", got)
}

tasks, err := v.ScanTasks()
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 || tasks[0].SourcePath != "inbox/Tasks/Today.md" {
t.Fatalf("expected only the Today task, got %+v", tasks)
}

for rel, want := range map[string]int{
"inbox/Tasks/Today.md": 1,
"inbox/Tasks/Someday/Maybe.md": 0,
"inbox/Reading.md": 0,
} {
got, err := v.ScanTasksForPath(rel)
if err != nil {
t.Fatal(err)
}
if len(got) != want {
t.Errorf("ScanTasksForPath(%q) = %d tasks, want %d", rel, len(got), want)
}
}

all, err := v.ScanTasksWith(ParseTasksOptions{IncludeExcluded: true})
if err != nil {
t.Fatal(err)
}
if len(all) != 4 {
t.Fatalf("expected 4 tasks with IncludeExcluded, got %d", len(all))
}
}

// #962: renaming a Task folder through the vault API carries both lists along,
// end to end through SetSettings and GetSettings.
func TestRenameFolderRewritesTaskFolders(t *testing.T) {
root := t.TempDir()
mustWrite := func(rel, body string) {
t.Helper()
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)
}
}
mustWrite(".zennotes/vault.json", `{"tasks":{"includedFolders":["inbox/Tasks","inbox/TasksArchive"],"excludedFolders":["inbox/Tasks/Someday"]}}`)
mustWrite("inbox/Tasks/Today.md", "- [ ] ship it\n")
mustWrite("inbox/Tasks/Someday/Maybe.md", "- [ ] later\n")
mustWrite("inbox/TasksArchive/Old.md", "- [x] done\n")

v, err := New(root, Options{})
if err != nil {
t.Fatal(err)
}
if _, err := v.RenameFolder(FolderInbox, "Tasks", "Todo"); err != nil {
t.Fatal(err)
}
settings, err := v.GetSettings()
if err != nil {
t.Fatal(err)
}
if got, want := tasksIncludedFolders(settings), []string{"inbox/Todo", "inbox/TasksArchive"}; !reflect.DeepEqual(got, want) {
t.Errorf("Task folders after rename = %v, want %v", got, want)
}
if got, want := tasksExcludedFolders(settings), []string{"inbox/Todo/Someday"}; !reflect.DeepEqual(got, want) {
t.Errorf("excluded folders after rename = %v, want %v", got, want)
}
tasks, err := v.ScanTasks()
if err != nil {
t.Fatal(err)
}
found := map[string]bool{}
for _, tk := range tasks {
found[tk.SourcePath] = true
}
if !found["inbox/Todo/Today.md"] || found["inbox/Todo/Someday/Maybe.md"] {
t.Errorf("tasks after rename = %v", found)
}
}
8 changes: 7 additions & 1 deletion internal/vault/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,11 +311,17 @@ type HarperSettings struct {
IgnoredLints []string `json:"ignoredLints"`
}

// TasksSettings mirrors shared/ipc.ts VaultSettings.tasks (#458).
// TasksSettings mirrors shared/ipc.ts VaultSettings.tasks (#458, #962).
// A new field here also needs normalizeTasksSettings and cloneSettings, or
// GetSettings hands it back stripped.
type TasksSettings struct {
// ExcludedFolders lists vault-relative directory paths (as they exist on
// disk) whose notes never feed the Tasks surfaces.
ExcludedFolders []string `json:"excludedFolders,omitempty"`
// IncludedFolders, the Task folders (#962), narrows Tasks to the notes
// inside the folders it lists. Empty means the whole vault, and
// ExcludedFolders still applies inside them.
IncludedFolders []string `json:"includedFolders,omitempty"`
}

// TypstPreambleSettings mirrors shared/ipc.ts VaultSettings.typstPreambles
Expand Down
35 changes: 24 additions & 11 deletions internal/vault/vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,12 @@ func cloneSettings(settings VaultSettings) VaultSettings {
if settings.Tasks != nil {
excluded := make([]string, len(settings.Tasks.ExcludedFolders))
copy(excluded, settings.Tasks.ExcludedFolders)
tasks = &TasksSettings{ExcludedFolders: excluded}
var included []string
if len(settings.Tasks.IncludedFolders) > 0 {
included = make([]string, len(settings.Tasks.IncludedFolders))
copy(included, settings.Tasks.IncludedFolders)
}
tasks = &TasksSettings{ExcludedFolders: excluded, IncludedFolders: included}
}
// The same rule for the two later pointer fields: a caller reading the
// value SetSettings hands back must see what was written, not nil.
Expand Down Expand Up @@ -2400,10 +2405,14 @@ func (v *Vault) RenameFolder(folder NoteFolder, oldSub, newSub string) (string,
if err != nil {
return "", err
}
oldVaultRel, _ := filepath.Rel(v.root, oldAbs)
newVaultRel, _ := filepath.Rel(v.root, newAbs)
err = v.relocateFolderTrees([][2]string{{oldAbs, newAbs}, {oldComments, newComments}}, func() error {
next := settings
next.FolderIcons = rewriteFolderIconsForRename(settings.FolderIcons, folder, oldSub, newSub)
next.FolderColors = rewriteFolderColorsForRename(settings.FolderColors, folder, oldSub, newSub)
// The Tasks folder lists name on-disk paths, so they follow the folder.
next.Tasks = rewriteTasksFoldersForRename(settings.Tasks, filepath.ToSlash(oldVaultRel), filepath.ToSlash(newVaultRel))
_, err := v.SetSettings(next)
return err
})
Expand Down Expand Up @@ -2510,8 +2519,8 @@ func (v *Vault) ScanTasks() ([]Task, error) {
}

// ScanTasksWith is ScanTasks honoring options: IncludeExcluded scans past
// both the vault-level excluded-folders list and the note-level frontmatter
// `tasks:` opt-out (#458).
// the vault-level folder lists (the excluded folders and the Task folders,
// #962) and the note-level frontmatter `tasks:` opt-out (#458).
func (v *Vault) ScanTasksWith(opts ParseTasksOptions) ([]Task, error) {
v.mu.RLock()
defer v.mu.RUnlock()
Expand All @@ -2520,8 +2529,10 @@ func (v *Vault) ScanTasksWith(opts ParseTasksOptions) ([]Task, error) {
return nil, err
}
excluded := tasksExcludedFolders(settings)
included := tasksIncludedFolders(settings)
if opts.IncludeExcluded {
excluded = nil
included = nil
}
hiddenRootNames := hiddenPrimaryRootNames(settings)
all := []Task{}
Expand Down Expand Up @@ -2564,13 +2575,15 @@ func (v *Vault) ScanTasksWith(opts ParseTasksOptions) ([]Task, error) {
if !strings.EqualFold(filepath.Ext(d.Name()), ".md") {
return nil
}
body, err := os.ReadFile(path)
if err != nil {
return nil
}
rel, _ := filepath.Rel(v.root, path)
relPosix := filepath.ToSlash(rel)
if isPathExcludedFromTasks(relPosix, excluded) {
// Before the read: with Task folders set, most of a vault is
// outside them and never needs opening.
if isPathExcludedFromTasks(relPosix, excluded, included) {
return nil
}
body, err := os.ReadFile(path)
if err != nil {
return nil
}
title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
Expand Down Expand Up @@ -2604,8 +2617,8 @@ func (v *Vault) ScanTasksForPathWith(rel string, opts ParseTasksOptions) ([]Task
// Same gates as the full scan, or a single-note rescan would resurrect
// tasks the walker skips: trashed and unclassifiable notes contribute
// nothing (mirrors the desktop's LIVE_FOLDERS check), and neither do notes
// under an excluded folder (#458). The caller uses the empty result to
// drop stale rows.
// under an excluded folder (#458) or outside the Task folders (#962). The
// caller uses the empty result to drop stale rows.
folder, ok := v.folderOf(abs)
if !ok || folder == FolderTrash {
return []Task{}, nil
Expand All @@ -2616,7 +2629,7 @@ func (v *Vault) ScanTasksForPathWith(rel string, opts ParseTasksOptions) ([]Task
if err != nil {
return nil, err
}
if isPathExcludedFromTasks(relPosix, tasksExcludedFolders(settings)) {
if isPathExcludedFromTasks(relPosix, tasksExcludedFolders(settings), tasksIncludedFolders(settings)) {
return []Task{}, nil
}
}
Expand Down
2 changes: 1 addition & 1 deletion release.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"version": "2.71.1",
"version": "2.72.0",
"vendorHash": "sha256-ZdOHC2JldvnKSDUFnBUJrKD4F1IWfvYJBksgeDnU9cw="
}
Loading
Loading