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
2 changes: 1 addition & 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.45.0",
"version": "2.46.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/cli/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
prependToNote,
readDatabaseVaultLayout,
readNote,
readNoteComments,
readPrimaryNotesLocation,
readVaultFileTextOrNull,
renameFolder,
Expand All @@ -55,7 +56,10 @@ import {
toggleTaskInBody,
unarchiveNote,
writeNote,
writeNoteComments,
writeVaultFileText,
type NoteComment,
type NoteCommentInput,
type NoteContent,
type NoteFolder,
type NoteMeta,
Expand Down Expand Up @@ -148,6 +152,9 @@ export interface VaultBackend {
backlinks(rel: string): Promise<NoteMeta[]>
scanAllTasks(opts?: { includeExcluded?: boolean }): Promise<VaultTask[]>
toggleTask(taskId: string): Promise<VaultTask | null>
/** A note's comments as stored (#738); `writeComments` replaces the list. */
listComments(rel: string): Promise<NoteComment[]>
writeComments(rel: string, comments: NoteCommentInput[]): Promise<NoteComment[]>
/** Database (`.base`) operations, composed from this backend's file IO via
* @shared/database-ops — the same composition the web and desktop remote
* clients use, so `zn base` writes the identical on-disk format. (#556) */
Expand Down Expand Up @@ -270,6 +277,9 @@ class LocalBackend implements VaultBackend {
scanAllTasks = (opts?: { includeExcluded?: boolean }): Promise<VaultTask[]> =>
scanAllTasks(this.root, opts)
toggleTask = (taskId: string): Promise<VaultTask | null> => toggleTask(this.root, taskId)
listComments = (rel: string): Promise<NoteComment[]> => readNoteComments(this.root, rel)
writeComments = (rel: string, comments: NoteCommentInput[]): Promise<NoteComment[]> =>
writeNoteComments(this.root, rel, comments)

private dbOps: DatabaseOps | null = null
databaseOps = (): DatabaseOps => {
Expand Down Expand Up @@ -417,6 +427,11 @@ class RemoteBackend implements VaultBackend {
scanAllTasks = (opts?: { includeExcluded?: boolean }): Promise<VaultTask[]> =>
this.client.scanTasks(opts)

listComments = (rel: string): Promise<NoteComment[]> =>
this.client.readComments(normalizeRelPath(rel))
writeComments = (rel: string, comments: NoteCommentInput[]): Promise<NoteComment[]> =>
this.client.writeComments(normalizeRelPath(rel), comments)

/** 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
* — which keeps the Go and TypeScript task parsers honest with each other. */
Expand Down
84 changes: 84 additions & 0 deletions apps/desktop/src/cli/commands/comments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { promises as fsp } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createBackend, type VaultBackend } from '../backend'
import type { ParsedArgs } from '../args'
import { cmdCommentAdd, cmdCommentList, cmdCommentReply, cmdCommentResolve } from './comments'

// `zn comment` (#738) against a folder vault: the same sidecar the app and
// the MCP tools read, so a thread started here shows up in the panel.

function args(positionals: string[], flags: Array<[string, string]> = []): ParsedArgs {
return { positionals, flags: new Map(flags.map(([k, v]) => [k, [v]])) }
}

let root: string
let backend: VaultBackend
let out: string[]

beforeEach(async () => {
root = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-comment-cli-'))
await fsp.mkdir(path.join(root, 'inbox'), { recursive: true })
await fsp.writeFile(path.join(root, 'inbox', 'Plan.md'), '# Plan\n\nShip the beta in October.\n')
backend = createBackend({ kind: 'local', root })
out = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
out.push(String(chunk))
return true
})
})

afterEach(async () => {
vi.restoreAllMocks()
await fsp.rm(root, { recursive: true, force: true })
})

describe('zn comment', () => {
it('adds, lists, answers and resolves a thread', async () => {
await cmdCommentAdd(
backend,
args(['inbox/Plan.md', 'Still realistic?'], [['anchor', 'Ship the beta in October.']])
)
expect(out.join('')).toMatch(/Commented on inbox\/Plan\.md \(.+, line 3\)/)

out = []
await cmdCommentList(backend, args(['inbox/Plan.md'], [['json', 'true']]))
const threads = JSON.parse(out.join('')) as Array<{ id: string; author: string | null; line: number }>
expect(threads).toHaveLength(1)
expect(threads[0].author).toBeNull()
expect(threads[0].line).toBe(3)

out = []
await cmdCommentReply(
backend,
args(['inbox/Plan.md', threads[0].id, 'Yes, the blocker runs at night.'], [['author', 'Claude']])
)
expect(out.join('')).toContain('Replied in')

out = []
await cmdCommentList(backend, args(['inbox/Plan.md']))
const text = out.join('')
expect(text).toContain('You')
expect(text).toContain('> Ship the beta in October.')
expect(text).toContain('Claude')
expect(text).toContain('Yes, the blocker runs at night.')

out = []
await cmdCommentResolve(backend, args(['inbox/Plan.md', threads[0].id]))
expect(out.join('')).toContain('Resolved')
out = []
await cmdCommentList(backend, args(['inbox/Plan.md']))
expect(out.join('')).toContain('No open comments')
out = []
await cmdCommentList(backend, args(['inbox/Plan.md'], [['all', 'true']]))
expect(out.join('')).toContain('(resolved)')
})

it('explains usage when the path or body is missing', async () => {
await expect(cmdCommentAdd(backend, args([]))).rejects.toThrow(/Usage: zn comment add/)
await expect(cmdCommentAdd(backend, args(['inbox/Plan.md']))).rejects.toThrow(/Usage: zn comment add/)
await expect(cmdCommentReply(backend, args(['inbox/Plan.md']))).rejects.toThrow(/Usage: zn comment reply/)
await expect(cmdCommentResolve(backend, args(['inbox/Plan.md']))).rejects.toThrow(/Usage: zn comment resolve/)
})
})
106 changes: 106 additions & 0 deletions apps/desktop/src/cli/commands/comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* `zn comment ...` (#738): list, add, reply to and resolve the comments on a
* note, the same operations the MCP tools expose, so a script or an agent
* without MCP can join a review thread.
*/

import type { VaultBackend } from '../backend.js'
import { getBool, getString, type ParsedArgs } from '../args.js'
import { emitJson, emitLine, emitOk } from '../format.js'
import {
addComment,
listCommentThreads,
replyToComment,
resolveComment,
type CommentThreadView
} from '../../mcp/comment-ops.js'

function requirePath(args: ParsedArgs, usage: string): string {
const rel = getString(args, 'path') ?? args.positionals[0]
if (!rel) throw new Error(`Usage: ${usage}`)
return rel
}

function requireBody(args: ParsedArgs, positionalIndex: number, usage: string): string {
const body = getString(args, 'body') ?? args.positionals[positionalIndex]
if (!body || !body.trim()) throw new Error(`Usage: ${usage}`)
return body
}

function when(ms: number): string {
return new Date(ms).toISOString().replace('T', ' ').slice(0, 16)
}

function printThread(thread: CommentThreadView): void {
const who = thread.author ?? 'You'
const state = thread.resolved ? ' (resolved)' : ''
emitLine(`${thread.id} ${who} ${when(thread.createdAt)} line ${thread.line}${state}`)
if (thread.anchorText) emitLine(` > ${thread.anchorText}`)
emitLine(` ${thread.body.replace(/\n/g, '\n ')}`)
for (const reply of thread.replies) {
emitLine(` ${reply.id} ${reply.author ?? 'You'} ${when(reply.createdAt)}`)
emitLine(` ${reply.body.replace(/\n/g, '\n ')}`)
}
}

export async function cmdCommentList(vault: VaultBackend, args: ParsedArgs): Promise<void> {
const rel = requirePath(args, 'zn comment list <path> [--all] [--json]')
const threads = await listCommentThreads(vault, rel, { includeResolved: getBool(args, 'all') })
if (getBool(args, 'json')) {
emitJson(threads)
return
}
if (threads.length === 0) {
emitLine(getBool(args, 'all') ? 'No comments.' : 'No open comments. Pass --all to include resolved ones.')
return
}
threads.forEach((thread, index) => {
if (index > 0) emitLine('')
printThread(thread)
})
}

export async function cmdCommentAdd(vault: VaultBackend, args: ParsedArgs): Promise<void> {
const usage = 'zn comment add <path> "<body>" [--anchor "<text from the note>"] [--author <name>]'
const rel = requirePath(args, usage)
const body = requireBody(args, 1, usage)
const thread = await addComment(vault, {
path: rel,
body,
anchorText: getString(args, 'anchor'),
author: getString(args, 'author')
})
if (getBool(args, 'json')) {
emitJson(thread)
return
}
emitOk(`Commented on ${rel} (${thread.id}${thread.anchorText ? `, line ${thread.line}` : ''})`)
}

export async function cmdCommentReply(vault: VaultBackend, args: ParsedArgs): Promise<void> {
const usage = 'zn comment reply <path> <id> "<body>" [--author <name>]'
const rel = requirePath(args, usage)
const id = getString(args, 'id') ?? args.positionals[1]
if (!id) throw new Error(`Usage: ${usage}`)
const body = requireBody(args, 2, usage)
const thread = await replyToComment(vault, { path: rel, id, body, author: getString(args, 'author') })
if (getBool(args, 'json')) {
emitJson(thread)
return
}
emitOk(`Replied in ${thread.id} on ${rel} (${thread.replies.length} ${thread.replies.length === 1 ? 'reply' : 'replies'})`)
}

export async function cmdCommentResolve(vault: VaultBackend, args: ParsedArgs): Promise<void> {
const usage = 'zn comment resolve <path> <id> [--reopen]'
const rel = requirePath(args, usage)
const id = getString(args, 'id') ?? args.positionals[1]
if (!id) throw new Error(`Usage: ${usage}`)
const reopen = getBool(args, 'reopen')
const thread = await resolveComment(vault, { path: rel, id, resolved: !reopen })
if (getBool(args, 'json')) {
emitJson(thread)
return
}
emitOk(`${reopen ? 'Reopened' : 'Resolved'} ${thread.id} on ${rel}`)
}
11 changes: 11 additions & 0 deletions apps/desktop/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [
{ name: 'task toggle <id>', description: 'Flip a task checkbox by stable id' }
]
},
{
heading: 'COMMENTS',
rows: [
{ name: 'comment list <path>', description: 'Comment threads on a note, with anchors and replies', flags: '--all --json' },
{ name: 'comment add <path> "<body>"', description: 'Start a thread, optionally anchored to text from the note', flags: '--anchor <text> --author <name> --json' },
{ name: 'comment reply <path> <id> "<body>"', description: 'Answer in a thread', flags: '--author <name> --json' },
{ name: 'comment resolve <path> <id>', description: 'Resolve a thread (or reopen it)', flags: '--reopen --json' }
]
},
{
heading: 'VAULT',
rows: [
Expand Down Expand Up @@ -203,6 +212,8 @@ const EXAMPLES: string[] = [
'zn list --server home # a self-hosted ZenNotes server',
'zn capture "from CI" --server https://notes.example.com',
'zn task list --unchecked --tag work',
'zn comment list inbox/Plan.md',
'zn comment reply inbox/Plan.md <id> "Agreed, fixed in the second paragraph." --author Claude',
'zn open ~/Downloads/notes.md',
'zn open ~/code/project/docs # focus a folder as a session'
]
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ import {
cmdFolderRename
} from './commands/folders.js'
import { cmdTaskList, cmdTaskToggle } from './commands/tasks.js'
import {
cmdCommentAdd,
cmdCommentList,
cmdCommentReply,
cmdCommentResolve
} from './commands/comments.js'
import { cmdTagFind, cmdTagList } from './commands/tags.js'
import { cmdVaultInfo, cmdVaultList } from './commands/vault.js'
import { cmdCapture } from './commands/capture.js'
Expand Down Expand Up @@ -132,6 +138,10 @@ async function main(argv: string[]): Promise<number> {
'tag find': cmdTagFind,
'task list': cmdTaskList,
'task toggle': cmdTaskToggle,
'comment list': cmdCommentList,
'comment add': cmdCommentAdd,
'comment reply': cmdCommentReply,
'comment resolve': cmdCommentResolve,
'vault info': cmdVaultInfo,
'base list': cmdBaseList,
'base create': cmdBaseCreate,
Expand Down Expand Up @@ -163,6 +173,7 @@ function peelSubcommand(
folder: ['list', 'create', 'rename', 'delete'],
tag: ['list', 'find'],
task: ['list', 'toggle'],
comment: ['list', 'add', 'reply', 'resolve'],
vault: ['info', 'list'],
base: ['list', 'create', 'rows', 'get', 'add', 'set']
}
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/cli/remote/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

import { remoteJsonRequest } from '../../main/remote/connection.js'
import type {
NoteComment,
NoteCommentInput,
NoteContent,
NoteFolder,
NoteMeta,
Expand Down Expand Up @@ -85,6 +87,16 @@ export class CliRemoteClient {
return this.get<VaultTextSearchMatch[]>(`/api/search/text?${params.toString()}`)
}

/** A note's comment sidecar, through the same two routes the desktop
* remote client and the web client use (#738). */
readComments(relPath: string): Promise<NoteComment[]> {
return this.get<NoteComment[]>(`/api/comments/read?path=${encodeURIComponent(relPath)}`)
}

writeComments(relPath: string, comments: NoteCommentInput[]): Promise<NoteComment[]> {
return this.post<NoteComment[]>('/api/comments/write', { path: relPath, comments })
}

scanTasks(opts?: { includeExcluded?: boolean }): Promise<VaultTask[]> {
return this.get<VaultTask[]>(
opts?.includeExcluded ? '/api/tasks?includeExcluded=1' : '/api/tasks'
Expand Down
32 changes: 31 additions & 1 deletion apps/desktop/src/main/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ describe('TOML serialization', () => {
quickNoteTitlePrefix: 'Quick Note',
keymapOverrides: { 'global.searchNotes': 'Mod+P' },
kanbanColumnTitles: { 'status:todo': 'To Do' },
systemFolderLabels: { inbox: 'In' }
systemFolderLabels: { inbox: 'In' },
savedTaskFilters: { 'Project alpha': '@project:alpha', Blocked: '@status:blocked' },
kanbanGroupBy: 'folder',
kanbanFolderRoot: 'Projects',
ignoredKeys: ['KanaMode', 'F24']
}

const text = serializeConfig(portable)
Expand All @@ -135,6 +139,18 @@ describe('TOML serialization', () => {
expect(round.keymapOverrides).toEqual({ 'global.searchNotes': 'Mod+P' })
expect(round.kanbanColumnTitles).toEqual({ 'status:todo': 'To Do' })
expect(round.systemFolderLabels).toEqual({ inbox: 'In' })
// The [saved_filters] table keeps the order the chips show (#731).
expect(text).toContain('kanban_folder_root = "Projects"')
expect(round.kanbanGroupBy).toBe('folder')
expect(round.kanbanFolderRoot).toBe('Projects')
expect(text).toContain('ignored_keys = ["KanaMode", "F24"]')
expect(round.ignoredKeys).toEqual(['KanaMode', 'F24'])
expect(text).toContain('[saved_filters]')
expect(text).toContain('"Project alpha" = "@project:alpha"')
expect(Object.entries(round.savedTaskFilters as Record<string, string>)).toEqual([
['Project alpha', '@project:alpha'],
['Blocked', '@status:blocked']
])
})

it('persists null as empty string and reads it back as null', () => {
Expand Down Expand Up @@ -281,3 +297,17 @@ describe('file watching', () => {
expect(changes.at(-1)?.editorFontSize).toBe(22)
}, 10000)
})

describe('unbound keymaps in config.toml', () => {
it('writes an unbind as an empty binding, marks it, and reads it back as ""', () => {
const text = serializeConfig({ keymapOverrides: { 'global.zoomIn': '' } })
expect(text).toContain('"global.zoomIn" = "" # unbound')
// The reference list explains the convention and no longer repeats the
// overridden action as a commented default.
expect(text).toContain('# An empty binding ("") removes the key entirely')
expect(text).not.toContain('# "global.zoomIn" = "Mod+="')

const { portable } = deserializeConfig(text)
expect(portable.keymapOverrides).toEqual({ 'global.zoomIn': '' })
})
})
Loading
Loading