diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 32d22d42..b9c437a0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.22.1", + "version": "2.23.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", @@ -268,6 +268,7 @@ "rpm", "tar.gz" ], + "compression": "normal", "icon": "build/icons", "maintainer": "Adib Hanna ", "category": "Office", diff --git a/apps/desktop/src/cli/backend.ts b/apps/desktop/src/cli/backend.ts index 8665bb23..071882b5 100644 --- a/apps/desktop/src/cli/backend.ts +++ b/apps/desktop/src/cli/backend.ts @@ -87,7 +87,7 @@ export interface VaultBackend { deleteFolder(folder: NoteFolder, subpath: string): Promise searchText(query: string, limit: number): Promise backlinks(rel: string): Promise - scanAllTasks(): Promise + scanAllTasks(opts?: { includeExcluded?: boolean }): Promise toggleTask(taskId: string): Promise } @@ -138,7 +138,8 @@ class LocalBackend implements VaultBackend { searchText = (query: string, limit: number): Promise => searchText(this.root, query, limit) backlinks = (rel: string): Promise => backlinks(this.root, rel) - scanAllTasks = (): Promise => scanAllTasks(this.root) + scanAllTasks = (opts?: { includeExcluded?: boolean }): Promise => + scanAllTasks(this.root, opts) toggleTask = (taskId: string): Promise => toggleTask(this.root, taskId) } @@ -213,7 +214,8 @@ class RemoteBackend implements VaultBackend { backlinks = async (rel: string): Promise => backlinksIn(await this.client.listNotes(), normalizeRelPath(rel)) - scanAllTasks = (): Promise => this.client.scanTasks() + scanAllTasks = (opts?: { includeExcluded?: boolean }): Promise => + this.client.scanTasks(opts) /** No task-toggle endpoint exists, so the note is read, the same transform a * local toggle applies is applied here, and the server re-parses the result @@ -222,9 +224,14 @@ class RemoteBackend implements VaultBackend { const { rel, indexStr } = splitTaskId(taskId) const note = await this.client.readNote(rel) + // Exclusion-blind (#458), like the local toggle: an explicit task id is an + // explicit ask, and ids for excluded tasks only circulate via + // `zn task list --include-excluded`. let nextBody: string | null if (indexStr === 'task') { - const current = (await this.client.scanTasksForPath(rel)).find((t) => t.id === taskId) + const current = ( + await this.client.scanTasksForPath(rel, { includeExcluded: true }) + ).find((t) => t.id === taskId) nextBody = current ? toggleFileTaskInBody(note.body, current.checked) : null } else { nextBody = toggleTaskInBody(note.body, parseTaskIndex(taskId, indexStr)) @@ -232,7 +239,11 @@ class RemoteBackend implements VaultBackend { if (nextBody == null) return null await this.client.writeNote(rel, nextBody) - return (await this.client.scanTasksForPath(rel)).find((t) => t.id === taskId) ?? null + return ( + (await this.client.scanTasksForPath(rel, { includeExcluded: true })).find( + (t) => t.id === taskId + ) ?? null + ) } } diff --git a/apps/desktop/src/cli/commands/tasks.ts b/apps/desktop/src/cli/commands/tasks.ts index 54827039..ef5c5235 100644 --- a/apps/desktop/src/cli/commands/tasks.ts +++ b/apps/desktop/src/cli/commands/tasks.ts @@ -10,9 +10,13 @@ import { emitJson, emitLine, emitOk, pad, truncate } from '../format.js' export async function cmdTaskList(vault: VaultBackend, args: ParsedArgs): Promise { const showAll = getBool(args, 'all') const onlyUnchecked = getBool(args, 'unchecked') + // `--all` was taken (it means every STATUS), so the exclusion escape hatch + // (#458) gets its own flag: also scan notes opted out via frontmatter + // `tasks:` and folders on the vault's excluded list. + const includeExcluded = getBool(args, 'include-excluded') const tag = getString(args, 'tag')?.replace(/^#/, '').toLowerCase() - let tasks = await vault.scanAllTasks() + let tasks = await vault.scanAllTasks(includeExcluded ? { includeExcluded: true } : undefined) if (!showAll) { if (onlyUnchecked) tasks = tasks.filter((t) => !t.checked) else tasks = tasks.filter((t) => !t.checked && !t.waiting) diff --git a/apps/desktop/src/cli/help.ts b/apps/desktop/src/cli/help.ts index bd099e3b..17d9415f 100644 --- a/apps/desktop/src/cli/help.ts +++ b/apps/desktop/src/cli/help.ts @@ -134,7 +134,7 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [ { heading: 'TASKS', rows: [ - { name: 'task list', description: 'Open checkbox tasks across all notes', flags: '--unchecked --all --tag --json' }, + { name: 'task list', description: 'Open checkbox tasks across all notes', flags: '--unchecked --all --tag --include-excluded --json' }, { name: 'task toggle ', description: 'Flip a task checkbox by stable id' } ] }, diff --git a/apps/desktop/src/cli/remote/client.ts b/apps/desktop/src/cli/remote/client.ts index d1c9284e..d056fd40 100644 --- a/apps/desktop/src/cli/remote/client.ts +++ b/apps/desktop/src/cli/remote/client.ts @@ -68,12 +68,20 @@ export class CliRemoteClient { return this.get(`/api/search/text?${params.toString()}`) } - scanTasks(): Promise { - return this.get('/api/tasks') - } - - scanTasksForPath(relPath: string): Promise { - return this.get(`/api/tasks/for?path=${encodeURIComponent(relPath)}`) + scanTasks(opts?: { includeExcluded?: boolean }): Promise { + return this.get( + opts?.includeExcluded ? '/api/tasks?includeExcluded=1' : '/api/tasks' + ) + } + + scanTasksForPath( + relPath: string, + opts?: { includeExcluded?: boolean } + ): Promise { + const suffix = opts?.includeExcluded ? '&includeExcluded=1' : '' + return this.get( + `/api/tasks/for?path=${encodeURIComponent(relPath)}${suffix}` + ) } /* --- writes --- */ diff --git a/apps/desktop/src/main/tasks.ts b/apps/desktop/src/main/tasks.ts index 77aaf714..240f17cc 100644 --- a/apps/desktop/src/main/tasks.ts +++ b/apps/desktop/src/main/tasks.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs' import path from 'node:path' import type { NoteFolder, NoteMeta } from '@shared/ipc' import { parseTaskFile, parseTasksFromBody, type VaultTask } from '@shared/tasks' +import { isPathExcludedFromTasks } from '@shared/tasks-excluded-folders' import { folderForRelativePath, getVaultSettings, listNotes } from './vault' /** Emit a note's file-task (if its frontmatter tags it `#task`) plus every @@ -41,9 +42,13 @@ async function readOne( /** Walk the whole vault and parse every task out of every live (non-trash) * note. Parallelized with `Promise.all` so a 500-note vault is IO-bound, - * not sequentially latent. */ + * not sequentially latent. Folders on the vault's `tasks.excludedFolders` + * list (#458) are skipped before any file is read. */ export async function scanAllTasks(root: string): Promise { - const metas = (await listNotes(root)).filter((m) => includesFolder(m.folder)) + const excluded = (await getVaultSettings(root)).tasks?.excludedFolders ?? [] + const metas = (await listNotes(root)).filter( + (m) => includesFolder(m.folder) && !isPathExcludedFromTasks(m.path, excluded) + ) const batches = await Promise.all(metas.map((m) => readOne(root, m))) const out: VaultTask[] = [] for (const b of batches) out.push(...b) @@ -64,8 +69,12 @@ export async function scanTasksForPath( // Settings-aware: with remapped system folders (vault.json // `systemFolderPaths`) the bare classifier would file a remapped Trash's // notes under inbox and leak their checkboxes into the Tasks view. - const folder = folderForRelativePath(posix, await getVaultSettings(root)) + const settings = await getVaultSettings(root) + const folder = folderForRelativePath(posix, settings) if (!folder || !LIVE_FOLDERS.has(folder)) return [] + // Same exclusion the full scan applies (#458), or a single-note rescan + // would resurrect an excluded folder's tasks on every edit. + if (isPathExcludedFromTasks(posix, settings.tasks?.excludedFolders ?? [])) return [] const abs = path.join(root, posix.split('/').join(path.sep)) let body: string diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 96d6bf0a..d4cc1fed 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -77,6 +77,7 @@ import { systemFolderForDirName, type SystemFolderPaths } from '@shared/system-folder-paths' +import { normalizeTasksExcludedFolders } from '@shared/tasks-excluded-folders' const CONFIG_FILE = 'zennotes.config.json' const FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash'] @@ -1045,6 +1046,7 @@ function normalizeVaultSettings( favorites?: unknown view?: unknown systemFolderPaths?: unknown + tasks?: unknown } const folderIcons: Record = {} if (candidate.folderIcons && typeof candidate.folderIcons === 'object') { @@ -1099,10 +1101,22 @@ function normalizeVaultSettings( folderColors: normalizeFolderColors(candidate.folderColors), favorites: normalizeFavorites(candidate.favorites), view: normalizeVaultViewSettings(candidate.view), - systemFolderPaths: normalizeSystemFolderPaths(candidate.systemFolderPaths) + systemFolderPaths: normalizeSystemFolderPaths(candidate.systemFolderPaths), + tasks: normalizeTasksSettings(candidate.tasks) } } +/** Carry the Tasks-system settings (#458) through the round-trip: a validated + * excludedFolders list, or undefined when nothing survives so vault.json + * stays free of empty stubs. */ +function normalizeTasksSettings(raw: unknown): VaultSettings['tasks'] | undefined { + if (!raw || typeof raw !== 'object') return undefined + const excluded = normalizeTasksExcludedFolders( + (raw as { excludedFolders?: unknown }).excludedFolders + ) + return excluded.length > 0 ? { excludedFolders: excluded } : undefined +} + /** Carry the per-vault view overrides (#292) through the round-trip, keeping * only known keys. The renderer validates the values strictly; here we just * preserve a clean object (or undefined when there are no overrides). */ diff --git a/apps/desktop/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts index 0767f78d..1e9ea1df 100644 --- a/apps/desktop/src/mcp/instructions.ts +++ b/apps/desktop/src/mcp/instructions.ts @@ -206,6 +206,9 @@ when the folder is \`Linear Algebra/\`), synonyms, and feeling tags - Before rename_note, run backlinks. - Task ids from list_tasks (\`path#index\`) are stable \u2014 pass them to toggle_task. +- list_tasks omits notes opted out of Tasks (frontmatter + \`tasks: false\`/\`note\`, excluded folders). Pass + includeExcluded: true only when the user asks for everything. ## Self-check before every write diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 52578d52..0e6e5574 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -641,6 +641,11 @@ const TOOLS: ToolDef[] = [ 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.' } } } @@ -652,6 +657,7 @@ const TOOLS: ToolDef[] = [ | 'waiting' | 'all' | undefined) ?? 'open' + const includeExcluded = args.includeExcluded === true const priority = optionalString(args, 'priority') as 'high' | 'med' | 'low' | undefined const dueBefore = optionalString(args, 'dueBefore') const dueAfter = optionalString(args, 'dueAfter') @@ -660,7 +666,7 @@ const TOOLS: ToolDef[] = [ const folder = args.folder ? (requireFolder(args, 'folder') as 'inbox' | 'quick' | 'archive') : null - const all = await scanAllTasks(vault) + const all = await scanAllTasks(vault, includeExcluded ? { includeExcluded: true } : undefined) return all.filter((t) => { if (folder && t.noteFolder !== folder) return false if (status === 'open' && (t.checked || t.waiting)) return false diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 07c3dc3a..8807f59b 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -13,6 +13,11 @@ import path from 'node:path' import os from 'node:os' import { parse as parseToml } from 'smol-toml' import { retitleLeadingHeading } from '@shared/note-heading-sync' +import { noteTasksMode, type NoteTasksMode } from '@shared/tasks' +import { + isPathExcludedFromTasks, + normalizeTasksExcludedFolders +} from '@shared/tasks-excluded-folders' import { isObsidianExcalidrawMarkdown, isObsidianExcalidrawPath @@ -1173,10 +1178,16 @@ function normalizeDueDate(raw: string | undefined): string | undefined { return isValidIsoDate(cleaned) ? cleaned : undefined } -function parseNoteDefaults(body: string): { due?: string; priority?: 'high' | 'med' | 'low' } { +function parseNoteDefaults(body: string): { + due?: string + priority?: 'high' | 'med' | 'low' + tasksMode: NoteTasksMode +} { const m = body.match(FRONTMATTER_RE) - if (!m) return {} - const out: { due?: string; priority?: 'high' | 'med' | 'low' } = {} + if (!m) return { tasksMode: 'all' } + const out: { due?: string; priority?: 'high' | 'med' | 'low'; tasksMode: NoteTasksMode } = { + tasksMode: 'all' + } for (const rawLine of m[1].split('\n')) { const line = rawLine.trim() if (!line || line.startsWith('#')) continue @@ -1191,17 +1202,29 @@ function parseNoteDefaults(body: string): { due?: string; priority?: 'high' | 'm else if (key === 'priority') { const p = normalizePriority(value) if (p) out.priority = p - } + } else if (key === 'tasks') out.tasksMode = noteTasksMode(value) } return out } +interface ParseTasksOptions { + /** Scan past the note-level `tasks:` opt-out (#458): the `list_tasks` + * includeExcluded / `zn task list --include-excluded` escape hatch. */ + includeExcluded?: boolean +} + function parseTasksFromBody( body: string, - ctx: { path: string; title: string; folder: NoteFolder } + ctx: { path: string; title: string; folder: NoteFolder }, + opts?: ParseTasksOptions ): VaultTask[] { const normalized = body.replace(/\r\n/g, '\n') const defaults = parseNoteDefaults(normalized) + + // Frontmatter `tasks:` opt-out (#458): 'none' and 'note-only' both silence + // inline checkboxes. Kept in sync with packages/shared-domain/src/tasks.ts; + // the value set itself comes from the shared noteTasksMode. + if (defaults.tasksMode !== 'all' && !opts?.includeExcluded) return [] const lines = normalized.split('\n') const tasks: VaultTask[] = [] @@ -1368,13 +1391,19 @@ function firstScalar(v: string | string[] | undefined): string | undefined { */ function parseTaskFile( body: string, - ctx: { path: string; title: string; folder: NoteFolder } + ctx: { path: string; title: string; folder: NoteFolder }, + opts?: ParseTasksOptions ): VaultTask | null { const normalized = body.replace(/\r\n/g, '\n') const m = normalized.match(FRONTMATTER_RE) if (!m) return null const fm = parseTaskFrontmatter(m[1]) + // `tasks: false` wins over `tags: [task]`; `tasks: note` deliberately falls + // through, keeping the file task while parseTasksFromBody drops the + // checkboxes. (#458) + if (noteTasksMode(fm.tasks) === 'none' && !opts?.includeExcluded) return null + const tags = asArray(fm.tags).map((t) => t.replace(/^#/, '').toLowerCase()) if (!tags.includes(TASK_FILE_TAG)) return null @@ -1441,8 +1470,32 @@ function todayIsoLocal(): string { return `${y}-${mo}-${day}` } -export async function scanAllTasks(root: string): Promise { - const metas = (await listNotes(root)).filter((m) => m.folder !== 'trash') +/** The vault's `tasks.excludedFolders` list (#458), read straight off + * vault.json like readSystemFolderPaths above; validation comes from the + * shared normalizer, so the rules cannot drift from the other runtimes. */ +async function readTasksExcludedFolders(root: string): Promise { + const settingsPath = path.join(root, INTERNAL_VAULT_DIR, VAULT_SETTINGS_FILE) + let raw: Record + try { + raw = JSON.parse(await fs.readFile(settingsPath, 'utf8')) as Record + } catch { + return [] + } + const tasks = raw['tasks'] + if (!tasks || typeof tasks !== 'object') return [] + return normalizeTasksExcludedFolders( + (tasks as { excludedFolders?: unknown }).excludedFolders + ) +} + +export async function scanAllTasks( + root: string, + opts?: ParseTasksOptions +): Promise { + const excluded = opts?.includeExcluded ? [] : await readTasksExcludedFolders(root) + const metas = (await listNotes(root)).filter( + (m) => m.folder !== 'trash' && !isPathExcludedFromTasks(m.path, excluded) + ) const out: VaultTask[] = [] await Promise.all( metas.map(async (meta) => { @@ -1458,8 +1511,8 @@ export async function scanAllTasks(root: string): Promise { title: meta.title, folder: meta.folder } - const fileTask = parseTaskFile(body, ctx) - const inline = parseTasksFromBody(body, ctx) + const fileTask = parseTaskFile(body, ctx, opts) + const inline = parseTasksFromBody(body, ctx, opts) // File task first, then any inline `- [ ]` checkboxes acting as subtasks. if (fileTask) out.push(fileTask, ...inline) else out.push(...inline) @@ -1486,11 +1539,13 @@ export async function toggleTask(root: string, taskId: string): Promise 512 { + return "" + } + return joined +} + +// normalizeTasksExcludedFolders drops invalid entries and duplicates, +// preserving order. +func normalizeTasksExcludedFolders(values []string) []string { + out := []string{} + seen := map[string]struct{}{} + for _, entry := range values { + cleaned := normalizeTasksExcludedFolder(entry) + if cleaned == "" { + continue + } + if _, dup := seen[cleaned]; dup { + continue + } + seen[cleaned] = struct{}{} + out = append(out, cleaned) + } + return out +} + +// 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. +func normalizeTasksSettings(value *TasksSettings) *TasksSettings { + if value == nil { + return nil + } + excluded := normalizeTasksExcludedFolders(value.ExcludedFolders) + if len(excluded) == 0 { + return nil + } + return &TasksSettings{ExcludedFolders: excluded} +} + +// tasksExcludedFolders reads the exclusion list off already-normalized +// settings. +func tasksExcludedFolders(settings VaultSettings) []string { + if settings.Tasks == nil { + return nil + } + 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 { + if relPath == folder || strings.HasPrefix(relPath, folder+"/") { + return true + } + } + return false +} diff --git a/apps/server/internal/vault/tasks_exclude_test.go b/apps/server/internal/vault/tasks_exclude_test.go new file mode 100644 index 00000000..1d3ae4e7 --- /dev/null +++ b/apps/server/internal/vault/tasks_exclude_test.go @@ -0,0 +1,98 @@ +package vault + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +// #458: mirrors tasks-excluded-folders.test.ts in shared-domain; the rules +// must stay byte-compatible across runtimes. + +func TestNormalizeTasksExcludedFolders(t *testing.T) { + got := normalizeTasksExcludedFolders([]string{ + "inbox/Books", + "../x", + "inbox/Books/", + "/inbox//Books", + "inbox\\Books", + "archive/Old", + "./inbox", + " ", + }) + want := []string{"inbox/Books", "archive/Old"} + if !reflect.DeepEqual(got, want) { + t.Errorf("normalizeTasksExcludedFolders = %v, want %v", got, want) + } +} + +func TestIsPathExcludedFromTasks(t *testing.T) { + excluded := []string{"inbox/Books", "archive/Old Projects"} + cases := []struct { + path string + want bool + }{ + {"inbox/Books/dune.md", true}, + {"inbox/Books/scifi/blindsight.md", true}, + {"archive/Old Projects/site.md", true}, + {"inbox/Bookshelf.md", false}, + {"inbox/Books.md", false}, + {"inbox/books/dune.md", false}, // case-sensitive + {"quick/note.md", false}, + } + for _, tc := range cases { + if got := isPathExcludedFromTasks(tc.path, excluded); got != tc.want { + t.Errorf("isPathExcludedFromTasks(%q) = %v, want %v", tc.path, got, tc.want) + } + } + if isPathExcludedFromTasks("inbox/Books/dune.md", nil) { + t.Error("empty exclusion list must never match") + } +} + +// End to end through New → GetSettings → ScanTasks, so the settings cache and +// cloneSettings are on the hook too: the defensive copy silently dropped the +// Tasks object once, and only a live-server smoke test caught it. +func TestScanTasksHonorsExcludedFolders(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":{"excludedFolders":["inbox/Books"]}}`) + mustWrite("inbox/Real Work.md", "- [ ] ship it\n") + mustWrite("inbox/Books/Backlog.md", "- [ ] Excession\n- [ ] Player of Games\n") + + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + + tasks, err := v.ScanTasks() + if err != nil { + t.Fatal(err) + } + for _, tk := range tasks { + if isPathExcludedFromTasks(tk.SourcePath, []string{"inbox/Books"}) { + t.Errorf("excluded-folder task leaked into the default scan: %s", tk.ID) + } + } + if len(tasks) != 1 { + t.Fatalf("expected only the Real Work task, got %d tasks", len(tasks)) + } + + all, err := v.ScanTasksWith(ParseTasksOptions{IncludeExcluded: true}) + if err != nil { + t.Fatal(err) + } + if len(all) != 3 { + t.Fatalf("expected 3 tasks with IncludeExcluded, got %d", len(all)) + } +} diff --git a/apps/server/internal/vault/types.go b/apps/server/internal/vault/types.go index 47fa814b..298cd199 100644 --- a/apps/server/internal/vault/types.go +++ b/apps/server/internal/vault/types.go @@ -283,6 +283,17 @@ type VaultSettings struct { // Per-system-folder on-disk path overrides (#115). Maps internal folder IDs // to vault-relative directory names. Absent entries fall back to the default. SystemFolderPaths map[string]string `json:"systemFolderPaths,omitempty"` + // Tasks-system settings (#458). Mirrors shared/ipc.ts VaultSettings.tasks; + // persisted as a first-class field so a web client's settings write never + // drops a desktop-written exclusion list (the #446/#379 round-trip rule). + Tasks *TasksSettings `json:"tasks,omitempty"` +} + +// TasksSettings mirrors shared/ipc.ts VaultSettings.tasks (#458). +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"` } // NoteMeta — vault-relative note metadata. Mirrors shared/ipc.ts NoteMeta. diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go index a76aecbe..9708722b 100644 --- a/apps/server/internal/vault/vault.go +++ b/apps/server/internal/vault/vault.go @@ -334,6 +334,14 @@ func cloneSettings(settings VaultSettings) VaultSettings { systemFolderPaths[key] = value } } + // Nil stays nil here too (#458); the walker treats an absent Tasks object + // as "nothing excluded". + var tasks *TasksSettings + if settings.Tasks != nil { + excluded := make([]string, len(settings.Tasks.ExcludedFolders)) + copy(excluded, settings.Tasks.ExcludedFolders) + tasks = &TasksSettings{ExcludedFolders: excluded} + } dailyLegacyPatterns := make([]DateNotePatternSettings, len(settings.DailyNotes.LegacyPatterns)) copy(dailyLegacyPatterns, settings.DailyNotes.LegacyPatterns) weeklyLegacyPatterns := make([]DateNotePatternSettings, len(settings.WeeklyNotes.LegacyPatterns)) @@ -375,6 +383,7 @@ func cloneSettings(settings VaultSettings) VaultSettings { FolderColors: folderColors, Favorites: favorites, SystemFolderPaths: systemFolderPaths, + Tasks: tasks, } } @@ -575,6 +584,7 @@ func normalizeVaultSettings(value VaultSettings, fallbackPrimary PrimaryNotesLoc FolderColors: folderColors, Favorites: normalizeFavorites(value.Favorites), SystemFolderPaths: normalizeSystemFolderPaths(value.SystemFolderPaths), + Tasks: normalizeTasksSettings(value.Tasks), } } @@ -2174,12 +2184,23 @@ func (v *Vault) DuplicateFolder(folder NoteFolder, subpath string) (string, erro // --- Tasks --- func (v *Vault) ScanTasks() ([]Task, error) { + return v.ScanTasksWith(ParseTasksOptions{}) +} + +// ScanTasksWith is ScanTasks honoring options: IncludeExcluded scans past +// both the vault-level excluded-folders list and the note-level frontmatter +// `tasks:` opt-out (#458). +func (v *Vault) ScanTasksWith(opts ParseTasksOptions) ([]Task, error) { v.mu.RLock() defer v.mu.RUnlock() settings, err := v.GetSettings() if err != nil { return nil, err } + excluded := tasksExcludedFolders(settings) + if opts.IncludeExcluded { + excluded = nil + } hiddenRootNames := hiddenPrimaryRootNames(settings) all := []Task{} for _, folder := range []NoteFolder{FolderInbox, FolderQuick, FolderArchive} { @@ -2227,8 +2248,11 @@ func (v *Vault) ScanTasks() ([]Task, error) { } rel, _ := filepath.Rel(v.root, path) relPosix := filepath.ToSlash(rel) + if isPathExcludedFromTasks(relPosix, excluded) { + return nil + } title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - tasks := ParseTasks(relPosix, title, folder, string(body)) + tasks := ParseTasksWith(relPosix, title, folder, string(body), opts) all = append(all, tasks...) return nil }) @@ -2237,6 +2261,14 @@ func (v *Vault) ScanTasks() ([]Task, error) { } func (v *Vault) ScanTasksForPath(rel string) ([]Task, error) { + return v.ScanTasksForPathWith(rel, ParseTasksOptions{}) +} + +// ScanTasksForPathWith is ScanTasksForPath honoring options. IncludeExcluded +// scans past the excluded-folders list and the frontmatter `tasks:` opt-out +// (never the trash gate): the remote task-toggle flow re-parses through here, +// and an explicitly-named task id is an explicit ask. +func (v *Vault) ScanTasksForPathWith(rel string, opts ParseTasksOptions) ([]Task, error) { v.mu.RLock() defer v.mu.RUnlock() abs, err := SafeJoin(v.root, rel) @@ -2247,9 +2279,27 @@ func (v *Vault) ScanTasksForPath(rel string) ([]Task, error) { if err != nil { return nil, err } - folder, _ := v.folderOf(abs) + // 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. + folder, ok := v.folderOf(abs) + if !ok || folder == FolderTrash { + return []Task{}, nil + } + relPosix := filepath.ToSlash(rel) + if !opts.IncludeExcluded { + settings, err := v.GetSettings() + if err != nil { + return nil, err + } + if isPathExcludedFromTasks(relPosix, tasksExcludedFolders(settings)) { + return []Task{}, nil + } + } title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - return ParseTasks(filepath.ToSlash(rel), title, folder, string(body)), nil + return ParseTasksWith(relPosix, title, folder, string(body), opts), nil } // --- Text search --- diff --git a/apps/server/package.json b/apps/server/package.json index 7f17d19a..139cca6e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.22.1", + "version": "2.23.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index 44b4c854..b609df37 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.22.1", + "version": "2.23.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/docs/reference/tasks-reference.md b/docs/reference/tasks-reference.md index 4b9e0fcf..c5fec5b3 100644 --- a/docs/reference/tasks-reference.md +++ b/docs/reference/tasks-reference.md @@ -57,6 +57,20 @@ priority: high Inline task metadata wins over frontmatter defaults. +## Checklists: opting out of Tasks + +Not every checkbox is a task. Two vault-portable mechanisms turn checkboxes back into plain checklists (#458): a per-note frontmatter key (two opt-out values) and a per-folder exclusion list. + +| Switch | Effect | +| --- | --- | +| Frontmatter `tasks: false` (or `off`) | The note feeds nothing to Tasks: no inline tasks, and no file task even when the note is tagged `task`. | +| Frontmatter `tasks: note` | The note's own file task stays visible, but its inline checkboxes are not tasks. For project notes that belong on the board without their checklists. | +| Excluded folders | `tasks.excludedFolders` in the vault settings lists folders whose notes are never scanned. Managed from the sidebar folder context menu ("Exclude from Tasks") or Settings → Tasks. | + +Checkboxes in excluded notes render and toggle in the editor exactly as before. Any other `tasks:` value (or none) keeps the default: every checkbox is a task. + +Exclusions apply on every surface: the Tasks views, calendars, the home widget, `zn task list`, the MCP `list_tasks` tool, and the self-hosted server's API. To list everything anyway: `zn task list --include-excluded`, `list_tasks` with `includeExcluded: true`, or `GET /api/tasks?includeExcluded=1`. + ## Tasks views The Tasks tab has three modes: diff --git a/package-lock.json b/package-lock.json index dfc9abd9..b191c323 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.22.1", + "version": "2.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.22.1", + "version": "2.23.0", "workspaces": [ "apps/*", "packages/*" @@ -20,7 +20,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.22.1", + "version": "2.23.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -99,11 +99,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.22.1" + "version": "2.23.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.22.1", + "version": "2.23.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -17123,7 +17123,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.22.1", + "version": "2.23.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -17187,18 +17187,18 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.22.1" + "version": "2.23.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.22.1", + "version": "2.23.0", "dependencies": { "lz-string": "^1.5.0" } }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.22.1" + "version": "2.23.0" } } } diff --git a/package.json b/package.json index e2f00b4f..42e629ee 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.22.1", + "version": "2.23.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 340f41f0..2e157073 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.22.1", + "version": "2.23.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index e45b77fd..3e6013b8 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -36,6 +36,7 @@ import { type McpInstructionsPayload, type McpServerRuntime, } from "@shared/mcp-clients"; +import { normalizeTasksExcludedFolder } from "@shared/tasks-excluded-folders"; import { useStore, refreshCustomThemes, refreshOverrides } from "../store"; import { WORKFLOW_PRESETS, hiddenPresetsInOrder } from "@shared/workflows/presets"; import { startWorkflowTutorial } from "../lib/workflow-tutorial-flow"; @@ -2679,6 +2680,22 @@ export function SettingsModal(): JSX.Element { "old", ], }, + { + id: "tasks-excluded-folders", + title: "Folders excluded from Tasks", + description: + "Keep checkbox-heavy folders (reading lists, media backlogs) out of the Tasks list, boards, and calendars. Stored in the vault, honored by every runtime.", + keywords: [ + "exclude", + "excluded", + "checklist", + "reading list", + "backlog", + "folder", + "hide", + "checkbox", + ], + }, ], content: (
@@ -2700,6 +2717,12 @@ export function SettingsModal(): JSX.Element { onChange={setShowArchivedTasks} /> +
+ +
+
+ ))} + +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addDraft(); + } + }} + placeholder="Folder path, e.g. inbox/Books" + aria-label="Folder path to exclude from Tasks" + className="min-w-0 flex-1 rounded-md border border-paper-300 bg-paper-100 px-2.5 py-1.5 text-sm text-ink-900 outline-none focus:border-accent/60" + /> + +
+ + ); +} + function SegmentedRow({ label, description, diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index f4d41b46..4875610b 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -441,6 +441,7 @@ export function Sidebar(): JSX.Element { const newDrawing = useStore((s) => s.newDrawing); const newDatabase = useStore((s) => s.newDatabase); const toggleFavorite = useStore((s) => s.toggleFavorite); + const toggleTasksExcludedFolder = useStore((s) => s.toggleTasksExcludedFolder); const createDatabase = useStore((s) => s.createDatabase); const createNoteInChosenFolder = useStore((s) => s.createNoteInChosenFolder); const openTemplatePaletteForFolder = useStore((s) => s.openTemplatePaletteForFolder); @@ -1962,6 +1963,27 @@ export function Sidebar(): JSX.Element { }, }); + // Vault-level Tasks exclusion (#458). The list stores on-disk relative + // paths, so a root-primary inbox's top level resolves to "": no valid + // entry to toggle, hide the item there. + const tasksExcludeRelDir = vaultRelativeFolderPath( + folder, + subpath, + vaultSettings, + ); + if (tasksExcludeRelDir) { + const tasksExcluded = ( + vaultSettings.tasks?.excludedFolders ?? [] + ).includes(tasksExcludeRelDir); + items.push({ kind: "separator" }); + items.push({ + label: tasksExcluded ? "Include in Tasks" : "Exclude from Tasks", + onSelect: async () => { + await toggleTasksExcludedFolder(tasksExcludeRelDir); + }, + }); + } + if (!isTop) { items.push({ kind: "separator" }); const leafName = subpath.split("/").slice(-1)[0]; @@ -2046,6 +2068,7 @@ export function Sidebar(): JSX.Element { openIconPicker, openColorPicker, toggleFavorite, + toggleTasksExcludedFolder, bulkSelectionMenuItems, selectedSidebarKeys, ]); diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index f14fb3c2..1d4e4b05 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -252,13 +252,18 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Tasks, tags, archive, and trash are vault-wide views', body: - 'Tasks scans every note for checkboxes, Tags lets you browse notes that carry all of the selected tags (toggle Match to Any for a union), Archive gives you a dedicated list of cold-storage notes, and Trash gives you a recovery surface for deleted notes without turning the left rail into a second browser. Selected tags accumulate so you can narrow across several at once; clear them with the Selected strip’s “Clear all”, the `c` key, or a right-click on any tag chip (which also offers “Unselect others” to keep just that one).' + 'Tasks scans every note for checkboxes, Tags lets you browse notes that carry all of the selected tags (toggle Match to Any for a union), Archive gives you a dedicated list of cold-storage notes, and Trash gives you a recovery surface for deleted notes without turning the left rail into a second browser. Selected tags accumulate so you can narrow across several at once; clear them with the Selected strip’s “Clear all”, the `c` key, or a right-click on any tag chip (which also offers “Unselect others” to keep just that one). A tag chip’s context menu also carries “Rename Tag…” and “Delete Tag…”, which rewrite or remove that hashtag across the whole vault. Tags work in any script (`#тест` and `#标签` are tags), while headings (`# x`) and hex colors (`#1971c2`) are not.' }, { title: 'Archiving a note retires its tasks', body: 'When a note moves to the Archive, its tasks leave the Tasks list, the Kanban boards, and the calendars with it, keeping Done focused on recent work instead of years of finished projects. Nothing is written: the markdown keeps its checkboxes, and un-archiving the note brings its tasks straight back. Archiving a note that still has open tasks asks first, so live work never disappears silently, and a bulk archive from the sidebar asks once for the whole set. Prefer the old behavior? Settings → Tasks → “Show tasks from archived notes” (or `show_archived_tasks` in `config.toml`) keeps archived tasks on every surface, including the Archive column on the folder Kanban board, which otherwise steps aside.' }, + { + title: 'Not every checkbox is a task', + body: + 'A reading list is a checklist, not a to-do. Add `tasks: false` (or `off`) to a note’s frontmatter and its checkboxes stay checkboxes: they render and toggle exactly as before, but stop feeding the Tasks list, the boards, the calendars, `zn task list`, and the MCP tools. On a #task note, `tasks: note` keeps the note itself on the board while silencing its internal checklist, so a project note can be one card instead of twenty. To retire a whole folder (a media backlog, a reference library), right-click it in the sidebar and choose “Exclude from Tasks”, or manage the list under Settings → Tasks; the exclusion is stored in the vault’s own settings, so desktop, web, mobile, and the CLI all agree. When you do want to see past it, `zn task list --include-excluded` and the MCP `list_tasks` tool’s `includeExcluded` flag list everything.' + }, { title: 'Any line becomes a checkbox with ⌘L', body: @@ -277,7 +282,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'The Tasks Kanban board, custom statuses, and any field', body: - 'Switch Tasks to Kanban (button or `3`) for a column board. "Group by" offers Status (Today / Upcoming / Waiting / Done, derived from due dates and `@waiting`), Priority, Folder, Custom status, and one entry per inline `@field` you use. Any task field works: tag tasks with `@key:value` tokens like `@status:review`, `@sprint:24`, or `@area:backend`, and each key becomes its own board with a column per value (auto-discovered, so it appears the moment you use it). For the status field, list the columns in order in `config.toml` under `[view]`, e.g. `kanban_statuses = ["backlog", "in_progress", "review", "done"]`; other fields sort their columns automatically. A note-level `status:` in frontmatter sets a default for that note’s tasks. Everything is keyboard-first: `h`/`l` move between columns, `j`/`k` between cards, `g` cycles the group-by, `Shift+H` / `Shift+L` send the focused card to the previous/next column (rewriting its `@field` token), `<` / `>` reorder the columns themselves (saved per board), and `Space`/`Enter` toggle/open. Drag does the same with the mouse — including dragging a column header to reorder, and dragging a card to a new spot inside its column to hand-prioritize it (that arrangement is saved per column and restored when you come back to the board). Renaming a column (click its title, or `[kanban_column_titles]` in `config.toml`) sets a display label only: the column still shows its underlying `@field:value` beneath the name, and moving a card in writes that value, not the label.' + 'Switch Tasks to Kanban (button or `3`) for a column board. "Group by" offers Status (Today / Upcoming / Waiting / Done, derived from due dates and `@waiting`), Priority, Folder, Custom status, and one entry per inline `@field` you use. Any task field works: tag tasks with `@key:value` tokens like `@status:review`, `@sprint:24`, or `@area:backend`, and each key becomes its own board with a column per value (auto-discovered, so it appears the moment you use it). For the status field, define the columns under Settings → Tasks → Kanban statuses, or list them in order in `config.toml` under `[view]`, e.g. `kanban_statuses = ["backlog", "in_progress", "review", "done"]`; other fields sort their columns automatically. A note-level `status:` in frontmatter sets a default for that note’s tasks. Everything is keyboard-first: `h`/`l` move between columns, `j`/`k` between cards, `g` cycles the group-by, `Shift+H` / `Shift+L` send the focused card to the previous/next column (rewriting its `@field` token), `<` / `>` reorder the columns themselves (saved per board), and `Space`/`Enter` toggle/open. Drag does the same with the mouse — including dragging a column header to reorder, and dragging a card to a new spot inside its column to hand-prioritize it (that arrangement is saved per column and restored when you come back to the board). Renaming a column (click its title, or `[kanban_column_titles]` in `config.toml`) sets a display label only: the column still shows its underlying `@field:value` beneath the name, and moving a card in writes that value, not the label.' }, { title: 'Forward a task to another note', @@ -312,7 +317,17 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Moving notes is path-first', body: - 'Use the note context menu, search `move` or `mv` in the command palette, or run `:move` / `:mv` from the ex line to move the active note into Inbox or Archive. With no argument, the command opens the folder picker; with a target like `:mv archive/Reference` or `:move inbox/Work`, it moves the note directly. The move prompt autocompletes folder paths, so you can type and Tab through existing destinations instead of dragging.' + 'Use the note context menu, search `move` or `mv` in the command palette, or run `:move` / `:mv` from the ex line to move the active note into Inbox or Archive. With no argument, the command opens the folder picker; with a target like `:mv archive/Reference` or `:move inbox/Work`, it moves the note directly. The move prompt autocompletes folder paths, so you can type and Tab through existing destinations instead of dragging. “Duplicate” (palette or context menu) copies a note in place, appending “ (copy)” to the name.' + }, + { + title: 'Renaming a note fixes its links', + body: + 'Rename a note and every `[[wikilink]]` pointing at it across the vault rewrites itself to the new name, so no link goes dead. Every form is handled (`[[Note]]`, `[[Note|alias]]` keeping your display text, `[[Note#heading]]`, `[[Note^block]]`, embeds `![[Note]]`, and path-style `[[folder/Note]]`), anything inside fenced or inline code is skipped, and only links that actually resolved to that note are touched, so notes sharing a title never get cross-wired. It runs in the vault layer, so the editor, the CLI, the MCP tools, and the HTTP API all get it, on desktop and the self-hosted server alike.' + }, + { + title: 'The note list sorts, groups, and reveals', + body: + 'The note list header has a sort menu (Name, Updated, Created, or a Manual order; also “Sort Notes: …” in the palette), a “Group by Kind” toggle that separates folders from notes, and “Auto-Reveal Active Note”, which expands a note’s ancestor folders and scrolls it into view in the sidebar whenever you open it.' }, { title: 'Command palette mirrors the important tab actions', @@ -402,12 +417,12 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Footer actions expose utility views', body: - 'The sidebar footer gives you direct access to Files, Help, and Settings, so utility screens stay discoverable even when you are new to the app.' + 'The sidebar footer gives you direct access to Files, Help, and Settings, so utility screens stay discoverable even when you are new to the app. A narrow sidebar degrades the row in steps instead of clipping: first the file count folds into the Files tooltip, then the labels drop and the three actions stand as icons.' }, { title: 'Destructive actions ask first', body: - 'Moving a note to Trash now asks for confirmation before anything is deleted from the active workspace, and the Trash view separates restore from permanent delete.' + 'Moving a note to Trash now asks for confirmation before anything is deleted from the active workspace, and the Trash view separates restore from permanent delete. “Empty Trash” clears the whole bin in one confirmed step, and assets deleted from the Files view land in Trash too, restorable to their original location.' }, { title: 'Updates are release-driven', @@ -417,7 +432,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Workflows plan first and write second', body: - 'A workflow is a plain `.md` file under `.zennotes/workflows/`: frontmatter plus one pipeline per line, like `good = books | where rating >= 4`. Wires carry sets of notes, so every wire on the canvas shows the live count flowing through it, and the canvas and the text are lossless projections of the same file (layout is computed, so no coordinates ever land in your vault). The engine can only propose changes: running shows the full dry-run diff before anything is applied, applying journals every file\'s pre-run bytes so Undo restores them exactly, and a run that fails midway rolls the whole thing back on its own. There are no code steps, no shell, and no network, which is why a workflow you did not write is safe to read and run. The feature is off by default; enable it under Settings → Workflows.' + 'A workflow is a plain `.md` file under `.zennotes/workflows/`: frontmatter plus one pipeline per line, like `good = books | where rating >= 4`. Wires carry sets of notes, so every wire on the canvas shows the live count flowing through it, and the canvas and the text are lossless projections of the same file (layout is computed, so no coordinates ever land in your vault). The engine can only propose changes: running shows the full dry-run diff before anything is applied, applying journals every file\'s pre-run bytes so Undo restores them exactly, and a run that fails midway rolls the whole thing back on its own. There are no code steps, no shell, and no network, which is why a workflow you did not write is safe to read and run. In this release workflows run when you run them: an event or schedule `trigger:` in the frontmatter parses but does not fire yet, running is desktop-only, and the web client shows workflows read-only. The feature is off by default; enable it under Settings → Workflows.' }, { title: 'The workflow grammar in one card', @@ -434,6 +449,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ items: [ { keys: 'Mod+P', action: 'Search notes', detail: 'Open the note search palette.' }, { keys: 'Mod+F', action: 'Search notes (non-Vim mode)', detail: 'Open the note search palette directly when Vim mode is off.' }, + { keys: 'Mod+F (in the editor)', action: 'Find and replace in the note', detail: 'In Edit and Split, open the editor’s find-and-replace bar: Tab moves between the Find and Replace fields, with match-case, whole-word, and regex toggles. Esc closes it.' }, { keys: 'Shift+Mod+P', action: 'Open commands', detail: 'Open the command palette.' }, { keys: 'Shift+Mod+N', action: 'New Quick Note', detail: 'Create a quick capture note in the main window and focus its title.' }, { keys: 'Shift+Mod+Space', action: 'Open quick capture window', detail: 'Open the floating, always-on-top capture window. Bound system-wide (CommandOrControl+Shift+Space by default) so it works over any app; change it under Settings → Editor.' }, @@ -448,6 +464,9 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Mod+W', action: 'Close active tab', detail: 'Close the current note or virtual tab.' }, { keys: 'Ctrl+Tab', action: 'Switch to previous note', detail: 'Switch to the most recently used note. Press again to alternate between the last two notes.' }, { keys: 'Shift+Mod+T', action: 'Reopen closed tab', detail: 'Reopen the most recently closed tab, restoring its position and pinned state. Repeat to walk back through your close history.' }, + { keys: 'Mod+O', action: 'Open file', detail: 'Desktop only: pick a Markdown file with the native dialog. A file inside a known vault opens against that vault; anything else opens in a standalone external-file window.' }, + { keys: 'Mod+4 / Mod+5 / Mod+6', action: 'Edit / Split / Preview mode', detail: 'Switch the active note between the raw editor, side-by-side split, and rendered preview.' }, + { keys: 'Mod+L', action: 'Toggle checkbox', detail: 'Turn the current line into a checkbox and toggle it on repeat. See the “Any line becomes a checkbox” card in Core concepts for the full state rules.' }, { keys: 'Shift+Mod+E', action: 'Export note as PDF', detail: 'Export the active note as a PDF file.' }, { keys: 'Mod+=', action: 'Zoom in', detail: 'Scale the whole app up, including chrome, editor, and preview.' }, { keys: 'Mod+-', action: 'Zoom out', detail: 'Scale the whole app down when the UI feels too large.' }, @@ -484,6 +503,10 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space p', action: 'Note outline', detail: 'Jump to any heading in the active note via a searchable overlay.' }, { keys: 'Space v', action: 'Switch vault', detail: 'Open the command palette directly to the local vault switcher.' }, { keys: 'Space a', action: 'Open workflows', detail: 'Open the Workflows view, where saved pipelines over your notes are built and run. Workflows are off by default; turn them on under Settings → Workflows first.' }, + { keys: 'Space q', action: 'Quick capture window', detail: 'Open the floating, always-on-top capture window, same as the global hotkey.' }, + { keys: 'Space i', action: 'Insert template into note', detail: 'Pick a template and insert it at the cursor of the active note, instead of creating a new note from it.' }, + { keys: 'Space c', action: 'Toggle calendar', detail: 'Show or hide the calendar panel for the active pane.' }, + { keys: 'Space l s', action: 'Toggle favorite', detail: 'Add or remove the active note from the sidebar’s Favorites section. Folders join it from their context menu.' }, { keys: 'Space, then pause', action: 'Show leader hints', detail: 'If enabled in Settings, open a which-key style guide for the next available leader actions. Sticky mode keeps it open until `Space` or `Esc`.' }, { keys: 'Mod+3', action: 'Toggle outline panel', detail: 'Show or hide the persistent outline in the active pane. Once focused (Ctrl+W l or Alt+L from the editor), j / k — or the arrows — walk the headings, gg / G jump to the first and last, Enter jumps the editor to the heading under the cursor, and Esc hands focus back.' }, { keys: 'zc / zo', action: 'Fold / unfold heading', detail: 'Collapse or expand the section below the heading at the cursor.' }, @@ -491,7 +514,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'zM / zR', action: 'Fold / unfold all', detail: 'Collapse or expand every heading section in the note.' }, { keys: 'Ctrl-o', action: 'Go back', detail: 'Jump to the previous note location in history.' }, { keys: 'Ctrl-i', action: 'Go forward', detail: 'Jump forward in note history.' }, - { keys: 'Space h', action: 'Hint mode', detail: 'Show jump labels over clickable targets — links, buttons, sidebar rows, tabs — so you can activate any of them from the keyboard. Works outside insert mode, including in the Tasks and Tags views.' } + { keys: 'Space h', action: 'Hint mode', detail: 'Show jump labels over clickable targets — links, buttons, sidebar rows, tabs — so you can activate any of them from the keyboard. Works outside insert mode, including in the Tasks and Tags views. Home-row-mod keyboards work too: a bare modifier tap or an uppercase label no longer cancels the hints.' } ] }, { @@ -587,6 +610,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: '>', action: 'Forward task', detail: 'Tasks list only: forward the selected task to another note. Opens a note picker; the original becomes a forwarded record (`[>]`) linking to the target, and a fresh copy is added there, backlinked home. Forwarded tasks live under a “Forwarded” group.' }, { keys: 'i', action: 'Mark task in progress', detail: 'Tasks list only: mark the selected task as started (`- [/]`), or set it back to open. In-progress tasks stay in Today and on the calendar, so the row keeps its place.' }, { keys: 'c', action: 'Cancel task', detail: 'Tasks list only: mark the selected task as intentionally abandoned (`- [-]`), or un-cancel it. Cancelled tasks live under a “Cancelled” group, out of Today and Done.' }, + { keys: 'K / J', action: 'Move task up / down', detail: 'Tasks list only: reorder the selected task within its group. Works with Vim mode on or off.' }, { keys: 'r', action: 'Restore trashed note', detail: 'Trash view only: restore the selected trashed note.' }, { keys: 'x / d', action: 'Delete forever', detail: 'Trash view only: permanently delete the selected trashed note after confirmation.' }, { keys: '/', action: 'Filter the view', detail: 'Focus the local filter box for tasks, tag matches, or trashed notes.' }, @@ -624,9 +648,13 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'h / j / k / l', action: 'Move the cell cursor', detail: 'Arrow keys also work. 0 / ^ jump to the first column, $ to the last.' }, { keys: 'H / L', action: 'Move the current column left / right', detail: 'Reorders columns from the keyboard and the cursor follows. You can also drag a column header, or use “Move left / Move right” in the field menu (⋯).' }, { keys: 'g g / G', action: 'Jump to first / last row', detail: 'Fast travel within the current column.' }, + { keys: 'k (into header)', action: 'Rename a field', detail: 'Press k up onto the column-header row, then Enter / i to rename the field, or m to open its column menu.' }, { keys: 'i / Enter', action: 'Edit the cell', detail: 'On a checkbox cell this toggles it instead of opening an editor.' }, + { keys: 'm', action: 'Open the row menu', detail: 'On a cell, open the right-click menu for that record (Open, Delete, and the rest).' }, { keys: 'Space / x', action: 'Select the row', detail: 'Toggle the row’s selection for bulk actions.' }, { keys: 'o', action: 'Open the record page', detail: 'Open the row as a Markdown note in the per-database folder.' }, + { keys: 'Ctrl-o', action: 'Jump back to the grid', detail: 'From a record page, return to the database grid, whether you opened it as a .csv file or via New Database.' }, + { keys: 'Ctrl-w k / j / h / l', action: 'Move to tabs or panes', detail: 'Move between the grid, the tab strip, and split panes, exactly like from the editor.' }, { keys: 'a', action: 'Add a row', detail: 'Append a new empty record and move the cursor to it.' }, { keys: 'd d', action: 'Delete the row', detail: 'Remove the record at the cursor.' }, { keys: 'Esc', action: 'Clear selection / leave the grid', detail: 'Clears a multi-row selection first, then blurs the grid.' } @@ -793,7 +821,7 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ { command: ' h', summary: 'Leader hint mode', - detail: 'Show jump labels over clickable targets — links, buttons, sidebar rows, tabs — to activate any of them from the keyboard. Works in the editor, sidebar, and the Tasks and Tags views.' + detail: 'Show jump labels over clickable targets — links, buttons, sidebar rows, tabs — to activate any of them from the keyboard. Works in the editor, sidebar, and the Tasks and Tags views, and survives home-row-mod keyboards: a bare modifier tap or an uppercase label no longer cancels the hints.' }, { command: ' o', @@ -825,6 +853,26 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Leader new from template', detail: 'Open the template picker to create a note from a built-in or custom template.' }, + { + command: ' i', + summary: 'Leader insert template', + detail: 'Pick a template and insert it at the cursor of the active note, instead of creating a new note from it.' + }, + { + command: ' q', + summary: 'Leader quick capture', + detail: 'Open the floating, always-on-top capture window, same as the global hotkey.' + }, + { + command: ' c', + summary: 'Leader toggle calendar', + detail: 'Show or hide the calendar panel for the active pane.' + }, + { + command: ' l s', + summary: 'Leader toggle favorite', + detail: 'Add or remove the active note from the sidebar’s Favorites section.' + }, { command: ' d', summary: "Leader today's daily note", @@ -840,6 +888,16 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: "Leader this month's note", detail: 'Open or create this month’s note (when monthly notes are enabled in Settings → Vault → Periodic notes).' }, + { + command: 'gt / gT', + summary: 'Next / previous tab', + detail: 'Move through the tabs in the active pane. Also `:tabnext` / `:tabprevious` on the ex line, and rebindable under Settings → Keymaps.' + }, + { + command: ':closepanel / :closep', + summary: 'Close the right panel', + detail: 'Dismiss whichever right-hand panel is open (connections, outline, comments, calendar). Also a “Close Right Panel” command in the palette.' + }, { command: ':outline', summary: 'Note outline palette', @@ -890,6 +948,8 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Leader key hints', detail: 'Show a which-key style guide after pressing the configured Leader key so available leader actions stay visible while you decide. This setting is only available when Vim mode is enabled.' }, { label: 'Leader hint behavior', detail: 'Choose whether leader hints auto-hide after a timeout or stay open until you dismiss them with the Leader key or Esc. These controls only appear when Vim mode is enabled.' }, { label: 'Leader hint duration', detail: 'When behavior is Timed, control how long the which-key overlay stays visible and how long the pending leader sequence remains active after pressing the Leader key. This setting is only available in Vim mode.' }, + { label: 'Scroll offset', detail: 'Vim scrolloff: keep N lines visible above and below the cursor so it never hugs the top or bottom edge. Default 0 (off). Only applies with Vim mode on.' }, + { label: 'Smooth preview scroll', detail: 'Animate Ctrl+D / Ctrl+U half-page scrolling in the preview pane.' }, { label: 'Vault text search backend and binary paths', detail: 'Choose Auto, the built-in searcher, ripgrep, or fzf for vault-wide text search. Auto prefers system tools when they are installed and falls back cleanly when they are not, you can provide explicit binary paths for ripgrep or fzf if they are not on your PATH, and Settings now shows the resolved runtime backend that will actually be used.' }, { label: 'Live preview', detail: 'Hide markdown syntax on lines you are not actively editing. Also draws math, tables, and `mermaid` diagrams in place; each turns back into its source when the cursor enters it.' }, { label: 'Render tables in live preview', detail: 'Show Markdown tables as interactive WYSIWYG widgets (edit cells, drag, right-click/`m` menu). Turn it off to keep tables as plain markdown text so you can edit them with the keyboard and Vim motions like any other line. When widgets are on, Arrow keys (and h/j/k/l) navigate cells; Shift+V then Shift+J/Shift+K move whole lines in the raw source.' }, @@ -897,7 +957,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Heading level labels', detail: 'Show H1 through H6 badges before headings. Heading fold arrows stay available whether labels are on or off.' }, { label: 'Tab size', detail: 'Choose how many spaces a tab occupies when rendered and when indenting in every Markdown editor surface.' }, { label: 'Text replacements', detail: 'Enable or disable typed expansions and manage the trigger-to-text rules under the dedicated Text replacements tab.' }, - { label: 'Note tabs', detail: 'Enable or disable tab-based editing and split-friendly note workflows.' }, + { label: 'Note tabs and wrap tabs', detail: 'Enable or disable tab-based editing and split-friendly note workflows, and wrap the tab strip onto extra rows when it overflows.' }, { label: 'Word wrap', detail: 'Wrap long lines to the editor width or let them scroll horizontally.' }, { label: 'Blinking cursor', detail: 'Blink the editor caret and the Vim block cursor, or turn it off for a solid cursor — for example to match the macOS "Prefer non-blinking cursor" accessibility setting. Applies to both the insert-mode caret and the Vim normal-mode block cursor.' }, { label: 'PDFs in edit mode', detail: 'Choose between compact PDF cards or full inline PDF embeds while writing.' }, @@ -935,7 +995,9 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Daily notes', detail: "Enable a daily-notes workflow, choose a directory pattern, naming pattern, locale, and template so each day’s note starts in the right place. Supported tokens are `yyyy`, `yy`, `M`, `MM`, `MMM`, `MMMM`, `d`, `dd`, `EEE`, `EEEE`, `w`, and `ww`; quote literal words like `'Daily Notes'/yyyy/MM-MMM`. Open today’s note with `Space d`, `:daily`, or the command palette. Two task options live here too: “Tasks are due on the note’s date” makes tasks in a daily note show on the calendar for that day (on by default), and “Roll over unfinished tasks to today” moves every unchecked task from past daily notes into today when you open it (off by default; also runnable from the command palette)." }, { label: 'Weekly notes', detail: "Enable weekly notes with a directory pattern, naming pattern, locale, and template. Weekly patterns support the same tokens as daily notes plus ISO week `w` and `ww`; the default title pattern is `yyyy-'W'ww`. Open this week’s note with `Space w`, `:weekly`, or the command palette." }, { label: 'Monthly notes', detail: 'Enable monthly notes with a directory pattern, naming pattern, locale, and template. It creates one note per calendar month, handy for monthly reviews and reflections. The default title pattern is `yyyy-MM` (e.g. `2026-07`). Open this month’s note with `Space m`, `:monthly`, or the command palette. Each notes section in Settings now collapses its fields when its toggle is off.' }, - { label: 'System folder labels', detail: 'Rename how Inbox, Quick Notes, Archive, and Trash appear in the UI without renaming the real folders on disk.' } + { label: 'Folder Paths', detail: 'Point any of the four system folders (Inbox, Quick Notes, Archive, Trash) at a directory of your choosing, labels included; stored as `systemFolderPaths` in `vault.json` so the vault stays portable. Relabeling without moving anything on disk works too.' }, + { label: 'Folder icons', detail: 'Right-click a folder in the sidebar and choose a custom icon that follows the current theme colors.' }, + { label: 'Saved Remote Workspaces', detail: 'Save multiple remote servers or vaults, reconnect from Settings or the command palette, and edit or remove them later. When connected remotely, Settings also exposes Change Remote Vault…, Return to Local Vault, and Open Local Vault….' } ] }, { @@ -944,6 +1006,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Template library', detail: 'Browse every template — built-in and custom. Built-ins cover engineering (ADR, RFC, Bug Report, Postmortem, Meeting Notes, 1:1) and personal use (Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, To-do).' }, { label: 'Create a custom template', detail: 'Author a new template as markdown with optional frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and variables like `{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`, `{{week}}`, and `{{cursor}}`. It is saved as a `.md` file in `.zennotes/templates/`.' }, { label: 'Edit or reset built-ins', detail: 'Press Edit on a built-in to fork an editable copy that shadows the original everywhere; Reset removes the copy and restores the built-in. Custom templates can be edited or deleted directly.' }, + { label: 'Remove or restore built-ins', detail: 'Hide all the shipped templates with “Remove Built-in Templates” (a button here, or the command palette; it asks first), and bring them back with “Restore Built-in Templates”. Your custom templates, and anything already pointing at a built-in by id, keep working.' }, { label: 'Where templates appear', detail: 'Use a template via the picker (`Space t` / `:template` / “New Note from Template…”), from a folder’s right-click “New from template”, or as the assigned daily/weekly note template. Custom templates require a local vault; built-ins work everywhere.' } ] }, @@ -957,6 +1020,13 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Uninstall', detail: 'Removes only the ZenNotes-managed symlink — never an arbitrary unmanaged binary named `zn`. The CLI stays inside the app bundle for next time.' } ] }, + { + title: 'Tasks', + items: [ + { label: 'Show tasks from archived notes', detail: 'Off by default: archiving a note retires its tasks from the Tasks list, calendar, and Kanban. Turn this on to keep them visible everywhere, Archive column included. The raw key is `show_archived_tasks` in `config.toml`.' }, + { label: 'Kanban statuses', detail: 'Define the ordered columns of the custom-status Kanban board here; `kanban_statuses` under `[view]` in `config.toml` is the file-level equivalent.' } + ] + }, { title: 'Workflows', items: [ diff --git a/packages/app-core/src/lib/markdown.test.ts b/packages/app-core/src/lib/markdown.test.ts index 4c3a240e..8fc449aa 100644 --- a/packages/app-core/src/lib/markdown.test.ts +++ b/packages/app-core/src/lib/markdown.test.ts @@ -332,6 +332,41 @@ describe('Typst math renderer', () => { }) }) +describe('display math inside a table cell (reading view matches the editor)', () => { + // The reported shape: a worked answer living in a table cell. Mid-line + // `$$…$$` can never be currency, so the guard must let it through, and the + // editor's table widget shows it as display math, so the reading view must + // agree. + const table = [ + '| # | Ans | Working |', + '| --- | --- | --- |', + '| 9 | B | $$\\frac{800}{10000} \\times 100\\% = 8\\%$$ |' + ].join('\n') + + it('renders $$…$$ in a cell as display math instead of literal source', () => { + setMarkdownMathRenderer('katex') + const html = renderMarkdown(table) + expect(html).toContain('katex') + expect(html).toContain('katex-display') + expect(html).not.toContain('$$\\frac') + }) + + it('renders a display placeholder under Typst', () => { + setMarkdownMathRenderer('typst') + const html = renderMarkdown(table) + expect(html).toContain('zen-typst-math') + expect(html).toContain('zen-typst-display') + setMarkdownMathRenderer('katex') + }) + + it('still leaves single-dollar currency in cells literal', () => { + setMarkdownMathRenderer('katex') + const html = renderMarkdown('| item | price |\n| --- | --- |\n| tea | $5 and $10 |') + expect(html).not.toContain('katex') + expect(html).toContain('$5 and $10') + }) +}) + describe('non-GFM task states in the reading view (#512)', () => { it('renders [/], [-] and [>] as markers instead of literal text', () => { const html = renderMarkdown( diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index ed1fbec9..9dba2ed2 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -792,6 +792,14 @@ function remarkSourceLines() { */ const STRICT_INLINE_MATH_RE = /^\$(?!\s)(?:\\.|[^$\\])*(? } + if ( + (parent as { type?: string }).type === 'tableCell' && + CELL_DISPLAY_MATH_RE.test(token) && + String(mathNode.value ?? '').trim() !== '' + ) { + const data = (mathNode.data ??= {}) + data.zenDisplayMath = true + const hProperties = ((data.hProperties ??= {}) as Record) + const classes = Array.isArray(hProperties.className) + ? (hProperties.className as string[]).filter((c) => c !== 'math-inline') + : [] + hProperties.className = [...classes, 'math-display'] + return + } ;(parent as unknown as AnyParent).children.splice(index, 1, { type: 'text', value: token }) return [SKIP, index + 1] }) @@ -852,7 +882,9 @@ function remarkTypstMathPlaceholders() { return (tree: MdRoot): void => { visit(tree, ['math', 'inlineMath'], (node) => { const mathNode = node as AnyNode & { value?: string; data?: Record } - const display = mathNode.type === 'math' + // zenDisplayMath: a `$$…$$` living inside a table cell, flagged by the + // currency guard; inline position, display rendering. + const display = mathNode.type === 'math' || mathNode.data?.zenDisplayMath === true const value = String(mathNode.value ?? '') const data = (mathNode.data ??= {}) data.hName = display ? 'div' : 'span' diff --git a/packages/app-core/src/lib/vault-layout.ts b/packages/app-core/src/lib/vault-layout.ts index fd85b12e..46e55b0e 100644 --- a/packages/app-core/src/lib/vault-layout.ts +++ b/packages/app-core/src/lib/vault-layout.ts @@ -23,6 +23,7 @@ import { resolveFolderPath, systemFolderForDirName } from '@shared/system-folder-paths' +import { normalizeTasksExcludedFolders } from '@shared/tasks-excluded-folders' import { getISOWeek, getISOWeekYear, mondayOfISOWeek } from './template-render' // Reserved however the system folders are remapped: @@ -593,6 +594,9 @@ export function normalizeVaultSettings( normalizedFavorites.push(entry) } } + const normalizedTasksExcluded = normalizeTasksExcludedFolders( + settings?.tasks?.excludedFolders + ) const primaryNotesLocation = settings?.primaryNotesLocation === 'root' ? 'root' @@ -658,7 +662,10 @@ export function normalizeVaultSettings( systemFolderPaths: normalizeSystemFolderPaths(settings?.systemFolderPaths), // Per-vault view overrides (#292): passed through as-is; the store validates // each value when it overlays them onto the live prefs. - ...(settings?.view ? { view: settings.view } : {}) + ...(settings?.view ? { view: settings.view } : {}), + ...(normalizedTasksExcluded.length > 0 + ? { tasks: { excludedFolders: normalizedTasksExcluded } } + : {}) } } diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index df50045e..3c01b03a 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand' import type { EditorView } from '@codemirror/view' import { DEFAULT_VAULT_SETTINGS } from '@shared/ipc' import { resolveFolderPath } from '@shared/system-folder-paths' +import { normalizeTasksExcludedFolder } from '@shared/tasks-excluded-folders' import type { AssetMeta, DateNotePatternSettings, @@ -2884,6 +2885,13 @@ interface Store { toggleFavoriteActiveNote: () => Promise /** @internal Replace the favorites list and persist (no note refresh). */ applyFavorites: (nextFavorites: string[]) => Promise + /** + * Toggle a folder on the vault's `tasks.excludedFolders` list (#458) and + * rescan, so its checkboxes leave (or rejoin) every Tasks surface at once. + * `relDir` is the folder's vault-relative on-disk path + * (`vaultRelativeFolderPath` output, e.g. `inbox/Books`). + */ + toggleTasksExcludedFolder: (relDir: string) => Promise setNotes: (notes: NoteMeta[]) => void setView: (view: View) => void /** Open the Tasks panel as a tab in the active pane. If the tab is @@ -4484,6 +4492,22 @@ export const useStore = create((set, get) => { if (!key) return await get().applyFavorites(toggleFavoriteKey(get().vaultSettings.favorites, key)) }, + toggleTasksExcludedFolder: async (relDir) => { + const cleaned = normalizeTasksExcludedFolder(relDir) + if (!cleaned) return + const settings = get().vaultSettings + const current = settings.tasks?.excludedFolders ?? [] + const next = current.includes(cleaned) + ? current.filter((f) => f !== cleaned) + : [...current, cleaned] + await get().setVaultSettings({ + ...settings, + tasks: next.length > 0 ? { excludedFolders: next } : undefined + }) + // Rescan immediately: the Tasks view, boards, and calendars should reflect + // the exclusion without waiting for the next natural refresh. + await get().refreshTasks() + }, toggleFavoriteActiveNote: async () => { const path = get().activeNote?.path ?? get().selectedPath if (!path) return diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 060b0feb..e71a625e 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.22.1", + "version": "2.23.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index d160baef..c2e7496e 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -438,6 +438,14 @@ export interface VaultSettings { * internal ID (`inbox`). */ systemFolderPaths?: Partial> + /** + * Tasks-system settings (#458). `excludedFolders` lists vault-relative + * directory paths (as they exist on disk) whose notes never feed the Tasks + * surfaces, on any runtime. Absent means nothing is excluded. An object + * rather than a bare list so the deferred per-vault `mode: all | tagged` + * can land beside it without another migration. + */ + tasks?: { excludedFolders?: string[] } } export const DEFAULT_DAILY_NOTES_DIRECTORY = 'Daily Notes' diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index fa0cf6a1..8e5c037c 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.22.1", + "version": "2.23.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/tasks-excluded-folders.test.ts b/packages/shared-domain/src/tasks-excluded-folders.test.ts new file mode 100644 index 00000000..609d0cc4 --- /dev/null +++ b/packages/shared-domain/src/tasks-excluded-folders.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { + isPathExcludedFromTasks, + normalizeTasksExcludedFolder, + normalizeTasksExcludedFolders +} from './tasks-excluded-folders' + +describe('normalizeTasksExcludedFolder (#458)', () => { + it('cleans slashes and whitespace', () => { + expect(normalizeTasksExcludedFolder('inbox/Books')).toBe('inbox/Books') + expect(normalizeTasksExcludedFolder('/inbox/Books/')).toBe('inbox/Books') + expect(normalizeTasksExcludedFolder('inbox//Books')).toBe('inbox/Books') + expect(normalizeTasksExcludedFolder('inbox\\Books')).toBe('inbox/Books') + expect(normalizeTasksExcludedFolder(' inbox / Books ')).toBe('inbox/Books') + }) + + it('rejects traversal, dot segments, empties, and non-strings', () => { + expect(normalizeTasksExcludedFolder('../outside')).toBeNull() + expect(normalizeTasksExcludedFolder('inbox/../trash')).toBeNull() + expect(normalizeTasksExcludedFolder('./inbox')).toBeNull() + expect(normalizeTasksExcludedFolder('')).toBeNull() + expect(normalizeTasksExcludedFolder(' ')).toBeNull() + expect(normalizeTasksExcludedFolder(42)).toBeNull() + expect(normalizeTasksExcludedFolder(null)).toBeNull() + }) +}) + +describe('normalizeTasksExcludedFolders (#458)', () => { + it('drops invalid entries and duplicates, preserving order', () => { + expect( + normalizeTasksExcludedFolders(['inbox/Books', '../x', 'inbox/Books/', 'archive/Old']) + ).toEqual(['inbox/Books', 'archive/Old']) + }) + + it('yields [] for malformed vault.json values', () => { + expect(normalizeTasksExcludedFolders(undefined)).toEqual([]) + expect(normalizeTasksExcludedFolders('inbox/Books')).toEqual([]) + expect(normalizeTasksExcludedFolders({ excludedFolders: [] })).toEqual([]) + }) +}) + +describe('isPathExcludedFromTasks (#458)', () => { + const excluded = ['inbox/Books', 'archive/Old Projects'] + + it('matches notes inside an excluded folder, any depth', () => { + expect(isPathExcludedFromTasks('inbox/Books/dune.md', excluded)).toBe(true) + expect(isPathExcludedFromTasks('inbox/Books/scifi/blindsight.md', excluded)).toBe(true) + expect(isPathExcludedFromTasks('archive/Old Projects/site.md', excluded)).toBe(true) + }) + + it('is a segment match, not a substring match', () => { + expect(isPathExcludedFromTasks('inbox/Bookshelf.md', excluded)).toBe(false) + expect(isPathExcludedFromTasks('inbox/Books.md', excluded)).toBe(false) + }) + + it('is case-sensitive like the rest of the vault layer', () => { + expect(isPathExcludedFromTasks('inbox/books/dune.md', excluded)).toBe(false) + }) + + it('never matches with an empty list', () => { + expect(isPathExcludedFromTasks('inbox/Books/dune.md', [])).toBe(false) + }) +}) diff --git a/packages/shared-domain/src/tasks-excluded-folders.ts b/packages/shared-domain/src/tasks-excluded-folders.ts new file mode 100644 index 00000000..81dc5a3a --- /dev/null +++ b/packages/shared-domain/src/tasks-excluded-folders.ts @@ -0,0 +1,53 @@ +// The vault-level "exclude this folder from Tasks" list (#458). Stored in +// `.zennotes/vault.json` under `tasks.excludedFolders` as vault-relative +// directory paths exactly as they exist on disk, so remapped system folders +// (#115) need no translation: every scanner matches against a note's real +// relative path. Desktop main, the MCP server, and the iPhone bridge all +// import these helpers; the Go server mirrors them in +// internal/vault/tasks_exclude.go: change both together and keep the rules +// byte-compatible. + +/** Validate one excluded-folder entry: forward slashes, no empty or dot + * segments, no traversal. Returns the cleaned path or null when invalid. */ +export function normalizeTasksExcludedFolder(value: unknown): string | null { + if (typeof value !== 'string') return null + const parts: string[] = [] + for (const seg of value.replace(/\\/g, '/').split('/')) { + const s = seg.trim() + if (!s) continue + if (s === '.' || s === '..') return null + parts.push(s) + } + if (parts.length === 0) return null + const joined = parts.join('/') + return joined.length > 512 ? null : joined +} + +/** Normalize the whole list: invalid entries drop, duplicates collapse, + * order is preserved. Never throws; a malformed vault.json value yields []. */ +export function normalizeTasksExcludedFolders(value: unknown): string[] { + if (!Array.isArray(value)) return [] + const out: string[] = [] + const seen = new Set() + for (const entry of value) { + const cleaned = normalizeTasksExcludedFolder(entry) + if (!cleaned || seen.has(cleaned)) continue + seen.add(cleaned) + out.push(cleaned) + } + return out +} + +/** 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`. */ +export function isPathExcludedFromTasks( + relPath: string, + excluded: readonly string[] +): boolean { + for (const folder of excluded) { + if (relPath === folder || relPath.startsWith(folder + '/')) return true + } + return false +} diff --git a/packages/shared-domain/src/tasks-mode.test.ts b/packages/shared-domain/src/tasks-mode.test.ts new file mode 100644 index 00000000..e396b5b0 --- /dev/null +++ b/packages/shared-domain/src/tasks-mode.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { + noteTasksMode, + parseTaskFile, + parseTasksFromBody, + type ParseTasksContext +} from './tasks' + +const ctx: ParseTasksContext = { path: 'inbox/t.md', title: 't', folder: 'inbox' } + +const fm = (lines: string[], body: string[] = []) => + ['---', ...lines, '---', ...body].join('\n') + +describe('noteTasksMode (#458)', () => { + it('maps false/off (any case, quoted or not) to none', () => { + expect(noteTasksMode('false')).toBe('none') + expect(noteTasksMode('off')).toBe('none') + expect(noteTasksMode('False')).toBe('none') + expect(noteTasksMode('OFF')).toBe('none') + expect(noteTasksMode(' off ')).toBe('none') + }) + + it('maps note to note-only', () => { + expect(noteTasksMode('note')).toBe('note-only') + expect(noteTasksMode('Note')).toBe('note-only') + }) + + it('falls back to all for absent, empty, and unrecognized values', () => { + expect(noteTasksMode(undefined)).toBe('all') + expect(noteTasksMode('')).toBe('all') + expect(noteTasksMode('true')).toBe('all') + expect(noteTasksMode('yes')).toBe('all') + expect(noteTasksMode('nope')).toBe('all') + // A bare `tasks:` key parses as an empty block list. + expect(noteTasksMode([])).toBe('all') + }) +}) + +describe('frontmatter tasks: false, the note is a pure checklist (#458)', () => { + const checklist = ['- [ ] Dune', '- [x] Hyperion', '- [ ] Blindsight due:2026-09-01'] + + it('suppresses every inline checkbox', () => { + expect(parseTasksFromBody(fm(['tasks: false'], checklist), ctx)).toEqual([]) + expect(parseTasksFromBody(fm(['tasks: off'], checklist), ctx)).toEqual([]) + }) + + it('suppresses the file task even when tags include task', () => { + const body = fm(['tags: [task]', 'tasks: false'], ['notes']) + expect(parseTaskFile(body, ctx)).toBeNull() + }) + + it('quoted values behave like bare ones', () => { + expect(parseTasksFromBody(fm(['tasks: "false"'], checklist), ctx)).toEqual([]) + }) + + it('leaves other notes untouched: unrecognized values mean all', () => { + expect(parseTasksFromBody(fm(['tasks: everything'], checklist), ctx)).toHaveLength(3) + expect(parseTasksFromBody(checklist.join('\n'), ctx)).toHaveLength(3) + }) +}) + +describe('frontmatter tasks: note, file task stays, checkboxes stop (#458)', () => { + const body = fm( + ['tags: [task]', 'tasks: note', 'status: in-progress', 'due: 2026-09-01'], + ['- [ ] research', '- [x] outline'] + ) + + it('keeps the file task with its frontmatter metadata', () => { + const task = parseTaskFile(body, ctx) + expect(task).not.toBeNull() + expect(task?.kind).toBe('file') + expect(task?.inProgress).toBe(true) + expect(task?.due).toBe('2026-09-01') + }) + + it('suppresses the inline checkboxes', () => { + expect(parseTasksFromBody(body, ctx)).toEqual([]) + }) + + it('on a note without the task tag it simply silences checkboxes', () => { + const plain = fm(['tasks: note'], ['- [ ] a', '- [ ] b']) + expect(parseTaskFile(plain, ctx)).toBeNull() + expect(parseTasksFromBody(plain, ctx)).toEqual([]) + }) +}) + +describe('includeExcluded escape hatch (#458)', () => { + const body = fm(['tags: [task]', 'tasks: false'], ['- [ ] hidden', '- [ ] also hidden']) + + it('reveals inline tasks with stable ids and indexes', () => { + const tasks = parseTasksFromBody(body, ctx, { includeExcluded: true }) + expect(tasks).toHaveLength(2) + expect(tasks[0].id).toBe('inbox/t.md#0') + expect(tasks[1].id).toBe('inbox/t.md#1') + }) + + it('reveals the file task', () => { + const task = parseTaskFile(body, ctx, { includeExcluded: true }) + expect(task?.kind).toBe('file') + expect(task?.id).toBe('inbox/t.md#task') + }) +}) diff --git a/packages/shared-domain/src/tasks.ts b/packages/shared-domain/src/tasks.ts index 810d5b7a..2bf7c797 100644 --- a/packages/shared-domain/src/tasks.ts +++ b/packages/shared-domain/src/tasks.ts @@ -100,6 +100,32 @@ interface NoteDefaults { due?: string priority?: TaskPriority status?: string + tasksMode: NoteTasksMode +} + +/** + * How a note participates in the Tasks system, from its frontmatter `tasks:` + * key (#458). `'all'` (the default, and the fallback for any unrecognized + * value): file task plus inline checkboxes, the behavior every vault had + * before the key existed. `'none'` (`tasks: false` or `tasks: off`): the note + * feeds nothing to any Tasks surface; its checkboxes stay plain checkboxes. + * `'note-only'` (`tasks: note`): the note's own file task (frontmatter + * `tags: [task]`) stays visible, but inline checkboxes are not tasks: a + * project note that belongs on the board without its checklist spilling onto + * it. + * + * No runtime in this app types YAML scalars, so `tasks: false` arrives as the + * string "false"; matching is exact string comparison after lower-casing. + * Mirrored in the MCP and Go parsers; keep the accepted values identical. + */ +export type NoteTasksMode = 'all' | 'note-only' | 'none' + +export function noteTasksMode(value: string | string[] | undefined): NoteTasksMode { + const scalar = Array.isArray(value) ? value[0] : value + const v = scalar?.trim().toLowerCase() + if (v === 'false' || v === 'off') return 'none' + if (v === 'note') return 'note-only' + return 'all' } const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/ @@ -126,12 +152,12 @@ function normalizeDueDate(raw: string | undefined): string | undefined { return isValidIsoDate(cleaned) ? cleaned : undefined } -/** Extract just the three keys we care about. Unparseable lines are ignored. */ +/** Extract just the keys we care about. Unparseable lines are ignored. */ function parseNoteDefaults(body: string): { defaults: NoteDefaults; fmEndOffset: number } { const m = body.match(FRONTMATTER_RE) - if (!m) return { defaults: {}, fmEndOffset: 0 } + if (!m) return { defaults: { tasksMode: 'all' }, fmEndOffset: 0 } const block = m[1] - const defaults: NoteDefaults = {} + const defaults: NoteDefaults = { tasksMode: 'all' } for (const rawLine of block.split('\n')) { const line = rawLine.trim() if (!line || line.startsWith('#')) continue @@ -147,6 +173,8 @@ function parseNoteDefaults(body: string): { defaults: NoteDefaults; fmEndOffset: if (p) defaults.priority = p } else if (key === 'status') { defaults.status = value.toLowerCase() + } else if (key === 'tasks') { + defaults.tasksMode = noteTasksMode(value) } } return { defaults, fmEndOffset: m[0].length } @@ -248,12 +276,28 @@ export interface ParseTasksContext { folder: NoteFolder } +export interface ParseTasksOptions { + /** Scan past the note-level `tasks:` opt-out (#458): the CLI/MCP/HTTP + * "include excluded" escape hatch. UI surfaces never set this. */ + includeExcluded?: boolean +} + /** Parse every checkbox in `body`, skipping fenced code. Index counting is * byte-for-byte identical to `toggleTaskAtIndex` so round-trip edits stay * stable. */ -export function parseTasksFromBody(body: string, ctx: ParseTasksContext): VaultTask[] { +export function parseTasksFromBody( + body: string, + ctx: ParseTasksContext, + opts?: ParseTasksOptions +): VaultTask[] { const normalized = body.replace(/\r\n/g, '\n') const { defaults } = parseNoteDefaults(normalized) + + // A note opted out via frontmatter `tasks:` contributes no inline tasks; + // both 'none' and 'note-only' suppress checkboxes (#458). Index counting + // below is untouched, so task ids stay stable when includeExcluded + // re-reveals them. + if (defaults.tasksMode !== 'all' && !opts?.includeExcluded) return [] const lines = normalized.split('\n') const tasks: VaultTask[] = [] @@ -367,12 +411,22 @@ function firstScalar(v: string | string[] | undefined): string | undefined { * is emitted *in addition* to any inline `- [ ]` checkboxes in the same body * (which act as subtasks), each keeping its own id. */ -export function parseTaskFile(body: string, ctx: ParseTasksContext): VaultTask | null { +export function parseTaskFile( + body: string, + ctx: ParseTasksContext, + opts?: ParseTasksOptions +): VaultTask | null { const normalized = body.replace(/\r\n/g, '\n') const m = normalized.match(FRONTMATTER_RE) if (!m) return null const fm = parseFrontmatterFields(m[1]) + // `tasks: false` wins over `tags: [task]`: the note stops being a task on + // every surface. `tasks: note` deliberately falls through: it suppresses + // only the inline checkboxes (in parseTasksFromBody), keeping this file + // task on the board. (#458) + if (noteTasksMode(fm.tasks) === 'none' && !opts?.includeExcluded) return null + const tags = asArray(fm.tags).map((t) => t.replace(/^#/, '').toLowerCase()) if (!tags.includes(TASK_FILE_TAG)) return null diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 03950964..8defdd38 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.22.1", + "version": "2.23.0", "type": "module", "exports": { ".": "./src/index.ts"