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
3 changes: 2 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -268,6 +268,7 @@
"rpm",
"tar.gz"
],
"compression": "normal",
"icon": "build/icons",
"maintainer": "Adib Hanna <adibhanna@gmail.com>",
"category": "Office",
Expand Down
21 changes: 16 additions & 5 deletions apps/desktop/src/cli/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export interface VaultBackend {
deleteFolder(folder: NoteFolder, subpath: string): Promise<void>
searchText(query: string, limit: number): Promise<VaultTextSearchMatch[]>
backlinks(rel: string): Promise<NoteMeta[]>
scanAllTasks(): Promise<VaultTask[]>
scanAllTasks(opts?: { includeExcluded?: boolean }): Promise<VaultTask[]>
toggleTask(taskId: string): Promise<VaultTask | null>
}

Expand Down Expand Up @@ -138,7 +138,8 @@ class LocalBackend implements VaultBackend {
searchText = (query: string, limit: number): Promise<VaultTextSearchMatch[]> =>
searchText(this.root, query, limit)
backlinks = (rel: string): Promise<NoteMeta[]> => backlinks(this.root, rel)
scanAllTasks = (): Promise<VaultTask[]> => scanAllTasks(this.root)
scanAllTasks = (opts?: { includeExcluded?: boolean }): Promise<VaultTask[]> =>
scanAllTasks(this.root, opts)
toggleTask = (taskId: string): Promise<VaultTask | null> => toggleTask(this.root, taskId)
}

Expand Down Expand Up @@ -213,7 +214,8 @@ class RemoteBackend implements VaultBackend {
backlinks = async (rel: string): Promise<NoteMeta[]> =>
backlinksIn(await this.client.listNotes(), normalizeRelPath(rel))

scanAllTasks = (): Promise<VaultTask[]> => this.client.scanTasks()
scanAllTasks = (opts?: { includeExcluded?: boolean }): Promise<VaultTask[]> =>
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
Expand All @@ -222,17 +224,26 @@ 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))
}
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
)
}
}

Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/cli/commands/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import { emitJson, emitLine, emitOk, pad, truncate } from '../format.js'
export async function cmdTaskList(vault: VaultBackend, args: ParsedArgs): Promise<void> {
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)
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <t> --json' },
{ name: 'task list', description: 'Open checkbox tasks across all notes', flags: '--unchecked --all --tag <t> --include-excluded --json' },
{ name: 'task toggle <id>', description: 'Flip a task checkbox by stable id' }
]
},
Expand Down
20 changes: 14 additions & 6 deletions apps/desktop/src/cli/remote/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,20 @@ export class CliRemoteClient {
return this.get<VaultTextSearchMatch[]>(`/api/search/text?${params.toString()}`)
}

scanTasks(): Promise<VaultTask[]> {
return this.get<VaultTask[]>('/api/tasks')
}

scanTasksForPath(relPath: string): Promise<VaultTask[]> {
return this.get<VaultTask[]>(`/api/tasks/for?path=${encodeURIComponent(relPath)}`)
scanTasks(opts?: { includeExcluded?: boolean }): Promise<VaultTask[]> {
return this.get<VaultTask[]>(
opts?.includeExcluded ? '/api/tasks?includeExcluded=1' : '/api/tasks'
)
}

scanTasksForPath(
relPath: string,
opts?: { includeExcluded?: boolean }
): Promise<VaultTask[]> {
const suffix = opts?.includeExcluded ? '&includeExcluded=1' : ''
return this.get<VaultTask[]>(
`/api/tasks/for?path=${encodeURIComponent(relPath)}${suffix}`
)
}

/* --- writes --- */
Expand Down
15 changes: 12 additions & 3 deletions apps/desktop/src/main/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<VaultTask[]> {
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)
Expand All @@ -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
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/src/main/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -1045,6 +1046,7 @@ function normalizeVaultSettings(
favorites?: unknown
view?: unknown
systemFolderPaths?: unknown
tasks?: unknown
}
const folderIcons: Record<string, FolderIconId> = {}
if (candidate.folderIcons && typeof candidate.folderIcons === 'object') {
Expand Down Expand Up @@ -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). */
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/mcp/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
}
}
}
Expand All @@ -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')
Expand All @@ -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
Expand Down
Loading
Loading