diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f5c8353c..75703951 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/cli/backend.ts b/apps/desktop/src/cli/backend.ts index bf45889c..6bc4ab23 100644 --- a/apps/desktop/src/cli/backend.ts +++ b/apps/desktop/src/cli/backend.ts @@ -40,6 +40,7 @@ import { prependToNote, readDatabaseVaultLayout, readNote, + readNoteComments, readPrimaryNotesLocation, readVaultFileTextOrNull, renameFolder, @@ -55,7 +56,10 @@ import { toggleTaskInBody, unarchiveNote, writeNote, + writeNoteComments, writeVaultFileText, + type NoteComment, + type NoteCommentInput, type NoteContent, type NoteFolder, type NoteMeta, @@ -148,6 +152,9 @@ export interface VaultBackend { backlinks(rel: string): Promise scanAllTasks(opts?: { includeExcluded?: boolean }): Promise toggleTask(taskId: string): Promise + /** A note's comments as stored (#738); `writeComments` replaces the list. */ + listComments(rel: string): Promise + writeComments(rel: string, comments: NoteCommentInput[]): Promise /** 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) */ @@ -270,6 +277,9 @@ class LocalBackend implements VaultBackend { scanAllTasks = (opts?: { includeExcluded?: boolean }): Promise => scanAllTasks(this.root, opts) toggleTask = (taskId: string): Promise => toggleTask(this.root, taskId) + listComments = (rel: string): Promise => readNoteComments(this.root, rel) + writeComments = (rel: string, comments: NoteCommentInput[]): Promise => + writeNoteComments(this.root, rel, comments) private dbOps: DatabaseOps | null = null databaseOps = (): DatabaseOps => { @@ -417,6 +427,11 @@ class RemoteBackend implements VaultBackend { scanAllTasks = (opts?: { includeExcluded?: boolean }): Promise => this.client.scanTasks(opts) + listComments = (rel: string): Promise => + this.client.readComments(normalizeRelPath(rel)) + writeComments = (rel: string, comments: NoteCommentInput[]): Promise => + 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. */ diff --git a/apps/desktop/src/cli/commands/comments.test.ts b/apps/desktop/src/cli/commands/comments.test.ts new file mode 100644 index 00000000..908d27df --- /dev/null +++ b/apps/desktop/src/cli/commands/comments.test.ts @@ -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/) + }) +}) diff --git a/apps/desktop/src/cli/commands/comments.ts b/apps/desktop/src/cli/commands/comments.ts new file mode 100644 index 00000000..5dfef00e --- /dev/null +++ b/apps/desktop/src/cli/commands/comments.ts @@ -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 { + const rel = requirePath(args, 'zn comment list [--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 { + const usage = 'zn comment add "" [--anchor ""] [--author ]' + 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 { + const usage = 'zn comment reply "" [--author ]' + 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 { + const usage = 'zn comment resolve [--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}`) +} diff --git a/apps/desktop/src/cli/help.ts b/apps/desktop/src/cli/help.ts index f342354a..8b7c51e6 100644 --- a/apps/desktop/src/cli/help.ts +++ b/apps/desktop/src/cli/help.ts @@ -138,6 +138,15 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [ { name: 'task toggle ', description: 'Flip a task checkbox by stable id' } ] }, + { + heading: 'COMMENTS', + rows: [ + { name: 'comment list ', description: 'Comment threads on a note, with anchors and replies', flags: '--all --json' }, + { name: 'comment add ""', description: 'Start a thread, optionally anchored to text from the note', flags: '--anchor --author --json' }, + { name: 'comment reply ""', description: 'Answer in a thread', flags: '--author --json' }, + { name: 'comment resolve ', description: 'Resolve a thread (or reopen it)', flags: '--reopen --json' } + ] + }, { heading: 'VAULT', rows: [ @@ -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 "Agreed, fixed in the second paragraph." --author Claude', 'zn open ~/Downloads/notes.md', 'zn open ~/code/project/docs # focus a folder as a session' ] diff --git a/apps/desktop/src/cli/index.ts b/apps/desktop/src/cli/index.ts index 79e37e26..7e07e8dc 100644 --- a/apps/desktop/src/cli/index.ts +++ b/apps/desktop/src/cli/index.ts @@ -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' @@ -132,6 +138,10 @@ async function main(argv: string[]): Promise { '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, @@ -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'] } diff --git a/apps/desktop/src/cli/remote/client.ts b/apps/desktop/src/cli/remote/client.ts index 4a3a165f..5f7006b0 100644 --- a/apps/desktop/src/cli/remote/client.ts +++ b/apps/desktop/src/cli/remote/client.ts @@ -14,6 +14,8 @@ import { remoteJsonRequest } from '../../main/remote/connection.js' import type { + NoteComment, + NoteCommentInput, NoteContent, NoteFolder, NoteMeta, @@ -85,6 +87,16 @@ export class CliRemoteClient { return this.get(`/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 { + return this.get(`/api/comments/read?path=${encodeURIComponent(relPath)}`) + } + + writeComments(relPath: string, comments: NoteCommentInput[]): Promise { + return this.post('/api/comments/write', { path: relPath, comments }) + } + scanTasks(opts?: { includeExcluded?: boolean }): Promise { return this.get( opts?.includeExcluded ? '/api/tasks?includeExcluded=1' : '/api/tasks' diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index f091f471..bb107de1 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -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) @@ -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)).toEqual([ + ['Project alpha', '@project:alpha'], + ['Blocked', '@status:blocked'] + ]) }) it('persists null as empty string and reads it back as null', () => { @@ -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': '' }) + }) +}) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index da3ebf41..b6a1da47 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -373,7 +373,13 @@ const SCALAR_FIELDS: Partial> = { kanbanGroupBy: { section: 'view', tomlKey: 'kanban_group_by', - comment: 'status | priority | folder' + comment: 'status | priority | folder (each note\'s own folder; see kanban_folder_root) | field:' + }, + kanbanFolderRoot: { + section: 'view', + tomlKey: 'kanban_folder_root', + comment: + 'folder board: group by the children of this folder, e.g. "Projects" (deeper notes roll up, notes outside it share one column); "" = each note\'s own folder' } } @@ -388,6 +394,12 @@ interface ListFieldMap { // List (ordered string[]) portable prefs → [section].key = ["a", "b"]. const LIST_FIELDS: Partial> = { + ignoredKeys: { + section: 'editor', + tomlKey: 'ignored_keys', + comment: + 'keys the app ignores entirely, by DOM key or code, e.g. ["KanaMode"] for the no-op a Kanata/QMK tap-hold layer sends with every keystroke' + }, kanbanStatuses: { section: 'view', tomlKey: 'kanban_statuses', @@ -416,6 +428,7 @@ const MAP_TABLE_FIELDS: Partial> = { table: 'keymaps', comment: [ 'Keymap overrides — only list the bindings you want to change.', + 'Set a binding to "" to remove the key entirely.', 'Find the full list of action IDs in Settings → Keymaps.' ], example: '"global.searchNotes" = "Mod+P"' @@ -444,6 +457,15 @@ const MAP_TABLE_FIELDS: Partial> = { table: 'text_replacements', comment: ['Text replacements expanded while typing, keyed by trigger.'], example: '"->" = "→"' + }, + savedTaskFilters: { + table: 'saved_filters', + comment: [ + 'Saved Tasks filters: a name you pick = the filter query it stands for.', + 'Recall one from the chips above the task list, the command palette,', + 'or `:filter ` in the Tasks view; `:savefilter ` adds one.' + ], + example: '"Project alpha" = "@project:alpha"' } } @@ -606,11 +628,14 @@ function keymapSectionLines(rawOverrides: unknown): string[] { '# Keymap overrides. Add or uncomment "" = "" lines.', '# Binding syntax: "Mod+P" = Cmd/Ctrl+P, "Shift+Mod+K", "Ctrl+W", "Space",', '# or a two-key sequence like "g g". Uncomment a reference line to remap it.', + '# An empty binding ("") removes the key entirely: nothing triggers that', + '# action until you give it a key again or delete the line.', '[keymaps]' ] for (const [key, value] of Object.entries(overrides)) { - lines.push(`${tomlKey(key)} = ${tomlValue(value)}`) + const line = `${tomlKey(key)} = ${tomlValue(value)}` + lines.push(value === '' ? `${line} # unbound` : line) } lines.push('', '# --- All actions (defaults shown; uncomment + edit to override) ---') diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2cb126de..3eb65213 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3448,18 +3448,36 @@ function registerIpc(): void { return await deleteWorkflowRuns(v.root, workflowId); }); - // Custom templates live on the local filesystem only; remote vaults fall - // back to built-in templates (renderer constants), so list returns empty and - // mutations are rejected. + // Custom templates are the vault's .zennotes/templates/ on both sides of + // the bridge: a local vault reads the disk, and a remote workspace asks the + // server, which serves the same files through its /templates routes since + // 2.46 (#723). An older server has no such routes: it lists nothing, keeps + // the built-in templates (renderer constants), and names the remedy on a + // write instead of answering a bare 404. + const requireRemoteTemplates = (action: string): RemoteServerClient => { + const client = requireRemoteWorkspaceClient(); + if (!remoteServerCapabilities?.supportsCustomTemplates) { + throw new Error( + `${action} needs ZenNotes server 2.46 or later. Update the server and reconnect this workspace.`, + ); + } + return client; + }; + handle(IPC.VAULT_LIST_TEMPLATES, async () => { - if (isRemoteWorkspaceActive()) return []; + if (isRemoteWorkspaceActive()) { + if (!remoteServerCapabilities?.supportsCustomTemplates) return []; + return await requireRemoteWorkspaceClient().listTemplates(); + } const v = requireVault(); return await listCustomTemplates(v.root); }); handle(IPC.VAULT_READ_TEMPLATE, async (_e, sourcePath: string) => { if (isRemoteWorkspaceActive()) { - throw new Error("Custom templates are unavailable on remote vaults"); + return await requireRemoteTemplates("Editing a custom template").readTemplate( + sourcePath, + ); } const v = requireVault(); return await readCustomTemplate(v.root, sourcePath); @@ -3467,7 +3485,9 @@ function registerIpc(): void { handle(IPC.VAULT_WRITE_TEMPLATE, async (_e, input: WriteTemplateInput) => { if (isRemoteWorkspaceActive()) { - throw new Error("Custom templates are unavailable on remote vaults"); + return await requireRemoteTemplates("Saving a custom template").writeTemplate( + input, + ); } const v = requireVault(); return await writeCustomTemplate(v.root, input); @@ -3475,7 +3495,9 @@ function registerIpc(): void { handle(IPC.VAULT_DELETE_TEMPLATE, async (_e, sourcePath: string) => { if (isRemoteWorkspaceActive()) { - throw new Error("Custom templates are unavailable on remote vaults"); + return await requireRemoteTemplates("Deleting a custom template").deleteTemplate( + sourcePath, + ); } const v = requireVault(); return await deleteCustomTemplate(v.root, sourcePath); diff --git a/apps/desktop/src/main/remote/server-client.test.ts b/apps/desktop/src/main/remote/server-client.test.ts index faa6a7a8..141c3a8f 100644 --- a/apps/desktop/src/main/remote/server-client.test.ts +++ b/apps/desktop/src/main/remote/server-client.test.ts @@ -311,3 +311,93 @@ describe('a 404 for a path this app asked to change (#734)', () => { } }) }) + +describe('custom templates on a remote vault (#723)', () => { + interface TemplateServer { + port: number + close: () => Promise + requests: Array<{ method: string; url: string; body: string }> + } + + async function templateServer(capabilities: Record): Promise { + const requests: TemplateServer['requests'] = [] + const server = http.createServer((req, res) => { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + requests.push({ method: req.method ?? '', url: req.url ?? '', body }) + const send = (status: number, payload: unknown): void => { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(payload)) + } + if (req.url === '/api/capabilities') return send(200, capabilities) + if (req.url === '/api/templates') { + return send(200, [{ sourcePath: '.zennotes/templates/adr.md', raw: '---\nname: ADR\n---\n' }]) + } + if (req.url?.startsWith('/api/templates/read?')) return send(200, { raw: '# raw body' }) + if (req.url === '/api/templates/write') { + const input = JSON.parse(body) as { slug: string; raw: string } + return send(200, { sourcePath: `.zennotes/templates/${input.slug}.md`, raw: input.raw }) + } + if (req.url === '/api/templates/delete') return send(200, { ok: true }) + res.writeHead(404) + res.end('not found') + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + return { port, requests, close: () => new Promise((resolve) => server.close(() => resolve())) } + } + + it('reads the capability flag, and its absence means an older server', async () => { + const supporting = await templateServer({ supportsCustomTemplates: true }) + const older = await templateServer({ supportsWorkflows: true }) + try { + const withRoutes = new RemoteServerClient({ baseUrl: `http://127.0.0.1:${supporting.port}` }) + const without = new RemoteServerClient({ baseUrl: `http://127.0.0.1:${older.port}` }) + expect(await withRoutes.supportsCustomTemplates()).toBe(true) + expect(await without.supportsCustomTemplates()).toBe(false) + } finally { + await supporting.close() + await older.close() + } + }) + + it('drives the four template routes with the bridge contract shapes', async () => { + const server = await templateServer({ supportsCustomTemplates: true }) + try { + const client = new RemoteServerClient({ + baseUrl: `http://127.0.0.1:${server.port}`, + authToken: 'tok' + }) + const listed = await client.listTemplates() + expect(listed).toEqual([{ sourcePath: '.zennotes/templates/adr.md', raw: '---\nname: ADR\n---\n' }]) + + expect(await client.readTemplate('.zennotes/templates/adr.md')).toBe('# raw body') + + const written = await client.writeTemplate({ + slug: 'weekly', + raw: '# weekly', + previousSourcePath: '.zennotes/templates/adr.md' + }) + expect(written).toEqual({ sourcePath: '.zennotes/templates/weekly.md', raw: '# weekly' }) + + await client.deleteTemplate('.zennotes/templates/weekly.md') + + expect(server.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ + 'GET /api/templates', + 'GET /api/templates/read?path=.zennotes%2Ftemplates%2Fadr.md', + 'POST /api/templates/write', + 'POST /api/templates/delete' + ]) + expect(JSON.parse(server.requests[2].body)).toEqual({ + slug: 'weekly', + raw: '# weekly', + previousSourcePath: '.zennotes/templates/adr.md' + }) + expect(JSON.parse(server.requests[3].body)).toEqual({ sourcePath: '.zennotes/templates/weekly.md' }) + } finally { + await server.close() + } + }) +}) diff --git a/apps/desktop/src/main/remote/server-client.ts b/apps/desktop/src/main/remote/server-client.ts index eea39912..0dc33f0e 100644 --- a/apps/desktop/src/main/remote/server-client.ts +++ b/apps/desktop/src/main/remote/server-client.ts @@ -20,6 +20,7 @@ import type { VaultTextSearchToolPaths } from '@shared/ipc' import type { VaultTask } from '@shared/tasks' +import type { CustomTemplateFile, WriteTemplateInput } from '@zennotes/bridge-contract/templates' import WebSocket from 'ws' import { connectionErrorMessage, @@ -172,6 +173,36 @@ export class RemoteServerClient { await this.jsonRequest('/api/workflows/delete', { method: 'POST', body: { sourcePath } }) } + /** True when the connected server advertises the custom-template routes + * from 2.46 (#723). An older server keeps Settings, Templates read-only, + * exactly like the web client against it. */ + async supportsCustomTemplates(): Promise { + const caps = await this.getCapabilities() + return caps?.supportsCustomTemplates === true + } + + async listTemplates(): Promise { + return this.jsonRequest('/api/templates') + } + + async readTemplate(sourcePath: string): Promise { + const result = await this.jsonRequest<{ raw: string }>( + `/api/templates/read?path=${encodeURIComponent(sourcePath)}` + ) + return result.raw + } + + async writeTemplate(input: WriteTemplateInput): Promise { + return this.jsonRequest('/api/templates/write', { + method: 'POST', + body: input as unknown as Record + }) + } + + async deleteTemplate(sourcePath: string): Promise { + await this.jsonRequest('/api/templates/delete', { method: 'POST', body: { sourcePath } }) + } + /** Prepare on this side (reads through the server), apply transactionally on * the server — the same split the web bridge ships for #608. */ async applyWorkflow(input: ApplyWorkflowInput): Promise { diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index de4e608e..47832097 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -53,6 +53,7 @@ import { VaultInfo } from '@shared/ipc' import { DEMO_TOUR_DIR } from '@shared/demo-tour' +import { normalizeNoteComments } from '@shared/note-comments' import { FRONTMATTER_BLOCK_RE, frontmatterTags } from '@shared/frontmatter' import { IMAGE_FILE_EXTENSIONS, pastedImageFilename } from '@shared/pasted-image' import { @@ -3135,59 +3136,6 @@ export async function writeNote(root: string, rel: string, body: string): Promis return await readMeta(root, abs, folder) } -function normalizeNoteComment(input: NoteCommentInput, notePath: string): NoteComment | null { - const body = typeof input.body === 'string' ? input.body.trim() : '' - if (!body) return null - const now = Date.now() - const rawStart = Number.isFinite(input.anchorStart) ? Math.max(0, Math.floor(input.anchorStart)) : 0 - const rawEnd = Number.isFinite(input.anchorEnd) ? Math.max(0, Math.floor(input.anchorEnd)) : rawStart - const anchorStart = Math.min(rawStart, rawEnd) - const anchorEnd = Math.max(rawStart, rawEnd) - const anchorText = - typeof input.anchorText === 'string' - ? input.anchorText.replace(/\s+/g, ' ').trim().slice(0, 500) - : '' - return { - id: typeof input.id === 'string' && input.id.trim() ? input.id.trim() : randomUUID(), - notePath, - anchorStart, - anchorEnd, - anchorText, - body, - createdAt: - typeof input.createdAt === 'number' && Number.isFinite(input.createdAt) - ? input.createdAt - : now, - updatedAt: - typeof input.updatedAt === 'number' && Number.isFinite(input.updatedAt) - ? input.updatedAt - : now, - resolvedAt: - typeof input.resolvedAt === 'number' && Number.isFinite(input.resolvedAt) - ? input.resolvedAt - : null - } -} - -function normalizeNoteComments(raw: unknown, notePath: string): NoteComment[] { - const values = Array.isArray(raw) - ? raw - : raw && typeof raw === 'object' && Array.isArray((raw as { comments?: unknown }).comments) - ? (raw as { comments: unknown[] }).comments - : [] - const seen = new Set() - const comments: NoteComment[] = [] - for (const value of values) { - if (!value || typeof value !== 'object') continue - const comment = normalizeNoteComment(value as NoteCommentInput, notePath) - if (!comment || seen.has(comment.id)) continue - seen.add(comment.id) - comments.push(comment) - } - comments.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)) - return comments -} - export async function readNoteComments(root: string, rel: string): Promise { const notePath = toPosix(rel) const abs = noteCommentsPath(root, notePath) diff --git a/apps/desktop/src/main/watcher.test.ts b/apps/desktop/src/main/watcher.test.ts index 5ffdcd28..f938291b 100644 --- a/apps/desktop/src/main/watcher.test.ts +++ b/apps/desktop/src/main/watcher.test.ts @@ -124,3 +124,35 @@ describe('VaultWatcher atomic saves', () => { 20_000 ) }) + +describe('VaultWatcher custom templates (#723)', () => { + it( + 'announces a template file under its own scope, and ignores what is not a template', + async () => { + const root = await makeVault() + const events: VaultChangeEvent[] = [] + const watcher = new VaultWatcher(() => Promise.resolve(settingsWithArchiveRemap())) + watchers.push(watcher) + watcher.start(root, (ev) => events.push(ev)) + await sleep(400) + + const dir = path.join(root, '.zennotes', 'templates') + await mkdir(dir, { recursive: true }) + await writeFile(path.join(dir, 'adr.md'), '---\nname: ADR\n---\n') + const event = await waitForEvent(events) + expect(event).toEqual({ + kind: 'add', + path: '.zennotes/templates/adr.md', + folder: 'inbox', + scope: 'templates' + }) + + events.length = 0 + await writeFile(path.join(dir, '.draft.md'), 'hidden') + await writeFile(path.join(dir, 'notes.txt'), 'text') + await sleep(600) + expect(events).toEqual([]) + }, + 15_000 + ) +}) diff --git a/apps/desktop/src/main/watcher.ts b/apps/desktop/src/main/watcher.ts index 549f9f01..04b3e5d2 100644 --- a/apps/desktop/src/main/watcher.ts +++ b/apps/desktop/src/main/watcher.ts @@ -9,6 +9,7 @@ const INTERNAL_VAULT_DIR = '.zennotes' const VAULT_SETTINGS_RELATIVE_PATH = `${INTERNAL_VAULT_DIR}/vault.json` const NOTE_COMMENTS_PREFIX = `${INTERNAL_VAULT_DIR}/comments/` const NOTE_COMMENTS_SUFFIX = '.comments.json' +const TEMPLATES_PREFIX = `${INTERNAL_VAULT_DIR}/templates/` function toPosix(p: string): string { return p.split(path.sep).join('/') @@ -36,6 +37,19 @@ function commentsNotePath(root: string, abs: string): string | null { return rel.slice(NOTE_COMMENTS_PREFIX.length, -NOTE_COMMENTS_SUFFIX.length) } +/** A custom template: a `.md` file directly inside `.zennotes/templates/`, + * the flat directory the template module serves. Dotfiles and nested paths + * are not templates there either. Mirrors templatePath in the Go watcher. */ +function templatePath(root: string, abs: string): string | null { + const rel = relativeVaultPath(root, abs) + if (!rel.startsWith(TEMPLATES_PREFIX)) return null + const name = rel.slice(TEMPLATES_PREFIX.length) + if (!name || name.includes('/') || name.startsWith('.') || !name.toLowerCase().endsWith('.md')) { + return null + } + return rel +} + export class VaultWatcher { private watcher: FSWatcher | null = null private root: string | null = null @@ -129,6 +143,14 @@ export class VaultWatcher { ) return } + // A template is not a note: its own scope has the renderer re-list + // templates instead of the note tree (#723). Another window on this + // vault, or a synced dotfile, is how one changes behind the app's back. + const templateSourcePath = templatePath(this.root, absPath) + if (templateSourcePath) { + onEvent({ kind, path: templateSourcePath, folder: 'inbox', scope: 'templates' }) + return + } // Any database file — `.base/data.csv` or `schema.json` (or a legacy // loose `.csv`/sidecar) — normalizes to the canonical `data.csv` path so // the renderer re-hydrates the right database. (Record-page `.md` notes in diff --git a/apps/desktop/src/mcp/comment-ops.test.ts b/apps/desktop/src/mcp/comment-ops.test.ts new file mode 100644 index 00000000..471c1353 --- /dev/null +++ b/apps/desktop/src/mcp/comment-ops.test.ts @@ -0,0 +1,116 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createBackend, type VaultBackend } from '../cli/backend' +import { addComment, anchorForText, listCommentThreads, replyToComment, resolveComment } from './comment-ops' + +// The comment operations behind the MCP tools and `zn comment` (#738), run +// against a real folder through the local backend so the sidecar the app +// reads is exactly what these write. + +let root: string +let backend: VaultBackend +const NOTE = 'inbox/Plan.md' + +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'zennotes-comment-ops-')) + await mkdir(path.join(root, 'inbox'), { recursive: true }) + await writeFile( + path.join(root, 'inbox', 'Plan.md'), + '# Plan\n\nShip the beta in October.\n\nThe migration runs at night.\n' + ) + backend = createBackend({ kind: 'local', root }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('anchorForText', () => { + const doc = 'Alpha beta\nGamma Delta\n' + it('finds the passage exactly, then ignoring case, and refuses what is not there', () => { + expect(anchorForText(doc, 'Gamma')).toEqual({ anchorStart: 11, anchorEnd: 16, anchorText: 'Gamma' }) + expect(anchorForText(doc, 'gamma delta')).toEqual({ anchorStart: 11, anchorEnd: 22, anchorText: 'Gamma Delta' }) + expect(anchorForText(doc, undefined)).toEqual({ anchorStart: 0, anchorEnd: 0, anchorText: '' }) + expect(() => anchorForText(doc, 'omega')).toThrow(/anchor_text was not found/) + }) +}) + +describe('comment threads on a note', () => { + it('adds an anchored comment, answers it in a thread, and resolves it', async () => { + const thread = await addComment(backend, { + path: NOTE, + body: 'Is October still realistic?', + anchorText: 'Ship the beta in October.', + author: undefined + }) + expect(thread.author).toBeNull() + expect(thread.line).toBe(3) + expect(thread.anchorText).toBe('Ship the beta in October.') + expect(thread.replies).toEqual([]) + + const answered = await replyToComment(backend, { + path: NOTE, + id: thread.id, + body: 'Yes: the migration is the only blocker and it runs at night.', + author: 'Claude Code' + }) + expect(answered.id).toBe(thread.id) + expect(answered.replies).toHaveLength(1) + expect(answered.replies[0].author).toBe('Claude Code') + + // A reply to the reply lands in the same thread, one level deep. + const again = await replyToComment(backend, { + path: NOTE, + id: answered.replies[0].id, + body: 'Agreed, keep October.' + }) + expect(again.id).toBe(thread.id) + expect(again.replies.map((r) => r.author)).toEqual(['Claude Code', null]) + + const open = await listCommentThreads(backend, NOTE) + expect(open.map((t) => t.id)).toEqual([thread.id]) + + const done = await resolveComment(backend, { path: NOTE, id: again.replies[1].id }) + expect(done.id).toBe(thread.id) + expect(done.resolved).toBe(true) + expect(await listCommentThreads(backend, NOTE)).toEqual([]) + expect((await listCommentThreads(backend, NOTE, { includeResolved: true }))[0].resolved).toBe(true) + + const reopened = await resolveComment(backend, { path: NOTE, id: thread.id, resolved: false }) + expect(reopened.resolved).toBe(false) + + // The sidecar is where the app reads: same path, same envelope. + const sidecar = JSON.parse( + await readFile(path.join(root, '.zennotes', 'comments', 'inbox', 'Plan.md.comments.json'), 'utf8') + ) as { version: number; comments: Array> } + expect(sidecar.version).toBe(1) + expect(sidecar.comments).toHaveLength(3) + expect(sidecar.comments[1]).toMatchObject({ parentId: thread.id, author: 'Claude Code' }) + expect(sidecar.comments[0]).not.toHaveProperty('author') + }) + + it('reports the line an anchor sits on after the note moved', async () => { + const thread = await addComment(backend, { + path: NOTE, + body: 'Night runs need a rollback plan.', + anchorText: 'The migration runs at night.' + }) + expect(thread.line).toBe(5) + await writeFile( + path.join(root, 'inbox', 'Plan.md'), + '# Plan\n\nA new paragraph first.\n\nShip the beta in October.\n\nThe migration runs at night.\n' + ) + const [moved] = await listCommentThreads(backend, NOTE) + expect(moved.line).toBe(7) + }) + + it('refuses an empty body, an unknown id, and text that is not in the note', async () => { + await expect(addComment(backend, { path: NOTE, body: ' ' })).rejects.toThrow(/empty/) + await expect(addComment(backend, { path: NOTE, body: 'x', anchorText: 'not here' })).rejects.toThrow(/not found/) + await expect(replyToComment(backend, { path: NOTE, id: 'nope', body: 'x' })).rejects.toThrow(/No comment with id/) + await expect(resolveComment(backend, { path: NOTE, id: 'nope' })).rejects.toThrow(/No comment with id/) + expect(await listCommentThreads(backend, NOTE)).toEqual([]) + }) +}) diff --git a/apps/desktop/src/mcp/comment-ops.ts b/apps/desktop/src/mcp/comment-ops.ts new file mode 100644 index 00000000..d56cd81e --- /dev/null +++ b/apps/desktop/src/mcp/comment-ops.ts @@ -0,0 +1,161 @@ +/** + * Comment operations for the MCP tools and `zn comment` (#738), composed + * from a backend's note read and comment read/write so a local folder and a + * ZenNotes server behave the same. The shapes here are what a model sees: + * threads (a top-level comment with its replies), the anchored text and the + * line it sits on today, and who said what. + */ + +import { + lineOfOffset, + resolveCommentAnchor, + threadNoteComments, + threadRootOf +} from '@shared/note-comments' +import type { NoteComment, NoteCommentInput } from '@shared/ipc' +import type { VaultBackend } from '../cli/backend.js' + +export interface CommentView { + id: string + author: string | null + body: string + createdAt: number + updatedAt: number +} + +export interface CommentThreadView extends CommentView { + /** The text the comment was written on, as stored. Empty for a note-level comment. */ + anchorText: string + /** 1-based line the anchor sits on in the note as it is now. */ + line: number + resolved: boolean + resolvedAt: number | null + replies: CommentView[] +} + +function view(comment: NoteComment): CommentView { + return { + id: comment.id, + author: comment.author ?? null, + body: comment.body, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt + } +} + +export async function listCommentThreads( + backend: VaultBackend, + rel: string, + opts: { includeResolved?: boolean } = {} +): Promise { + const [note, comments] = await Promise.all([backend.readNote(rel), backend.listComments(rel)]) + const doc = note.body + return threadNoteComments(comments) + .filter((thread) => opts.includeResolved || thread.comment.resolvedAt == null) + .map((thread) => ({ + ...view(thread.comment), + anchorText: thread.comment.anchorText, + line: lineOfOffset(doc, resolveCommentAnchor(thread.comment, doc).from), + resolved: thread.comment.resolvedAt != null, + resolvedAt: thread.comment.resolvedAt, + replies: thread.replies.map(view) + })) +} + +/** + * Where a new comment attaches. `anchorText` must appear in the note as + * written (an exact match first, then one ignoring case); without it the + * comment is note-level, anchored at the top. + */ +export function anchorForText( + doc: string, + anchorText: string | undefined +): Pick { + const wanted = (anchorText ?? '').trim() + if (!wanted) return { anchorStart: 0, anchorEnd: 0, anchorText: '' } + let at = doc.indexOf(wanted) + if (at < 0) at = doc.toLowerCase().indexOf(wanted.toLowerCase()) + if (at < 0) { + throw new Error( + `anchor_text was not found in the note. Pass the text exactly as it appears (read_note shows it), or omit it for a note-level comment.` + ) + } + return { + anchorStart: at, + anchorEnd: at + wanted.length, + anchorText: doc.slice(at, at + wanted.length).replace(/\s+/g, ' ').trim().slice(0, 500) + } +} + +export async function addComment( + backend: VaultBackend, + input: { path: string; body: string; anchorText?: string; author?: string } +): Promise { + const body = input.body.trim() + if (!body) throw new Error('body must not be empty') + const note = await backend.readNote(input.path) + const anchor = anchorForText(note.body, input.anchorText) + const now = Date.now() + const current = await backend.listComments(input.path) + const draft: NoteCommentInput = { + notePath: input.path, + ...anchor, + body, + author: input.author, + createdAt: now, + updatedAt: now, + resolvedAt: null + } + const written = await backend.writeComments(input.path, [...current, draft]) + const created = written.find((c) => c.createdAt === now && c.body === body) ?? written[written.length - 1] + const threads = await listCommentThreads(backend, input.path, { includeResolved: true }) + return threads.find((t) => t.id === created.id) ?? threads[threads.length - 1] +} + +export async function replyToComment( + backend: VaultBackend, + input: { path: string; id: string; body: string; author?: string } +): Promise { + const body = input.body.trim() + if (!body) throw new Error('body must not be empty') + const current = await backend.listComments(input.path) + const root = threadRootOf(current, input.id) + if (!root) throw new Error(`No comment with id ${input.id} on ${input.path}. Use list_comments to find ids.`) + const now = Date.now() + const draft: NoteCommentInput = { + notePath: input.path, + anchorStart: root.anchorStart, + anchorEnd: root.anchorEnd, + anchorText: root.anchorText, + body, + author: input.author, + parentId: root.id, + createdAt: now, + updatedAt: now, + resolvedAt: null + } + await backend.writeComments(input.path, [...current, draft]) + const threads = await listCommentThreads(backend, input.path, { includeResolved: true }) + const thread = threads.find((t) => t.id === root.id) + if (!thread) throw new Error(`Thread ${root.id} vanished while replying`) + return thread +} + +export async function resolveComment( + backend: VaultBackend, + input: { path: string; id: string; resolved?: boolean } +): Promise { + const current = await backend.listComments(input.path) + const root = threadRootOf(current, input.id) + if (!root) throw new Error(`No comment with id ${input.id} on ${input.path}. Use list_comments to find ids.`) + const resolved = input.resolved ?? true + const now = Date.now() + const next = current.map((c) => + c.id === root.id ? { ...c, resolvedAt: resolved ? now : null, updatedAt: now } : c + ) + await backend.writeComments(input.path, next) + const threads = await listCommentThreads(backend, input.path, { includeResolved: true }) + const thread = threads.find((t) => t.id === root.id) + if (!thread) throw new Error(`Thread ${root.id} vanished while resolving`) + return thread +} diff --git a/apps/desktop/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts index aef9ea1b..aab786c5 100644 --- a/apps/desktop/src/mcp/instructions.ts +++ b/apps/desktop/src/mcp/instructions.ts @@ -213,6 +213,28 @@ when the folder is \`Linear Algebra/\`), synonyms, and feeling tags \`tasks: false\`/\`note\`, excluded folders). Pass includeExcluded: true only when the user asks for everything. +## Comments: reviewing a note together + +Notes carry comment threads, kept beside the note and shown in the app's +Comments panel. A user who asks you to review, answer, or discuss a note +usually means through those threads, like a pull request, not by editing +the body. + +- Start with \`list_comments\` on the note. Each thread shows the passage it + is anchored to, the line it sits on now, who wrote what (\`author\` is + null for the user), and the replies so far. +- Answer a thread with \`reply_to_comment\`; it lands under the user's + comment, signed with your name. Reply to every open thread you were asked + about, one reply per thread, and keep replies short and concrete. +- Raise something new with \`add_comment\`, passing \`anchor_text\` copied + verbatim from the note so the comment highlights that passage in the app. + Omit it only for a note-level remark. +- Change the note body only when the user asks for the change; when a thread + ends in "do it", make the edit with the editing tools, then reply in the + thread saying what changed. +- \`resolve_comment\` only when the user says the thread is settled or asks + you to close it. Resolved threads stay in the note's history. + ## Self-check before every write Scan the markdown before sending it. Fix, don\u2019t ship: diff --git a/apps/desktop/src/mcp/server.test.ts b/apps/desktop/src/mcp/server.test.ts index 9fcfdf6b..3b1708ae 100644 --- a/apps/desktop/src/mcp/server.test.ts +++ b/apps/desktop/src/mcp/server.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { VaultBackend } from '../cli/backend' import { RemoteRequestError } from '../main/remote/connection' -import { callTool, describeToolError, listToolNames } from './server' +import { callTool, commentAuthorForClient, describeToolError, listToolNames } from './server' // Only the members a given test reaches are implemented; the cast keeps the // stubs honest about being partial. @@ -116,7 +116,11 @@ describe('tools run through the backend', () => { 'append_to_note', 'prepend_to_note', 'insert_at_line', - 'replace_in_note' + 'replace_in_note', + 'list_comments', + 'add_comment', + 'reply_to_comment', + 'resolve_comment' ]) }) }) @@ -134,3 +138,55 @@ describe('describeToolError', () => { expect(describeToolError(new RemoteRequestError('nope', 500))).toBe('nope') }) }) + +describe('comment tools (#738)', () => { + it('lists the four comment tools', () => { + const names = listToolNames() + for (const name of ['list_comments', 'add_comment', 'reply_to_comment', 'resolve_comment']) { + expect(names).toContain(name) + } + }) + + it('signs a comment with the connected client, readably', () => { + expect(commentAuthorForClient('claude-code')).toBe('Claude Code') + expect(commentAuthorForClient('claude-ai')).toBe('Claude') + expect(commentAuthorForClient('codex-cli')).toBe('Codex') + expect(commentAuthorForClient('my_custom-agent')).toBe('My Custom Agent') + expect(commentAuthorForClient(null)).toBe('Assistant') + expect(commentAuthorForClient(' ')).toBe('Assistant') + }) + + it('reply_to_comment threads under the top-level comment with the author', async () => { + let stored: Array> = [ + { + id: 'c1', + notePath: 'inbox/Plan.md', + anchorStart: 8, + anchorEnd: 33, + anchorText: 'Ship the beta in October.', + body: 'Still realistic?', + createdAt: 1, + updatedAt: 1, + resolvedAt: null + } + ] + const result = (await callTool( + 'reply_to_comment', + { path: 'inbox/Plan.md', id: 'c1', body: 'Yes, the blocker runs at night.' }, + backend({ + readNote: async () => + ({ path: 'inbox/Plan.md', body: '# Plan\n\nShip the beta in October.\n' }) as never, + listComments: async () => stored as never, + writeComments: async (_rel, comments) => { + stored = comments.map((c, i) => ({ ...c, id: (c as { id?: string }).id ?? `c${i + 1}` })) + return stored as never + } + }) + )) as { id: string; replies: Array<{ author: string | null; body: string }> } + expect(result.id).toBe('c1') + expect(result.replies).toEqual([ + expect.objectContaining({ author: 'Assistant', body: 'Yes, the blocker runs at night.' }) + ]) + expect(stored[1]).toMatchObject({ parentId: 'c1', anchorText: 'Ship the beta in October.' }) + }) +}) diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index c7caef8e..cb67b9c9 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -22,12 +22,45 @@ import { createBackend, type VaultBackend } from '../cli/backend.js' import { resolveDefaultTarget } from '../cli/vault-target.js' import { RemoteRequestError } from '../main/remote/connection.js' import type { NoteFolder } from './vault-ops.js' +import { addComment, listCommentThreads, replyToComment, resolveComment } from './comment-ops.js' interface ToolDef { schema: Tool handler: (args: Record, backend: VaultBackend) => Promise } +/* ---------- Comment authorship ---------------------------------------- */ + +// The MCP client's name from the initialize handshake ("claude-code", +// "claude-ai", "codex-cli"), read as a display name so a comment left by an +// assistant says who left it. Set once the session is initialized; the +// fallback covers direct callTool use and clients that send nothing. +let connectedClientName: string | null = null + +const CLIENT_DISPLAY_NAMES: Record = { + 'claude-ai': 'Claude', + 'claude-code': 'Claude Code', + 'claude-desktop': 'Claude', + 'codex-cli': 'Codex', + codex: 'Codex' +} + +export function commentAuthorForClient(clientName: string | null | undefined): string { + const raw = (clientName ?? '').trim() + if (!raw) return 'Assistant' + const known = CLIENT_DISPLAY_NAMES[raw.toLowerCase()] + if (known) return known + return raw + .split(/[-_\s]+/) + .filter(Boolean) + .map((word) => word[0].toUpperCase() + word.slice(1)) + .join(' ') +} + +function defaultCommentAuthor(): string { + return commentAuthorForClient(connectedClientName) +} + /* ---------- Argument helpers ----------------------------------------- */ function requireString(args: Record, key: string): string { @@ -53,6 +86,13 @@ function requireFolder(args: Record, key: string): NoteFolder { return value } +function optionalBoolean(args: Record, key: string): boolean | undefined { + const value = args[key] + if (value == null) return undefined + if (typeof value !== 'boolean') throw new Error(`${key} must be a boolean`) + return value +} + function optionalNumber(args: Record, key: string): number | undefined { const value = args[key] if (value == null) return undefined @@ -789,6 +829,104 @@ const TOOLS: ToolDef[] = [ const occurrence = (optionalString(args, 'occurrence') as 'first' | 'all' | undefined) ?? 'first' return await backend.replaceInNote(rel, find, replace, occurrence) } + }, + { + schema: { + name: 'list_comments', + description: + 'The comment threads on a note: each top-level comment with the text it is anchored to, the line that text sits on now, who wrote it (author is null for the vault owner), and its replies in order. Read this before reviewing or answering a discussion; unresolved threads only unless include_resolved is true.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Vault-relative note path.' }, + include_resolved: { type: 'boolean', description: 'Also list resolved threads. Default false.' } + }, + required: ['path'] + } + }, + handler: async (args, backend) => + await listCommentThreads(backend, requireString(args, 'path'), { + includeResolved: optionalBoolean(args, 'include_resolved') ?? false + }) + }, + { + schema: { + name: 'add_comment', + description: + 'Start a new comment thread on a note, attributed to you. Pass anchor_text, a passage copied exactly from the note, to attach the comment to it (the app highlights it and jumps there); omit it for a note-level comment. Markdown is fine in the body. Returns the new thread.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Vault-relative note path.' }, + body: { type: 'string', description: 'The comment, in Markdown.' }, + anchor_text: { + type: 'string', + description: 'Text from the note the comment is about, verbatim. Omit for a note-level comment.' + }, + author: { + type: 'string', + description: 'Display name to sign with. Defaults to the connected client (e.g. "Claude Code").' + } + }, + required: ['path', 'body'] + } + }, + handler: async (args, backend) => + await addComment(backend, { + path: requireString(args, 'path'), + body: requireString(args, 'body'), + anchorText: optionalString(args, 'anchor_text'), + author: optionalString(args, 'author') ?? defaultCommentAuthor() + }) + }, + { + schema: { + name: 'reply_to_comment', + description: + 'Answer a comment in its thread, attributed to you. id is a thread id (or any reply id in it) from list_comments; the reply keeps the thread\u2019s anchor. Use this to respond to the user\u2019s comments the way you would on a pull request, instead of editing the note body. Returns the updated thread.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Vault-relative note path.' }, + id: { type: 'string', description: 'A comment id from list_comments.' }, + body: { type: 'string', description: 'The reply, in Markdown.' }, + author: { + type: 'string', + description: 'Display name to sign with. Defaults to the connected client.' + } + }, + required: ['path', 'id', 'body'] + } + }, + handler: async (args, backend) => + await replyToComment(backend, { + path: requireString(args, 'path'), + id: requireString(args, 'id'), + body: requireString(args, 'body'), + author: optionalString(args, 'author') ?? defaultCommentAuthor() + }) + }, + { + schema: { + name: 'resolve_comment', + description: + 'Mark a comment thread resolved (or reopen it with resolved: false). Resolve only when the discussion is settled or the user asks; the thread stays in the note\u2019s history and moves to the Resolved section of the app\u2019s Comments panel.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Vault-relative note path.' }, + id: { type: 'string', description: 'A comment id from list_comments.' }, + resolved: { type: 'boolean', description: 'false reopens the thread. Default true.' } + }, + required: ['path', 'id'] + } + }, + handler: async (args, backend) => + await resolveComment(backend, { + path: requireString(args, 'path'), + id: requireString(args, 'id'), + resolved: optionalBoolean(args, 'resolved') ?? true + }) } ] @@ -853,6 +991,10 @@ export async function runMcpServer(): Promise { } ) + server.oninitialized = () => { + connectedClientName = server.getClientVersion()?.name ?? null + } + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS.map((t) => t.schema) })) diff --git a/apps/desktop/src/mcp/vault-ops.test.ts b/apps/desktop/src/mcp/vault-ops.test.ts index 271d4f1f..15169c2b 100644 --- a/apps/desktop/src/mcp/vault-ops.test.ts +++ b/apps/desktop/src/mcp/vault-ops.test.ts @@ -7,6 +7,7 @@ import { createNote, insertAtLineInBody, listNotes, + readPrimaryNotesLocation, renameNote, replaceInBody, scanAllTasks, @@ -242,3 +243,49 @@ describe('pure body edits shared with the remote backend (#688)', () => { expect(insertAtLineInBody('one', -5, 'top')).toBe('top\none') }) }) + +// The app treats an explicit primaryNotesLocation as the answer and infers +// from the layout only when vault.json leaves it unstated. The CLI and MCP +// used to let the layout outrank the file, so a vault switched to root mode +// whose old notes still sat in inbox/ kept getting new notes filed there +// (#745). The seeded inbox/GitHub note is exactly that leftover. +describe('primary notes location follows vault.json (#745)', () => { + it('files a new note at the root when vault.json says root, old inbox notes or not', async () => { + await mkdir(path.join(root, '.zennotes'), { recursive: true }) + await writeFile( + path.join(root, '.zennotes', 'vault.json'), + JSON.stringify({ primaryNotesLocation: 'root' }) + ) + expect(await readPrimaryNotesLocation(root)).toBe('root') + + const meta = await createNote(root, 'inbox', 'Test', '', 'test') + expect(meta.path).toBe('Test.md') + expect(meta.folder).toBe('inbox') + expect(await readFile(path.join(root, 'Test.md'), 'utf8')).toBe('test') + }) + + it('keeps filing into inbox/ when vault.json says inbox, whatever sits at the root', async () => { + await mkdir(path.join(root, '.zennotes'), { recursive: true }) + await writeFile( + path.join(root, '.zennotes', 'vault.json'), + JSON.stringify({ primaryNotesLocation: 'inbox' }) + ) + await writeFile(path.join(root, 'Loose.md'), '# Loose\n') + expect(await readPrimaryNotesLocation(root)).toBe('inbox') + + const meta = await createNote(root, 'inbox', 'Test') + expect(meta.path).toBe('inbox/Test.md') + }) + + it('infers from the layout only when vault.json leaves the question open', async () => { + // No vault.json and notes only in inbox/: a classic ZenNotes vault. + expect(await readPrimaryNotesLocation(root)).toBe('inbox') + // A loose root note flips the inference to a flat vault. + await writeFile(path.join(root, 'Loose.md'), '# Loose\n') + expect(await readPrimaryNotesLocation(root)).toBe('root') + // A vault.json that is silent about it changes nothing. + await mkdir(path.join(root, '.zennotes'), { recursive: true }) + await writeFile(path.join(root, '.zennotes', 'vault.json'), JSON.stringify({ systemFolderPaths: {} })) + expect(await readPrimaryNotesLocation(root)).toBe('root') + }) +}) diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 2942d5e2..266137ef 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -13,6 +13,13 @@ 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 { + NOTE_COMMENTS_DIR, + NOTE_COMMENTS_SUFFIX, + normalizeNoteComments +} from '@shared/note-comments' +import type { NoteComment, NoteCommentInput } from '@shared/ipc' +export type { NoteComment, NoteCommentInput } import { noteTasksMode, type NoteTasksMode } from '@shared/tasks' import { isPathExcludedFromTasks, @@ -188,61 +195,29 @@ async function countLooseRootContent(root: string, paths: SystemFolderPathsMap): return count } -/** Recursively count .md files under a given directory. Used to see - * whether `/inbox/` actually has content. */ -async function countMdFilesRecursively(dir: string): Promise { - let entries: import('node:fs').Dirent[] - try { - entries = await fs.readdir(dir, { withFileTypes: true }) - } catch { - return 0 - } - let count = 0 - for (const entry of entries) { - if (entry.name.startsWith('.')) continue - const full = path.join(dir, entry.name) - if (entry.isDirectory()) count += await countMdFilesRecursively(full) - else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) count += 1 - } - return count -} - -/** Decide whether this vault uses inbox-mode or root-mode for its - * primary notes area. The vault's on-disk layout is the strongest - * signal — the explicit `vault.json` setting is consulted only when - * the layout is genuinely ambiguous (a fresh, empty vault). - * - * This deliberately ignores `vault.json` when it disagrees with the - * layout so that: +/** Decide whether this vault keeps its primary notes in `inbox/` or at the + * vault root. An explicit `primaryNotesLocation` in vault.json is the + * answer, exactly as it is for the app (`getVaultSettings` in + * main/vault.ts): the layout is consulted only when the file leaves the + * question open, because it is missing, unreadable (a TCC-restricted child + * process), or silent about it. * - * - A user who switched modes in Settings but whose vault hasn't - * been migrated yet still gets notes filed where their existing - * notes live. - * - A user whose `vault.json` was never created (or was deleted / - * restored from a sync) still gets correct behavior. - * - Sandboxed / TCC-restricted child processes that can't read - * `vault.json` still pick the right answer from `readdir` calls - * that succeeded. + * This used to be the other way round, with the layout outranking the file + * so that a vault switched to root mode but not yet migrated kept filing new + * notes next to its old ones in inbox/. That put the CLI and MCP at odds + * with the app on the very same vault: Settings said root, the app created + * notes at the root, and `zn create` and `create_note` kept writing into + * inbox/ while `vault_info` reported inbox (#745). The file wins now, on + * every side of the bridge. */ export async function readPrimaryNotesLocation(root: string): Promise { + const explicit = await readExplicitPrimaryNotesLocation(root) + if (explicit) return explicit + // Loose .md files or user folders at the root mean a flat, Obsidian-style + // vault; anything else defaults to inbox, as a fresh ZenNotes vault does. + // Mirrors inferPrimaryNotesLocation in main/vault.ts. const paths = await readSystemFolderPaths(root) - const [rootContent, inboxNotes, explicit] = await Promise.all([ - countLooseRootContent(root, paths), - countMdFilesRecursively(path.join(root, resolvedFolderDirName('inbox', paths))), - readExplicitPrimaryNotesLocation(root) - ]) - - // Strong layout signal — root has user-organized content (loose - // .md files, custom subfolders). The vault is laid out flat. - if (rootContent >= 1) return 'root' - - // Strong layout signal — only inbox/ has notes, root is empty or - // just system folders. Classic ZenNotes lifecycle layout. - if (inboxNotes >= 1) return 'inbox' - - // Ambiguous (empty vault). Trust the explicit setting if present, - // otherwise default to inbox (matches a fresh ZenNotes install). - return explicit ?? 'inbox' + return (await countLooseRootContent(root, paths)) >= 1 ? 'root' : 'inbox' } /** The absolute directory that holds notes for a given top-level @@ -1925,6 +1900,46 @@ export async function insertAtLine( /* ---------- Backlinks ------------------------------------------------- */ +/* ---------- Note comments (#738) --------------------------------------- */ + +/** The sidecar beside a note: `.zennotes/comments/.comments.json`, the + * same path the desktop and the Go server use, validated against escapes. */ +function noteCommentsPath(root: string, rel: string): string { + const commentsRoot = path.join(root, INTERNAL_VAULT_DIR, NOTE_COMMENTS_DIR) + return resolveSafe(commentsRoot, `${toPosix(rel)}${NOTE_COMMENTS_SUFFIX}`) +} + +export async function readNoteComments(root: string, rel: string): Promise { + const notePath = toPosix(rel) + try { + const raw = await fs.readFile(noteCommentsPath(root, notePath), 'utf8') + return normalizeNoteComments(JSON.parse(raw), notePath) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] + if (err instanceof SyntaxError) return [] + throw err + } +} + +/** Replace a note's comments. An empty list removes the sidecar, as the app + * does, so a note with no comments leaves nothing behind. */ +export async function writeNoteComments( + root: string, + rel: string, + comments: NoteCommentInput[] +): Promise { + const notePath = toPosix(rel) + const normalized = normalizeNoteComments(comments, notePath) + const abs = noteCommentsPath(root, notePath) + if (normalized.length === 0) { + await fs.rm(abs, { force: true }) + return [] + } + await fs.mkdir(path.dirname(abs), { recursive: true }) + await fs.writeFile(abs, JSON.stringify({ version: 1, comments: normalized }, null, 2), 'utf8') + return normalized +} + export async function backlinks(root: string, rel: string): Promise { const abs = resolveSafe(root, rel) const all = await listNotes(root) diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index 0fc1d558..9b4ffafb 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -221,6 +221,11 @@ func (s *Server) registerProtectedRoutes(r chi.Router) { r.Post("/folders/delete", s.deleteFolder) r.Post("/folders/duplicate", s.duplicateFolder) + r.Get("/templates", s.listTemplates) + r.Get("/templates/read", s.readTemplate) + r.Post("/templates/write", s.writeTemplate) + r.Post("/templates/delete", s.deleteTemplate) + r.Get("/search/capabilities", s.searchCapabilities) r.Get("/search/text", s.searchText) @@ -297,7 +302,7 @@ func writeError(w http.ResponseWriter, err error) { http.Error(w, err.Error(), http.StatusBadRequest) return } - if errors.Is(err, vault.ErrInvalidWorkflow) { + if errors.Is(err, vault.ErrInvalidWorkflow) || errors.Is(err, vault.ErrInvalidTemplate) { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -415,6 +420,11 @@ func (s *Server) capabilities(w http.ResponseWriter, _ *http.Request) { // prepared-run endpoint applies them under the same vault lock as note // writes. Its presence lets bundled web clients enable authoring and Run. "supportsWorkflows": true, + // Custom-template CRUD under .zennotes/templates/ (the /templates + // routes), the same files the desktop keeps for a local vault. Absent + // before 2.46: the web client and a desktop on a remote vault hide New + // template and Edit there and say the server needs an update. + "supportsCustomTemplates": true, // Says out loud that a missing file answers 404 rather than 500. // Databases are composed from file reads where "absent" and "failed" // mean opposite things (see remote-absence.ts), and a server that diff --git a/apps/server/internal/httpserver/templates.go b/apps/server/internal/httpserver/templates.go new file mode 100644 index 00000000..3ba65fd4 --- /dev/null +++ b/apps/server/internal/httpserver/templates.go @@ -0,0 +1,77 @@ +package httpserver + +import ( + "errors" + "net/http" + + "github.com/ZenNotes/zennotes/apps/server/internal/vault" +) + +// Custom-template routes: the server half of Settings, Templates for the web +// client and for a desktop connected to a remote vault. Clients gate on the +// supportsCustomTemplates capability, so an older server answers a bare 404 +// here and they say the server needs an update instead. + +const maxTemplateMetadataRequestBytes = 64 << 10 + +func (s *Server) listTemplates(w http.ResponseWriter, _ *http.Request) { + files, err := s.currentVault().ListTemplates() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, files) +} + +func (s *Server) readTemplate(w http.ResponseWriter, r *http.Request) { + raw, err := s.currentVault().ReadTemplate(r.URL.Query().Get("path")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"raw": raw}) +} + +func (s *Server) writeTemplate(w http.ResponseWriter, r *http.Request) { + cfg := s.currentConfig() + r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxNoteBytes+jsonEnvelopeBytes) + var input vault.WriteTemplateInput + if err := readJSON(r, &input); err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + http.Error(w, "template exceeds the configured note size limit", http.StatusRequestEntityTooLarge) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // The envelope allowance above is for field names and JSON escaping, not + // for the template: a body that fits the reader can still unescape to a + // raw string past the note limit, and a template is a note in waiting. + if cfg.MaxNoteBytes > 0 && int64(len(input.Raw)) > cfg.MaxNoteBytes { + http.Error(w, "template exceeds the configured note size limit", http.StatusRequestEntityTooLarge) + return + } + file, err := s.currentVault().WriteTemplate(input) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, file) +} + +func (s *Server) deleteTemplate(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxTemplateMetadataRequestBytes) + var request struct { + SourcePath string `json:"sourcePath"` + } + if err := readJSON(r, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := s.currentVault().DeleteTemplate(request.SourcePath); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/apps/server/internal/httpserver/templates_test.go b/apps/server/internal/httpserver/templates_test.go new file mode 100644 index 00000000..9a6c4266 --- /dev/null +++ b/apps/server/internal/httpserver/templates_test.go @@ -0,0 +1,225 @@ +package httpserver + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ZenNotes/zennotes/apps/server/internal/config" + "github.com/ZenNotes/zennotes/apps/server/internal/vault" +) + +const templateTestToken = "template-token" + +func templateTestServer(t *testing.T, maxNoteBytes int64) (*httptest.Server, string) { + t.Helper() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "inbox", "Note.md"), []byte("# Note\n"), 0o600); err != nil { + t.Fatal(err) + } + server, _ := newTestServer(t, config.Config{ + VaultPath: root, + DefaultVaultPath: root, + Bind: "127.0.0.1:7878", + AuthToken: templateTestToken, + BrowseRoots: []string{root}, + MaxNoteBytes: maxNoteBytes, + }) + return server, root +} + +func templateRequest(t *testing.T, method, url string, body any, token string) *http.Response { + t.Helper() + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, url, reader) + if err != nil { + t.Fatal(err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + return resp +} + +func decodeBody[T any](t *testing.T, resp *http.Response) T { + t.Helper() + defer resp.Body.Close() + var out T + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + return out +} + +func TestTemplateRoutesRequireAuth(t *testing.T) { + server, _ := templateTestServer(t, 10<<20) + for _, route := range []struct{ method, path string }{ + {http.MethodGet, "/api/templates"}, + {http.MethodGet, "/api/templates/read?path=.zennotes/templates/adr.md"}, + {http.MethodPost, "/api/templates/write"}, + {http.MethodPost, "/api/templates/delete"}, + } { + resp := templateRequest(t, route.method, server.URL+route.path, map[string]string{}, "") + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("%s %s without a token: %d, want 401", route.method, route.path, resp.StatusCode) + } + } + resp := templateRequest(t, http.MethodGet, server.URL+"/api/templates", nil, templateTestToken) + if resp.StatusCode != http.StatusOK { + t.Fatalf("list with a token: %d", resp.StatusCode) + } + if files := decodeBody[[]vault.CustomTemplateFile](t, resp); len(files) != 0 { + t.Fatalf("fresh vault lists %+v", files) + } +} + +func TestTemplateRoutesWriteListReadDelete(t *testing.T) { + server, root := templateTestServer(t, 10<<20) + raw := "---\nname: Weekly\n---\n# {{title}}\n" + + resp := templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ + "slug": "Réunion Hebdo!", "raw": raw, + }, templateTestToken) + if resp.StatusCode != http.StatusOK { + t.Fatalf("write: %d", resp.StatusCode) + } + written := decodeBody[vault.CustomTemplateFile](t, resp) + if written.SourcePath != ".zennotes/templates/r-union-hebdo.md" || written.Raw != raw { + t.Fatalf("written = %+v", written) + } + if body, err := os.ReadFile(filepath.Join(root, ".zennotes", "templates", "r-union-hebdo.md")); err != nil || string(body) != raw { + t.Fatalf("file on disk: %q (%v)", body, err) + } + + files := decodeBody[[]vault.CustomTemplateFile](t, templateRequest(t, http.MethodGet, server.URL+"/api/templates", nil, templateTestToken)) + if len(files) != 1 || files[0].SourcePath != written.SourcePath || files[0].Raw != raw { + t.Fatalf("list = %+v", files) + } + + read := decodeBody[map[string]string](t, templateRequest(t, http.MethodGet, server.URL+"/api/templates/read?path="+written.SourcePath, nil, templateTestToken)) + if read["raw"] != raw { + t.Fatalf("read = %+v", read) + } + + renamed := decodeBody[vault.CustomTemplateFile](t, templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ + "slug": "weekly", "raw": raw + "\nmore", "previousSourcePath": written.SourcePath, + }, templateTestToken)) + if renamed.SourcePath != ".zennotes/templates/weekly.md" { + t.Fatalf("renamed = %+v", renamed) + } + if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "r-union-hebdo.md")); !os.IsNotExist(err) { + t.Fatalf("rename left the old file: %v", err) + } + + del := templateRequest(t, http.MethodPost, server.URL+"/api/templates/delete", map[string]string{"sourcePath": renamed.SourcePath}, templateTestToken) + del.Body.Close() + if del.StatusCode != http.StatusOK { + t.Fatalf("delete: %d", del.StatusCode) + } + if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "weekly.md")); !os.IsNotExist(err) { + t.Fatalf("delete left the file: %v", err) + } + gone := templateRequest(t, http.MethodGet, server.URL+"/api/templates/read?path="+renamed.SourcePath, nil, templateTestToken) + gone.Body.Close() + if gone.StatusCode != http.StatusNotFound { + t.Fatalf("read after delete: %d, want 404", gone.StatusCode) + } +} + +func TestTemplateRoutesRejectUnsafePaths(t *testing.T) { + server, root := templateTestServer(t, 10<<20) + for _, path := range []string{ + "../../etc/passwd", + ".zennotes/templates/../../inbox/Note.md", + ".zennotes/templates/sub/dir.md", + ".zennotes/templates/not-markdown.txt", + "inbox/Note.md", + } { + resp := templateRequest(t, http.MethodGet, server.URL+"/api/templates/read?path="+path, nil, templateTestToken) + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("read %q: %d, want 400", path, resp.StatusCode) + } + del := templateRequest(t, http.MethodPost, server.URL+"/api/templates/delete", map[string]string{"sourcePath": path}, templateTestToken) + del.Body.Close() + if del.StatusCode != http.StatusBadRequest { + t.Errorf("delete %q: %d, want 400", path, del.StatusCode) + } + } + if _, err := os.Stat(filepath.Join(root, "inbox", "Note.md")); err != nil { + t.Fatalf("a template delete reached a note: %v", err) + } +} + +func TestTemplateWriteRespectsNoteSizeLimit(t *testing.T) { + server, root := templateTestServer(t, 64) + // Past the note limit but inside the JSON envelope allowance: the explicit + // check answers. + resp := templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ + "slug": "big", "raw": strings.Repeat("x", 200), + }, templateTestToken) + resp.Body.Close() + if resp.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("200-byte template with a 64-byte limit: %d, want 413", resp.StatusCode) + } + // Past the reader itself. + resp = templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ + "slug": "huge", "raw": strings.Repeat("x", 70<<10), + }, templateTestToken) + resp.Body.Close() + if resp.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("70 KiB template with a 64-byte limit: %d, want 413", resp.StatusCode) + } + if entries, _ := os.ReadDir(filepath.Join(root, ".zennotes", "templates")); len(entries) != 0 { + t.Fatalf("rejected writes left files: %v", entries) + } + ok := templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ + "slug": "small", "raw": "fits", + }, templateTestToken) + ok.Body.Close() + if ok.StatusCode != http.StatusOK { + t.Fatalf("small template: %d", ok.StatusCode) + } +} + +func TestCapabilitiesAdvertiseCustomTemplateSupport(t *testing.T) { + root := t.TempDir() + server, _ := newTestServer(t, config.Config{ + VaultPath: root, + DefaultVaultPath: root, + Bind: "127.0.0.1:7878", + BrowseRoots: []string{root}, + }) + resp, err := http.Get(server.URL + "/api/capabilities") + if err != nil { + t.Fatal(err) + } + caps := decodeBody[map[string]any](t, resp) + if caps["supportsCustomTemplates"] != true { + t.Fatalf("supportsCustomTemplates = %v, want true", caps["supportsCustomTemplates"]) + } +} diff --git a/apps/server/internal/vault/templates.go b/apps/server/internal/vault/templates.go new file mode 100644 index 00000000..0cf9c752 --- /dev/null +++ b/apps/server/internal/vault/templates.go @@ -0,0 +1,229 @@ +package vault + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Custom-template file I/O for a vault served over HTTP. Templates are plain +// `.md` files in the flat `.zennotes/templates/` directory, and this layer is +// deliberately parse-free: it moves raw bytes, and the client owns the +// frontmatter format (`packages/shared-domain/src/template-files.ts`). +// +// SYNCED COPY: the filename rules here (safeTemplateSlug, uniqueTemplateSlug, +// resolveTemplatePath) mirror `apps/desktop/src/main/templates.ts` byte for +// byte. A vault served remotely today is opened locally tomorrow, and a +// template's id is `custom:`, so both sides must land the same +// bytes on the same filename. Change one, change both. + +const templatesRelDir = ".zennotes/templates" + +var ErrInvalidTemplate = errors.New("invalid template request") + +// CustomTemplateFile matches bridge-contract's CustomTemplateFile. +type CustomTemplateFile struct { + SourcePath string `json:"sourcePath"` + Raw string `json:"raw"` +} + +// WriteTemplateInput matches bridge-contract's WriteTemplateInput. +type WriteTemplateInput struct { + Slug string `json:"slug"` + Raw string `json:"raw"` + PreviousSourcePath string `json:"previousSourcePath,omitempty"` +} + +func templateDir(root string) string { + return filepath.Join(root, ".zennotes", "templates") +} + +func templateSourcePath(name string) string { + return templatesRelDir + "/" + name +} + +func templateFilenameStem(sourcePath string) string { + name := sourcePath[strings.LastIndex(sourcePath, "/")+1:] + if strings.EqualFold(filepath.Ext(name), ".md") { + return name[:len(name)-len(".md")] + } + return name +} + +// safeTemplateSlug keeps lowercase letters, digits and dashes; every run of +// anything else becomes one dash, and leading and trailing dashes go. Dashes +// that were already there stay as typed (`a--b` remains `a--b`): that is what +// the desktop does, and the renderer's slugifyTemplateName has collapsed them +// before the request is made anyway. +func safeTemplateSlug(slug string) string { + var out strings.Builder + inRun := false + for _, r := range strings.ToLower(slug) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + if inRun { + out.WriteByte('-') + inRun = false + } + out.WriteRune(r) + continue + } + inRun = true + } + if inRun { + out.WriteByte('-') + } + cleaned := strings.Trim(out.String(), "-") + if cleaned == "" { + return "template" + } + return cleaned +} + +// resolveTemplatePath turns a vault-relative sourcePath into an absolute one, +// refusing anything outside the flat templates directory (no traversal, no +// subdirectories, no symlinked escape) and anything that is not a `.md` file. +func (v *Vault) resolveTemplatePath(sourcePath string) (string, error) { + abs, err := SafeJoin(v.root, sourcePath) + if err != nil { + return "", err + } + rel, err := filepath.Rel(templateDir(v.root), abs) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { + return "", fmt.Errorf("%w: refusing template path outside templates dir: %s", ErrInvalidTemplate, sourcePath) + } + if !strings.EqualFold(filepath.Ext(rel), ".md") { + return "", fmt.Errorf("%w: template path must be a .md file: %s", ErrInvalidTemplate, sourcePath) + } + return abs, nil +} + +// uniqueTemplateSlug picks a free slug. Editing the same file keeps its slug +// (the write lands in place); otherwise the slug is de-duplicated against the +// files already there (adr, adr-2, adr-3, ...). +func uniqueTemplateSlug(dir, base, previousSourcePath string) string { + prevStem := "" + if previousSourcePath != "" { + prevStem = templateFilenameStem(previousSourcePath) + } + candidate := base + for n := 2; ; n++ { + if candidate == prevStem { + return candidate + } + if _, err := os.Lstat(filepath.Join(dir, candidate+".md")); errors.Is(err, os.ErrNotExist) { + return candidate + } + candidate = fmt.Sprintf("%s-%d", base, n) + } +} + +// ListTemplates returns every custom template with its raw bytes. A vault +// without a templates directory has no templates rather than an error, and +// an unreadable file is skipped, as the desktop does. +func (v *Vault) ListTemplates() ([]CustomTemplateFile, error) { + v.mu.RLock() + defer v.mu.RUnlock() + entries, err := os.ReadDir(templateDir(v.root)) + if errors.Is(err, os.ErrNotExist) { + return []CustomTemplateFile{}, nil + } + if err != nil { + return nil, err + } + out := make([]CustomTemplateFile, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".md") { + continue + } + sourcePath := templateSourcePath(name) + abs, err := v.resolveTemplatePath(sourcePath) + if err != nil { + continue + } + raw, err := os.ReadFile(abs) + if err != nil { + continue + } + out = append(out, CustomTemplateFile{SourcePath: sourcePath, Raw: string(raw)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].SourcePath < out[j].SourcePath }) + return out, nil +} + +func (v *Vault) ReadTemplate(sourcePath string) (string, error) { + v.mu.RLock() + defer v.mu.RUnlock() + abs, err := v.resolveTemplatePath(sourcePath) + if err != nil { + return "", err + } + raw, err := os.ReadFile(abs) + if err != nil { + return "", err + } + return string(raw), nil +} + +// WriteTemplate saves a template under a slug derived from the request, and +// removes the file it replaces when an edit changed the slug. The previous +// path is validated before anything is written, so a bad one cannot leave a +// stray new file behind. +func (v *Vault) WriteTemplate(input WriteTemplateInput) (CustomTemplateFile, error) { + v.mu.Lock() + defer v.mu.Unlock() + var previous string + if input.PreviousSourcePath != "" { + abs, err := v.resolveTemplatePath(input.PreviousSourcePath) + if err != nil { + return CustomTemplateFile{}, err + } + previous = abs + } + dir := templateDir(v.root) + slug := uniqueTemplateSlug(dir, safeTemplateSlug(input.Slug), input.PreviousSourcePath) + sourcePath := templateSourcePath(slug + ".md") + abs, err := v.resolveTemplatePath(sourcePath) + if err != nil { + return CustomTemplateFile{}, err + } + if err := writeFileAtomic(abs, []byte(input.Raw), v.fileMode, v.dirMode); err != nil { + return CustomTemplateFile{}, err + } + if previous != "" && previous != abs { + // On a case-insensitive filesystem two differently-cased paths can name + // the SAME file, and writeFileAtomic just landed the new content on it; + // a spelling compare would then delete the template that was just + // saved. Compare file identity, not path strings. + sameFile := false + if prevInfo, statErr := os.Stat(previous); statErr == nil { + if newInfo, statErr := os.Stat(abs); statErr == nil && os.SameFile(prevInfo, newInfo) { + sameFile = true + } + } + if !sameFile { + if err := os.Remove(previous); err != nil && !errors.Is(err, os.ErrNotExist) { + return CustomTemplateFile{}, err + } + } + } + return CustomTemplateFile{SourcePath: sourcePath, Raw: input.Raw}, nil +} + +// DeleteTemplate removes a template; a file that is already gone is a +// success, as it is on the desktop. +func (v *Vault) DeleteTemplate(sourcePath string) error { + v.mu.Lock() + defer v.mu.Unlock() + abs, err := v.resolveTemplatePath(sourcePath) + if err != nil { + return err + } + if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} diff --git a/apps/server/internal/vault/templates_test.go b/apps/server/internal/vault/templates_test.go new file mode 100644 index 00000000..d6bff8af --- /dev/null +++ b/apps/server/internal/vault/templates_test.go @@ -0,0 +1,227 @@ +package vault + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func templateTestVault(t *testing.T) (*Vault, string) { + t.Helper() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { + t.Fatal(err) + } + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + return v, root +} + +// The slug rules are a synced copy of the desktop module; these cases are the +// desktop's behaviour, spelled out so a drift on either side fails here. +func TestSafeTemplateSlugMirrorsDesktop(t *testing.T) { + cases := map[string]string{ + "Réunion Hebdo!": "r-union-hebdo", + " ADR ": "adr", + "a--b": "a--b", + "a - b": "a---b", + "Weekly Review 2026": "weekly-review-2026", + "UPPER_case.name": "upper-case-name", + "": "template", + "---": "template", + "!!!": "template", + } + for input, want := range cases { + if got := safeTemplateSlug(input); got != want { + t.Errorf("safeTemplateSlug(%q) = %q, want %q", input, got, want) + } + } + stems := map[string]string{ + ".zennotes/templates/adr.md": "adr", + ".zennotes/templates/adr.MD": "adr", + ".zennotes/templates/x.y.md": "x.y", + "adr": "adr", + } + for input, want := range stems { + if got := templateFilenameStem(input); got != want { + t.Errorf("templateFilenameStem(%q) = %q, want %q", input, got, want) + } + } +} + +func TestWriteTemplateDedupesAndKeepsSlugOnEdit(t *testing.T) { + v, root := templateTestVault(t) + + first, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1"}) + if err != nil { + t.Fatal(err) + } + if first.SourcePath != ".zennotes/templates/adr.md" { + t.Fatalf("first sourcePath = %q", first.SourcePath) + } + second, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v2"}) + if err != nil { + t.Fatal(err) + } + if second.SourcePath != ".zennotes/templates/adr-2.md" { + t.Fatalf("duplicate slug landed on %q, want adr-2.md", second.SourcePath) + } + + edited, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1 edited", PreviousSourcePath: first.SourcePath}) + if err != nil { + t.Fatal(err) + } + if edited.SourcePath != first.SourcePath { + t.Fatalf("editing in place moved the file to %q", edited.SourcePath) + } + body, err := os.ReadFile(filepath.Join(root, ".zennotes", "templates", "adr.md")) + if err != nil || string(body) != "v1 edited" { + t.Fatalf("edit did not land: %q (%v)", body, err) + } + if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "adr-3.md")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("editing in place must not create adr-3.md: %v", err) + } + + files, err := v.ListTemplates() + if err != nil { + t.Fatal(err) + } + if len(files) != 2 || files[0].SourcePath != ".zennotes/templates/adr-2.md" || files[1].SourcePath != ".zennotes/templates/adr.md" { + t.Fatalf("list = %+v", files) + } + entries, _ := os.ReadDir(filepath.Join(root, ".zennotes", "templates")) + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".tmp") { + t.Fatalf("atomic write left its scratch file behind: %s", entry.Name()) + } + } +} + +func TestWriteTemplateRenameRemovesPrevious(t *testing.T) { + v, root := templateTestVault(t) + if _, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1"}); err != nil { + t.Fatal(err) + } + renamed, err := v.WriteTemplate(WriteTemplateInput{Slug: "Decision Record", Raw: "v2", PreviousSourcePath: ".zennotes/templates/adr.md"}) + if err != nil { + t.Fatal(err) + } + if renamed.SourcePath != ".zennotes/templates/decision-record.md" { + t.Fatalf("renamed sourcePath = %q", renamed.SourcePath) + } + if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "adr.md")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("previous file should be gone after the rename: %v", err) + } + raw, err := v.ReadTemplate(renamed.SourcePath) + if err != nil || raw != "v2" { + t.Fatalf("read after rename = %q (%v)", raw, err) + } + // Deleting twice is fine: the desktop's rm --force semantics. + if err := v.DeleteTemplate(renamed.SourcePath); err != nil { + t.Fatal(err) + } + if err := v.DeleteTemplate(renamed.SourcePath); err != nil { + t.Fatalf("second delete should be a no-op, got %v", err) + } + if _, err := v.ReadTemplate(renamed.SourcePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("read after delete = %v, want ErrNotExist", err) + } +} + +func TestTemplatePathsMustStayInsideTemplatesDir(t *testing.T) { + v, root := templateTestVault(t) + for _, bad := range []string{ + "../../etc/passwd", + "/etc/passwd", + ".zennotes/templates/../../inbox/A.md", + ".zennotes/templates/sub/dir.md", + ".zennotes/templates/not-markdown.txt", + ".zennotes/templates", + "inbox/A.md", + "", + } { + if _, err := v.ReadTemplate(bad); !errors.Is(err, ErrInvalidTemplate) && !errors.Is(err, ErrPathEscape) { + t.Errorf("ReadTemplate(%q) = %v, want an invalid-path error", bad, err) + } + if err := v.DeleteTemplate(bad); !errors.Is(err, ErrInvalidTemplate) && !errors.Is(err, ErrPathEscape) { + t.Errorf("DeleteTemplate(%q) = %v, want an invalid-path error", bad, err) + } + } + // A template delete can never reach a note. + if _, err := os.Stat(filepath.Join(root, "inbox", "A.md")); err != nil { + t.Fatalf("note went missing: %v", err) + } + // A bad previous path fails before anything is written. + if _, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1", PreviousSourcePath: "inbox/A.md"}); !errors.Is(err, ErrInvalidTemplate) { + t.Fatalf("write with a note as previous = %v, want ErrInvalidTemplate", err) + } + if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "adr.md")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rejected write left a file behind: %v", err) + } +} + +func TestTemplatesRejectSymlinkedTemplatesDir(t *testing.T) { + v, root := templateTestVault(t) + external := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".zennotes"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(root, ".zennotes", "templates")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1"}); !errors.Is(err, ErrPathEscape) { + t.Fatalf("write through a symlinked templates dir = %v, want ErrPathEscape", err) + } + if entries, _ := os.ReadDir(external); len(entries) != 0 { + t.Fatalf("write escaped into %s: %v", external, entries) + } + if err := os.WriteFile(filepath.Join(external, "leak.md"), []byte("outside"), 0o600); err != nil { + t.Fatal(err) + } + files, err := v.ListTemplates() + if err != nil { + t.Fatal(err) + } + if len(files) != 0 { + t.Fatalf("list followed the symlink: %+v", files) + } +} + +func TestListTemplatesSkipsDotfilesDirsAndNonMarkdown(t *testing.T) { + v, root := templateTestVault(t) + dir := filepath.Join(root, ".zennotes", "templates") + if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o700); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "adr.md": "adr", + "Weekly.MD": "weekly", + ".draft.md": "hidden", + "notes.txt": "text", + "nested/x.md": "nested", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + files, err := v.ListTemplates() + if err != nil { + t.Fatal(err) + } + if len(files) != 2 || files[0].SourcePath != ".zennotes/templates/Weekly.MD" || files[0].Raw != "weekly" || files[1].SourcePath != ".zennotes/templates/adr.md" { + t.Fatalf("list = %+v", files) + } + empty, root2 := templateTestVault(t) + _ = root2 + files, err = empty.ListTemplates() + if err != nil || len(files) != 0 { + t.Fatalf("vault without a templates dir: %+v, %v", files, err) + } +} diff --git a/apps/server/internal/vault/types.go b/apps/server/internal/vault/types.go index 0c1492f7..79bfde3c 100644 --- a/apps/server/internal/vault/types.go +++ b/apps/server/internal/vault/types.go @@ -352,6 +352,11 @@ type NoteComment struct { CreatedAt int64 `json:"createdAt"` UpdatedAt int64 `json:"updatedAt"` ResolvedAt *int64 `json:"resolvedAt"` + // Author is who wrote it: empty for the vault's owner, an assistant's + // name otherwise. ParentID threads a reply under a top-level comment. + // Both mirror shared-domain/note-comments.ts (#738). + Author string `json:"author,omitempty"` + ParentID string `json:"parentId,omitempty"` } // FolderEntry — mirrors shared/ipc.ts FolderEntry. diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go index f13bf06e..35c98fa0 100644 --- a/apps/server/internal/vault/vault.go +++ b/apps/server/internal/vault/vault.go @@ -1628,9 +1628,21 @@ func normalizeComment(input NoteComment, notePath string) (NoteComment, bool) { CreatedAt: createdAt, UpdatedAt: updatedAt, ResolvedAt: input.ResolvedAt, + Author: normalizeCommentAuthor(input.Author), + ParentID: strings.TrimSpace(input.ParentID), }, true } +// normalizeCommentAuthor collapses whitespace and caps the name, mirroring +// normalizeCommentAuthor in shared-domain/note-comments.ts. +func normalizeCommentAuthor(raw string) string { + author := strings.Join(strings.Fields(raw), " ") + if len(author) > 80 { + author = author[:80] + } + return author +} + func normalizeComments(inputs []NoteComment, notePath string) []NoteComment { out := make([]NoteComment, 0, len(inputs)) seen := map[string]struct{}{} @@ -1651,6 +1663,20 @@ func normalizeComments(inputs []NoteComment, notePath string) []NoteComment { } return out[i].CreatedAt < out[j].CreatedAt }) + // A reply whose parent is gone (or is itself) stays as a comment of its + // own rather than vanishing from the thread view. + ids := make(map[string]struct{}, len(out)) + for _, comment := range out { + ids[comment.ID] = struct{}{} + } + for i := range out { + if out[i].ParentID == "" { + continue + } + if _, ok := ids[out[i].ParentID]; !ok || out[i].ParentID == out[i].ID { + out[i].ParentID = "" + } + } return out } diff --git a/apps/server/internal/vault/vault_test.go b/apps/server/internal/vault/vault_test.go index 12193675..0dc1e7cf 100644 --- a/apps/server/internal/vault/vault_test.go +++ b/apps/server/internal/vault/vault_test.go @@ -1289,3 +1289,39 @@ func TestHarperSettingsRoundTripAndNormalize(t *testing.T) { t.Errorf("empty harper block should be dropped, got %+v", cleared.Harper) } } + +func TestNoteCommentsKeepAuthorAndThreadReplies(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + meta, err := v.WriteNote("inbox/Reviewed.md", "line one\nline two\nline three") + if err != nil { + t.Fatalf("write note: %v", err) + } + written, err := v.WriteNoteComments(meta.Path, []NoteComment{ + {ID: "c1", Body: "Is this right?", CreatedAt: 1, UpdatedAt: 1}, + {ID: "c2", Body: "Yes, see line 3.", CreatedAt: 2, UpdatedAt: 2, Author: " Claude Code ", ParentID: " c1 "}, + {ID: "c3", Body: "orphan", CreatedAt: 3, UpdatedAt: 3, ParentID: "missing"}, + }) + if err != nil { + t.Fatalf("write comments: %v", err) + } + if len(written) != 3 { + t.Fatalf("expected 3 comments, got %d", len(written)) + } + read, err := v.ReadNoteComments(meta.Path) + if err != nil { + t.Fatalf("read comments: %v", err) + } + if read[1].Author != "Claude Code" || read[1].ParentID != "c1" { + t.Fatalf("reply lost its author or parent: %#v", read[1]) + } + if read[0].Author != "" || read[0].ParentID != "" { + t.Fatalf("top-level comment gained fields: %#v", read[0]) + } + if read[2].ParentID != "" { + t.Fatalf("orphan reply kept a missing parent: %#v", read[2]) + } +} diff --git a/apps/server/internal/watcher/watcher.go b/apps/server/internal/watcher/watcher.go index 4b4ed1e2..64c56371 100644 --- a/apps/server/internal/watcher/watcher.go +++ b/apps/server/internal/watcher/watcher.go @@ -17,6 +17,7 @@ const ( vaultSettingsFilePath = ".zennotes/vault.json" noteCommentsPrefix = ".zennotes/comments/" noteCommentsSuffix = ".comments.json" + templatesPrefix = ".zennotes/templates/" ) // Watcher recursively watches the vault root and fans out change @@ -242,6 +243,47 @@ func (w *Watcher) commentsNotePath(absPath string) (string, bool) { return strings.TrimSuffix(strings.TrimPrefix(rel, noteCommentsPrefix), noteCommentsSuffix), true } +// templatePath reports whether the path is a custom template: a `.md` file +// directly inside .zennotes/templates/, the flat directory the template +// routes serve. Dotfiles and nested paths are not templates there either. +func (w *Watcher) templatePath(absPath string) (string, bool) { + rel := w.relativePath(absPath) + if !strings.HasPrefix(rel, templatesPrefix) { + return "", false + } + name := strings.TrimPrefix(rel, templatesPrefix) + if name == "" || strings.Contains(name, "/") || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".md") { + return "", false + } + return rel, true +} + +// watchSubdirs adds the directories already inside a directory that just +// appeared. A tree that arrives in one go (mkdir -p, or a template write +// creating .zennotes/templates/ in a vault that had no .zennotes/ yet) raises +// one Create for the top; its children were created before that watch +// existed, so without this walk they would stay unwatched until a restart. +func (w *Watcher) watchSubdirs(dir string) { + _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || path == dir || !d.IsDir() { + return nil + } + name := d.Name() + if strings.HasPrefix(name, ".") && name != internalVaultDir { + return filepath.SkipDir + } + if _, ok := w.dirs[path]; ok { + return nil + } + if addErr := w.fs.Add(path); addErr != nil { + log.Printf("watcher: cannot watch new directory %s: %v", path, addErr) + } + w.dirs[path] = struct{}{} + w.broadcastFolder(path, "add") + return nil + }) +} + func (w *Watcher) handle(ev fsnotify.Event) { base := filepath.Base(ev.Name) // The scratch file every atomic write renames from. Its create/write/rename @@ -263,6 +305,7 @@ func (w *Watcher) handle(ev fsnotify.Event) { // An empty folder produces no note event, so clients would never // learn about it until a manual refresh. Surface it explicitly. w.broadcastFolder(ev.Name, "add") + w.watchSubdirs(ev.Name) } return } @@ -310,6 +353,21 @@ func (w *Watcher) handle(ev fsnotify.Event) { }) return } + if templatePath, ok := w.templatePath(ev.Name); ok { + kind := eventKind(ev, statErr == nil) + if kind == "" { + return + } + // A template is not a note: its own scope keeps clients from + // re-listing the note tree and rescanning tasks for every save. + w.broadcast(vault.ChangeEvent{ + Kind: kind, + Path: templatePath, + Folder: vault.FolderInbox, + Scope: "templates", + }) + return + } if strings.HasPrefix(relPosix, ".") || strings.Contains(relPosix, "/.") { return } diff --git a/apps/server/internal/watcher/watcher_test.go b/apps/server/internal/watcher/watcher_test.go index 7f9f415f..03ecd1ef 100644 --- a/apps/server/internal/watcher/watcher_test.go +++ b/apps/server/internal/watcher/watcher_test.go @@ -315,3 +315,80 @@ func TestWatcherDoesNotReportAReplacedNoteAsDeleted(t *testing.T) { t.Fatalf("deleted note event = %+v, want unlink", ev) } } + +func TestWatcherSurfacesTemplateChangesWithOwnScope(t *testing.T) { + root := t.TempDir() + w := newTestWatcher(t, root) + ch, unsub := w.Subscribe() + defer unsub() + + dir := filepath.Join(root, internalVaultDir, "templates") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, "adr.md") + if err := os.WriteFile(file, []byte("---\nname: ADR\n---\n"), 0o600); err != nil { + t.Fatal(err) + } + w.handle(fsnotify.Event{Name: file, Op: fsnotify.Write}) + ev := recvChange(t, ch) + if ev.Scope != "templates" || ev.Kind != "change" || ev.Path != ".zennotes/templates/adr.md" { + t.Fatalf("template write event = %+v", ev) + } + + if err := os.Remove(file); err != nil { + t.Fatal(err) + } + w.handle(fsnotify.Event{Name: file, Op: fsnotify.Remove}) + ev = recvChange(t, ch) + if ev.Scope != "templates" || ev.Kind != "unlink" { + t.Fatalf("template remove event = %+v", ev) + } +} + +func TestWatcherIgnoresNonTemplatesUnderTemplatesDir(t *testing.T) { + root := t.TempDir() + w := newTestWatcher(t, root) + ch, unsub := w.Subscribe() + defer unsub() + + dir := filepath.Join(root, internalVaultDir, "templates") + if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o700); err != nil { + t.Fatal(err) + } + for _, name := range []string{".draft.md", "notes.txt", filepath.Join("nested", "x.md")} { + file := filepath.Join(dir, name) + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + w.handle(fsnotify.Event{Name: file, Op: fsnotify.Write}) + } + select { + case ev := <-ch: + t.Fatalf("unexpected event for a non-template: %+v", ev) + case <-time.After(100 * time.Millisecond): + // Expected: dotfiles, other extensions and nested paths are not templates. + } +} + +func TestWatcherWatchesDirectoriesCreatedWithTheirParent(t *testing.T) { + root := t.TempDir() + w := newTestWatcher(t, root) + _, unsub := w.Subscribe() + defer unsub() + + // .zennotes/ and .zennotes/templates/ arrive together (one MkdirAll); the + // watcher hears one Create for the parent. + internal := filepath.Join(root, internalVaultDir) + templates := filepath.Join(internal, "templates") + if err := os.MkdirAll(templates, 0o700); err != nil { + t.Fatal(err) + } + w.handle(fsnotify.Event{Name: internal, Op: fsnotify.Create}) + if _, ok := w.dirs[internal]; !ok { + t.Fatalf("parent directory not tracked") + } + if _, ok := w.dirs[templates]; !ok { + t.Fatalf("child directory created with its parent is not watched") + } +} diff --git a/apps/server/package.json b/apps/server/package.json index 0ee99611..e7b2caea 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.45.0", + "version": "2.46.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 8a975cac..50ae4206 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.45.0", + "version": "2.46.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index d6ff36f7..0d6e3410 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -814,23 +814,48 @@ async function deleteWorkflowRuns(workflowId: string): Promise { }) } -// Custom templates require local-filesystem CRUD, which the web app does not -// have (supportsCustomTemplates is false). Built-in templates still work since -// they are renderer constants. List is empty; mutations are rejected. -function listTemplates(): Promise { - return Promise.resolve([]) +// Custom templates live in the vault's .zennotes/templates/, the same files +// the desktop keeps for a local vault, served by the /templates routes since +// server 2.46 (#723). An older server has none: the list is empty, Settings, +// Templates stays read-only, and the built-in templates (renderer constants) +// keep working. The request gate and the UI gate (getCapabilities below) +// read the same cached fact. +async function serverSupportsCustomTemplates(): Promise { + const capabilities = lastServerCapabilities ?? (await getServerCapabilities()) + return capabilities?.supportsCustomTemplates === true +} + +async function requireServerTemplateSupport(): Promise { + if (await serverSupportsCustomTemplates()) return + throw new Error( + 'Custom templates need ZenNotes server 2.46 or later. Update the server and reload.' + ) } -function readTemplate(_sourcePath: string): Promise { - return Promise.reject(new Error('Custom templates are unavailable on the web')) +async function listTemplates(): Promise { + if (!(await serverSupportsCustomTemplates())) return [] + return jsonRequest('/templates') } -function writeTemplate(_input: WriteTemplateInput): Promise { - return Promise.reject(new Error('Custom templates are unavailable on the web')) +async function readTemplate(sourcePath: string): Promise { + await requireServerTemplateSupport() + const result = await jsonRequest<{ raw: string }>( + `/templates/read?path=${encodeURIComponent(sourcePath)}` + ) + return result.raw +} + +async function writeTemplate(input: WriteTemplateInput): Promise { + await requireServerTemplateSupport() + return jsonRequest('/templates/write', { + method: 'POST', + body: input as unknown as Record + }) } -function deleteTemplate(_sourcePath: string): Promise { - return Promise.reject(new Error('Custom templates are unavailable on the web')) +async function deleteTemplate(sourcePath: string): Promise { + await requireServerTemplateSupport() + await jsonRequest('/templates/delete', { method: 'POST', body: { sourcePath } }) } // -------------------------------------------------------------------- @@ -1426,13 +1451,15 @@ function clipboardReadText(): string { // -------------------------------------------------------------------- export const httpBridge: ZenBridge = { - // Workflows are the one capability the SERVER decides; derive it from the - // cached /capabilities response instead of mutating the const in place, so - // the UI gate (this) and the request gate (serverSupportsWorkflows) can - // never disagree about the same fact. + // Workflows and custom templates are the capabilities the SERVER decides; + // derive them from the cached /capabilities response instead of mutating + // the const in place, so the UI gate (this) and the request gates + // (serverSupportsWorkflows, serverSupportsCustomTemplates) can never + // disagree about the same fact. getCapabilities: (): ZenCapabilities => ({ ...WEB_CAPABILITIES, - supportsWorkflows: lastServerCapabilities?.supportsWorkflows === true + supportsWorkflows: lastServerCapabilities?.supportsWorkflows === true, + supportsCustomTemplates: lastServerCapabilities?.supportsCustomTemplates === true }), getAppInfo: (): ZenAppInfo => WEB_APP_INFO, platform, diff --git a/docs/how-to/self-host-with-docker.md b/docs/how-to/self-host-with-docker.md index 560d4e94..070e6ede 100644 --- a/docs/how-to/self-host-with-docker.md +++ b/docs/how-to/self-host-with-docker.md @@ -248,7 +248,8 @@ or via the orchestrator of your choice. - `ZENNOTES_BROWSE_ROOTS` — directories the server may consider as vault candidates. Anything outside is rejected. - `ZENNOTES_MAX_NOTE_BYTES` / `ZENNOTES_MAX_ASSET_BYTES` — per-request - byte caps for `/api/notes/write` and `/api/assets/upload`. Defaults + byte caps for `/api/notes/write` (and the workflow and template writes, + which are notes in waiting) and `/api/assets/upload`. Defaults 10 MiB and 50 MiB. - `ZENNOTES_VAULT_FILE_MODE` / `ZENNOTES_VAULT_DIR_MODE` — octal mode for new files / directories. Defaults `0600` and `0700`. diff --git a/docs/releases/v2.46.0/RELEASE_NOTES.md b/docs/releases/v2.46.0/RELEASE_NOTES.md new file mode 100644 index 00000000..90e92477 --- /dev/null +++ b/docs/releases/v2.46.0/RELEASE_NOTES.md @@ -0,0 +1,72 @@ +ZenNotes 2.46.0: comment threads your assistant can join, a Kanban board per folder, unbind a shortcut outright, ignored keys for tap-hold remappers, saved Tasks filters, custom templates on remote vaults, and a calendar in the @ menu + +> Seven features and three fixes. Home-row-mods users can list the no-op key their remapper sends, and the app stops seeing it. The Kanban Folder board now groups by each note's folder, or by the children of one folder you name. Note comments thread and carry a name, and the MCP server can read, answer, open and resolve them, so a draft can be reviewed with Claude or Codex inside the Comments panel instead of a chat window. A shortcut can be removed instead of remapped onto some chord you would never press: every keymap row has an Unbind button, `:unbind action.id` does the same from the ex line, and `"action.id" = ""` under `[keymaps]` in config.toml carries it between machines. And custom templates now work in the self-hosted web client and on a desktop connected to a ZenNotes server, stored in the vault's `.zennotes/templates/` like everywhere else, with a change in one client showing up in the others. The `@` menu ends with Date…, a keyboard-first calendar for any day, next to Today, Yesterday, Tomorrow and Now. A Tasks filter can be saved under a name and recalled from a chip, `:filter `, the F picker, or the command palette, with the list kept in config.toml as `[saved_filters]`. `zn create` and the MCP tools now file notes where the app does on a vault in root mode, display math renders inside callouts in both views, and Typst formulas are the size KaTeX's are. + +## ✨ New + +- **Ignore the no-op key a tap-hold remapper sends.** (#732, requested by @potter1402) Kanata, QMK and ZMK tap-hold layers emit a harmless extra key with every keystroke, on Linux usually the Katakana/Hiragana key, which reads as KanaMode. Neovim ignores keys it does not map; here every stray keydown reset a pending sequence, so `jk` never left insert mode and typed itself, `dd` deleted nothing, and a leader chord or hint mode died halfway. Settings → Keymap gains an **Ignored keys** row: press Record a key, then the no-op, and it is listed by name (its physical code when the layout reports Unidentified). From then on the app never sees it, in the editor, the panels, the hints and the shortcuts alike. `:ignorekey ` adds one from the ex line (bare, it opens the page), and `ignored_keys = ["KanaMode"]` under `[editor]` in config.toml is the portable form. + + How to test locally: set `insert_escape = "jk"` under `[vim]` in a scratch config.toml, open a note, enter insert mode and type `j`, press the Katakana/Hiragana key (or any key you can spare, such as F24), then `k`. Before: the editor stays in insert mode with `jk` typed. Record that key under Settings → Keymap → Ignored keys and repeat: `jk` leaves insert mode, `d` `d` deletes the line, and config.toml has `ignored_keys = ["KanaMode"]`. + +- **The Kanban Folder board groups by each note's folder, or by one folder's children.** (#730, requested by @andradejoelwp) "Group by: Folder" grouped by the four built-in folders, so a vault organised as `Projects//…` got one column holding everything plus an empty Quick, and people kept a hand-maintained `@status` token per task to get project columns. The board now gives every folder that holds tasks its own column (the Inbox root, `Projects/alpha`, `Areas`, Quick Notes), system folders first and subfolders alphabetical, with no empty columns. Point it at one folder and its children become the columns: `:folderroot Projects` on the board, the chip next to the group-by menu, Settings → Tasks → Folder board, or `kanban_folder_root = "Projects"` under `[view]` in config.toml. Deeper notes roll up to their child, notes in the root itself get the root's column, and notes outside it share an Other folders column. The root is saved per vault like the group-by and travels with your dotfiles. A remapped inbox and a root-mode vault classify the way the sidebar does, column titles and order still stick (ids are the note directories now), and folder columns stay read-only: moving the note is how a card changes column. + + How to test locally: make a vault with `inbox/Projects/alpha/A.md`, `inbox/Projects/beta/meetings/K.md`, `inbox/Areas/Home.md` and `inbox/Plan.md`, each with a `- [ ]` task, then open Tasks, press `3`, and pick Folder. Before: Inbox and an empty Quick. After: Inbox, Areas, Projects/alpha, Projects/beta/meetings. Type `:folderroot Projects`: alpha and beta (the kickoff note rolled up) plus Other folders; config.toml has `kanban_folder_root = "Projects"`. Bare `:folderroot` returns to one column per folder. + +- **Discuss a note with an assistant through its comments.** (#738, requested by @gverger) Comments were flat and unsigned, and only the app could reach them, so reviewing a draft with an assistant meant pasting the note into a chat and carrying the answers back by hand. Now comments thread and carry a name: a reply files under the comment it answers, every entry shows who wrote it (You, or the assistant's name), and `a` on a thread (or the Reply arrow) opens a reply box with ⌘↵ to send. The MCP server gains four tools on the same sidecar: `list_comments` returns each thread with the anchored passage, the line it sits on in the note today, the author and the replies; `reply_to_comment` answers in a thread; `add_comment` opens a new one anchored to a passage copied from the note (the editor marks the text) or a note-level remark; `resolve_comment` closes a settled thread or reopens it. Replies are signed with the connected client's name (Claude Code, Claude, Codex), so its words and yours stay apart in the panel, and they arrive live through the vault watcher. The server instructions steer a model toward answering in the threads rather than editing the note body, and toward resolving only when you say so. `zn comment list|add|reply|resolve` does the same from a terminal, against a folder or a ZenNotes server. Everything stays in the `.comments.json` beside the note, so the web client and every MCP client see the same threads. + + How to test locally: launch the built app as above, open a note, select a passage and press ⌘⌥M, type a comment, ⌘↵. In a terminal, `node apps/desktop/out/main/cli.js comment list inbox/.md` shows the thread with its line; `node apps/desktop/out/main/cli.js comment reply inbox/.md "Answer" --author Claude` lands under it in the panel within a second, signed Claude. For the MCP path, connect Claude Code to `zn mcp` and ask it to review the note's comments. Before 2.46: comments have no author, no replies, and no tool reaches them. + +- **Unbind a shortcut instead of hiding it on an obscure key.** Settings, Keymap gains an **Unbind** button on every row, next to Change and Reset, and inside the recorder in place of the old Clear (Backspace still clears a recording). An unbound row reads **Unbound** with the Custom badge, Unbind greys out, and Reset brings the shipped default back. Nothing fires the action until it gets a key again: the shortcut, sequence, leader chord or editor chord simply stops existing, and that includes the leader and pane prefixes themselves. Everything that advertises keys follows suit: the which-key hints drop an unbound leader action, the command palette shows no chord for it, tooltips lose their parenthetical, and the in-app manual prints "Unbound" where the key used to be. An unbound action never counts as a conflict either, so another global shortcut may take the key it used to hold. +- **`:unbind action.id` from the ex line.** In Vim mode, `:unbind global.toggleSidebar` removes the key and a toast names what it was (`Unbound Toggle sidebar (was ⌘1)`). Bare `:unbind`, or an id the catalog does not know, opens Settings on the Keymap page, which lists every action id, instead of guessing. Tab completes it in the `:` wildmenu like every other ex command. +- **Portable through config.toml.** An unbind is written as `"global.commandPalette" = "" # unbound` under `[keymaps]`, so it travels with your dotfiles, and a hand edit that sets any action to `""` applies live like every other config change. The generated reference block explains the convention above the table. TOML has no null, which is why the empty string is the spelling. + + How to test locally: `cd apps/desktop && npx electron-vite build`, then from the repo root launch `apps/desktop/out/main/index.js` with `ZEN_PERF=1`, `ZENNOTES_USER_DATA_PATH` and `ZENNOTES_CONFIG_DIR` pointing at scratch folders. Press ⌘, (Ctrl+, on Windows and Linux), open Keymap, filter "command palette", press Unbind. Before: ⇧⌘P opens the palette. After: it does nothing, the row reads Unbound, and `config.toml` in the scratch config dir has `"global.commandPalette" = "" # unbound`. Open a note, type `:unbind vim.leaderOpenBuffers`, press Space: the `o` entry is gone from the leader hints. Reset on the row brings ⇧⌘P back. + +- **Custom templates on remote vaults.** (#723, requested by @ajselzilic; the server and web groundwork was first explored by @flokchvtr on their web-feature-parity branch) Settings, Templates used to say that custom templates require a local vault in the web client and in desktop remote mode, because nothing served the vault's `.zennotes/templates/` over HTTP. The server now does, through four authenticated routes (list, read, write, delete) that mirror the desktop module byte for byte: the same slug rules, the same flat-directory and `.md`-only validation, the same `adr`, `adr-2` de-duplication and rename-removes-previous behaviour, atomic writes under the vault lock, and the configured note-size limit (a 413 past it). It advertises them as `supportsCustomTemplates`, and both the web client and the desktop remote client gate on that flag: an older server keeps the section read-only with a note naming the fix (update the server and reload, or reconnect the workspace), and the built-in templates keep working. Both watchers, the server's and the desktop's, now report `.zennotes/templates/` under a `templates` scope of their own, so a template saved in one client appears in the others without a reconnect, and a change-feed gap re-lists them as well. A local vault behaves exactly as before. Found on the way: the desktop read the remote workspace info before the connection existed and kept that copy, so it never learned what the server advertises; it re-reads once the vault is connected. + + How to test locally: `cd apps/server && go build -o /tmp/zennotes-server ./cmd/zennotes-server`, then `ZENNOTES_CONFIG_PATH=/tmp/zs.json ZENNOTES_VAULT_PATH=/path/to/vault ZENNOTES_AUTH_TOKEN=tok ZENNOTES_BIND=127.0.0.1:7878 /tmp/zennotes-server`. Web: `npm run dev:web`, open http://localhost:5180, sign in with `tok`, Settings, Templates. Before: "Custom templates require a local vault". After: New template, and Save lands `/path/to/vault/.zennotes/templates/.md`; Edit renames the file, Delete removes it. Drop a `.md` with `name:` frontmatter into that folder from a terminal and it appears in the list on its own. Desktop: Settings, Vault, Connect to Remote Vault… with the same URL and token, then the same steps; against a server older than 2.46 the section says the server needs an update instead. + +- **Save a Tasks filter under a name, and get it back in one keystroke.** (#731, requested by @andradejoelwp) The filter box narrows the Tasks views to one project, one area, one context, but it was transient: the same query had to be retyped every time, which is why people stopped reaching for it and went looking for a grouping mode instead. Grouping has one axis; filters compose. A query worth typing twice is now saved under a name, as the `[saved_filters]` table in config.toml (`"Project alpha" = "@project:alpha"`, one line each), so it is diffable, syncs with the rest of your preferences, and a hand edit applies live. Recall is the point, so it is cheap from everywhere: a chip row under the Tasks header shows the saved filters in file order, a click applies one and a second click clears it; `:filter ` applies the saved query whenever the text is a saved name (any other text filters literally, as before); `F` in Vim mode opens a picker that narrows as you type; and the command palette lists every saved filter as "Tasks: name", which opens the view already filtered from any note. Saving is a chip too: an unsaved query shows **Save filter…**, which asks for a name and offers the existing ones for an overwrite; `:savefilter ` does the same from the ex line, `:delfilter ` forgets one, and a chip's right-click menu renames or deletes it. Names match regardless of case, edits keep the chip order, and the web client keeps its saved filters in the browser. With Vim mode off, the chips and the palette are the way in, as with every other single-key shortcut in the list. + + How to test locally: launch the built app as above with a scratch config dir, then add to its `config.toml`: `[saved_filters]` and `"Project alpha" = "@project:alpha"`. Open Tasks: the chip is there, and clicking it puts `@project:alpha` in the filter box with the count chip reading `M of N`. Type `:f !high`, then `:savefilter Urgent`: a new chip appears and `config.toml` gains `Urgent = "!high"`. Append `"Blocked" = "@status:blocked"` to the file by hand: the chip appears without a restart. Open a note and press ⇧⌘P, type `Tasks: Blocked`, Enter: the Tasks view opens filtered. Before 2.46: none of this exists; the query is gone the moment you clear it. + +- **Pick any date from the `@` menu.** (#743, requested by @uNyanda) `@` offered Today, Yesterday, Tomorrow and Now; any other day meant typing it out by hand. The list now ends with **Date…** (`@date`, `@cal` and `@pick` narrow to it), and Enter on it opens a calendar on today with today's cell already focused, so nothing needs a Tab or a mouse: arrows move a day or a week, PageUp/PageDown change the month, with Shift the year, Home/End go to the ends of the week, and Enter inserts the ISO date (`2026-10-17`, the same shape the quick options write) exactly where the `@` stood, with focus back in the note. A digit pressed on the grid jumps to the text field, so a date can be typed outright, and the grid follows it as it takes shape; a day the month does not have (`2026-02-30`) is refused rather than rolled into March. With Vim mode on, h j k l move and t jumps back to today; with Vim mode off, letters stay inert, as they do in every list. Escape leaves the note as it was before the `@`. The quick options are unchanged, the calendar honours the week start from Settings, Calendar, and it works wherever the `@` menu does, the pinned reference pane and the web client included. + + How to test locally: launch the built app as above, open a note, type `@`: the list ends with Date…. Type `date` and press Enter. Before: `@date` matched nothing and the menu just closed. After: a calendar opens on today; press → → ↓ then PageDown, then Enter, and the note reads the day nine days from today in next month, where the `@` was. Type `@date`, Enter, then `2027-03-14` and Enter: that date is inserted. Type `@date`, Enter, Escape: nothing is left behind. + +## 🐛 Fixes + +- **`zn create` and the MCP tools follow `primaryNotesLocation: root`.** (#745 by @diazkev314) With Settings, Vault set to "Vault root", `zn create` and the MCP `create_note` tool still wrote new notes into `inbox/`, and `vault_info` reported `inbox`, while `zn list` and `zn search` found root-level notes fine. The CLI and MCP decided the mode from the vault's layout first and only consulted `vault.json` when the layout was ambiguous, a rule written when root mode was new so that a vault switched in Settings but not yet migrated kept filing next to its old notes. The app does the opposite: an explicit setting is the answer and the layout is consulted only when `vault.json` leaves it unstated. With old notes still in `inbox/`, the two halves disagreed on the same vault. The file wins now on every side: an explicit `primaryNotesLocation` decides, and only a vault without one (or one a sandboxed process cannot read) is inferred from its layout, as the app infers it. Reads were already right and are unchanged. + + How to test locally: make a scratch vault with `.zennotes/vault.json` containing `{"primaryNotesLocation": "root"}` and a leftover `inbox/Old.md`, then `node apps/desktop/out/main/cli.js create --vault /path/to/vault --title Test --body test --json`. Before: `"path": "inbox/Test.md"`. After: `"path": "Test.md"`, and the MCP `vault_info` tool reports `primaryNotesLocation: root` with `create_note` landing at the root too. + +- **Display math renders inside callouts, in the editor and the reading view.** (#748 by @OstrichDowneyJr) A `$$…$$` block inside an Obsidian-style callout stayed raw in the editor, because the `> ` in front of its fence counted as prose and the live preview only renders fences that own their line. In the reading view it was worse: the fence normalizer, after copying a canonical block that came earlier in the note, re-scanned that block's closing fence as an opener and paired it with the callout's `> $$`, which it took for content hugging a fence; the rewrite left a bare `$$` outside the quote, and with Typst selected that block swallowed everything after it into one failing formula (with KaTeX, into one long one). Both views now treat a fence inside a quote as a fence: the editor strips the quote markers from the formula and gives the rendered block the callout card's own classes so the card stays whole around it, and the normalizer looks past the markers, closes a block only at its own quote depth, and moves past a canonical block instead of re-reading its closing fence. A bare `$` on its own line is still not a display block in ZenNotes (with either engine, that is `$$`), but a span demoted to text inside a callout no longer carries the `> ` of every line it spans. + + How to test locally: with Settings, Editor, Math renderer set to Typst, put `> [!note]`, `> $$`, `> x_1 = frac(det W_1, det A)`, `> $$` in a note under a normal `$$…$$` block. Before: the callout's formula stays raw in the editor, and in the reading view the callout loses its body while a red Typst error starting with `> x_1` swallows the rest of the note. After: both blocks render the same, inside the card in the editor and inside the callout in the reading view. + +- **Typst formulas are the size KaTeX formulas are, and their rules follow the text color.** (#746 by @cyperion) With Typst selected, inline math sat visibly smaller than the surrounding text, where KaTeX's fit. KaTeX draws Computer Modern at 1.21 times the text size, its own stylesheet's choice, because the family sits small on its em square and a plain 1em reads undersized next to prose. The Typst SVG was sized at that plain 1em, and New Computer Modern shares the metrics, so the same formula came out a fifth smaller: 52 px wide against KaTeX's 63 for `E = h nu` at the default text size. It is now sized with the same 1.21 factor in both the editor and the reading view, display blocks included, so switching engines no longer changes how big the math is. The Math size setting still scales on top. Found on the way: the recolor that makes Typst glyphs follow the theme only knew fills, so the rules a formula draws as shapes, a square root's bar and a fraction line, kept Typst's black stroke and vanished on a dark theme. Strokes are recolored now too. + + How to test locally: write `The energy of a photon is $E = h nu$ here.` with Settings, Editor, Math renderer set to Typst, then compare against the KaTeX rendering of `$E = h\nu$`. Before: the Typst formula is noticeably smaller than the text, and on a dark theme the bar of `$$ sqrt(p^2 c^2 + m^2 c^4) $$` is black. After: the two engines render the formula at the same width, the text and the math read as one size, and the bar is the text color. + +## 🧰 For contributors + +- The convention lives in `packages/app-core/src/lib/keymaps.ts`: `UNBOUND_BINDING` (`""`), `isUnboundBinding`, `isKeymapUnbound`, and `UNBOUND_LABEL`. `getKeymapBinding` returns the empty string for an unbound action instead of falling back to the default, `normalizeKeymapOverrides` keeps blank strings, `matchesShortcutBinding` and `eventMatchesUserOverride` return false for them, and `getKeymapDisplay` returns `""` so a caller can leave the chip out; `labelWithShortcut` builds tooltips that never read "Go back ()". The store's `setKeymapBinding(id, null)` clears an override and `setKeymapBinding(id, "")` stores an unbind. +- Any new consumer of a binding must treat `""` as "no key". CodeMirror files an empty key name without complaint and would run the command on a keydown whose `key` is empty, so editor keymap entries go through `keyBindingsFor` in `vim-half-page-keymap.ts`, which returns nothing for an unbound action; VimNav's leader and pane prefix tokens fall back to `UNBOUND_BINDING`, which never equals a token read off an event, rather than to `Space` and `Ctrl+W`; codemirror-vim mappings already dropped a null sequence. +- `:unbind` is registered in `Editor.tsx` next to `:harper` and listed in `MANUAL_EX_NAMES`; `SettingsNavigationTarget` gained `"keymaps"` so the command can open that page. The config writer in `apps/desktop/src/main/app-config.ts` appends `# unbound` to empty entries and documents `""` in the `[keymaps]` header. +- Tests: `keymaps.test.ts` (normalization, resolution, matching, conflicts, tooltips), `vim-half-page-keymap.test.ts` (no keymap entry for an unbound action), `SettingsModal.test.ts` (the Unbind row and the Unbound state), and `app-config.test.ts` (the config.toml round trip). Both docs surfaces are updated: the in-app manual (Keymaps section, config-file entry, `:unbind` ex row) and the website's `docs.blade.php` and `docs-sections.json`, whose DocsPage tests pass. +- Verification, this pass: monorepo typecheck clean; app-core 1899 tests and the desktop config suite green; the built desktop app driven over CDP with isolated userData and config through 24 checks: the palette before and after the row Unbind, config.toml contents, `:unbind` with a known and an unknown id, the leader hints before and after, a hand-edited `""` picked up live, and Reset restoring the default. Tab completion of `:unb` checked the same way. Captioned demo: `media/keymap-unbind.mp4` (30 seconds, H.264, 1920×1080, CRF 17) with the captions burned into a strip under the app frame, since the clip has no voice, plus `media/keymap-unbind.vtt` for the website's caption track. Recorded from the built app at 1600×820 with sticky which-key hints so the overlay stays readable. + +- Remote templates (#723): `apps/server/internal/vault/templates.go` is a SYNCED COPY of `apps/desktop/src/main/templates.ts` (slug, path and de-duplication rules; a template's id is `custom:`, so both sides must land the same filename); `apps/server/internal/httpserver/templates.go` holds the four routes and `writeError` maps `vault.ErrInvalidTemplate` to 400. The Go watcher gained a `templates` scope branch before its dot-path drop, plus `watchSubdirs`, which adds directories created together with a parent that only raised one Create (`.zennotes/templates/` in a vault that had no `.zennotes/` yet stayed unwatched until a restart). The bridge contract gained `ServerCapabilities.supportsCustomTemplates?` and the `templates` member of `VaultChangeScope`; the desktop watcher mirrors the scope; `RemoteServerClient` gained the four template methods; the desktop IPC handlers dispatch through `requireRemoteTemplates`, reading the connect-time `remoteServerCapabilities` rather than re-fetching; the web bridge derives both the request gate and `getCapabilities()` from the same cached `/capabilities` response; `store.applyChange` re-lists templates on the new scope and after a resync; `store.init` re-reads the workspace info after `getCurrentVault` connects. Settings gates on `remoteWorkspaceInfo.capabilities` in remote mode and on the host capability otherwise. +- #745 lives in `readPrimaryNotesLocation` in `apps/desktop/src/mcp/vault-ops.ts`, shared by the CLI's local backend and the MCP server; the inbox-notes count that used to outrank the file is gone, and the layout fallback mirrors `inferPrimaryNotesLocation` in `main/vault.ts`. Three tests in `vault-ops.test.ts` pin the order: file says root with old inbox notes, file says inbox with loose root notes, and no file at all. Verified through the running app over CDP: the vault switched to Vault root in Settings, then `zn create` with no `--vault` (it follows the vault the app has open) and the MCP `vault_info` and `create_note` tools over stdio, with both new notes appearing in the app at the root while the old ones stayed in `inbox/`. Captioned demo: `media/cli-root-mode-745.mp4` plus `media/cli-root-mode-745.vtt` (1920×1080). +- #748 lives in two places. `normalizeBlockMathFences` in `packages/app-core/src/lib/markdown.ts` now splits every line into its blockquote prefix and content, matches fences on the content, re-emits the prefix on whatever it writes (an empty quote line where a blank would end the quote), pairs closers only at the same quote depth, and copies a canonical block through with `i = close + 1` instead of `i++` (the re-scan that paired fences across the callout edge). The currency guard's raw source slice drops the markers of continuation lines. In `cm-math-render.ts`, `quoteDepthOf` accepts markers before an opening `$$`, `stripQuoteMarkers` takes them off the formula, and `BlockMathWidget` gained a `frame` of `cm-callout cm-callout-` (or `cm-wq-quote`) resolved from the outermost Blockquote node, so the widget is styled as part of the card. Tests in `markdown.test.ts` and `cm-math-render.test.ts`; verified in the built app in edit and preview with Typst selected, on the pre-fix and fixed builds of the same note. Captioned before/after: `media/typst-callout-748.mp4` plus `media/typst-callout-748.vtt` (1920×1080). +- #746 lives in `styleSvg` in `packages/app-core/src/lib/typst-math-render.ts`: `KATEX_EM_SCALE = 1.21` applied when the SVG's pt dimensions become em sizes, and `BLACK_PAINT_RE`, which turns every black `fill` or `stroke` (`#000000`, `#000`, `black`, `rgb(0,0,0)`) into `currentColor`; Typst exports glyphs as black fills and shape rules as black strokes, and only the fills were handled. `styleSvg` is exported now, with `typst-math-render.test.ts` pinning the factor and both recolors. Measured in the built app before and after (inline `E = h nu` at 16 px text: 52.4 px wide before, 63.4 after, KaTeX 63.2). Captioned before/after: `media/typst-size-746.mp4` plus `media/typst-size-746.vtt` (1920×1080). +- #732: `packages/app-core/src/lib/ignored-keys.ts` holds `normalizeIgnoredKeys`, `isIgnoredKeyEvent` (key or code, case-insensitive), `ignoredKeyTokenFromEvent` (key, else code for Unidentified/Dead/Process), `setIgnoredKeysRecorderActive` and `installIgnoredKeysGuard`, a single capture-phase guard on `window` for keydown, keyup and keypress that is installed at App.tsx module load (an effect would run after VimNav's) and reads the live list from the store. The pref `ignoredKeys` is portable (`ignored_keys` list under `[editor]`), with `setIgnoredKeys`, `addIgnoredKey` and `removeIgnoredKey`; Settings renders `IgnoredKeysRow` above the shortcut editor and `:ignorekey` sits next to `:unbind` in `Editor.tsx`. Tests: `ignored-keys.test.ts` (jsdom), `store.test.ts`, the config round trip. Verified in the built app over CDP with realistic timing (the no-op 15 ms after the key): the report reproduced with an empty list, then fixed with the key recorded; eleven checks. Captioned demo: `media/ignored-keys-732.mp4` plus `media/ignored-keys-732.vtt` (1920×1080). +- #730: `folderColumns`, `noteLocationOf` and `FolderBoardLayout` in `TasksKanban.tsx` (exported for tests) place a task by splitting its path through `systemFolderForDirName`, so a remapped system folder strips its real prefix and a root-mode note has none; column ids are vault-relative directories, `NO_VALUE_COLUMN_ID` is the Other folders bucket (pinned last by `arrangeColumns`), and `kanbanColumnTitles`/`kanbanColumnOrder` grammars now accept path ids (`FOLDER_COLUMN_TITLE_KEY_RE`, `MAX_KANBAN_COLUMN_ID_LENGTH`). The pref `kanbanFolderRoot` (`normalizeKanbanFolderRoot`, `setKanbanFolderRoot`) is portable and a `VaultViewSettings` override; the board's `pickFolderRoot` prompt lists the folders that hold tasks; `:folderroot` lives in the Tasks view ex line; Settings gained `KanbanFolderRootRow`. Tests in `TasksKanban.test.ts`, `store.test.ts` and the config round trip. Verified in the built app over CDP (ten checks). Captioned demo: `media/folder-board-730.mp4` plus `media/folder-board-730.vtt` (1920×1080). +- #738: the record shape lives in `packages/shared-domain/src/note-comments.ts` (`normalizeNoteComment(s)` with the optional `author` and `parentId`, orphaned replies kept as top-level comments, `threadNoteComments`, `threadRootOf`, the anchor helpers that used to sit in app-core, `lineOfOffset`), imported by the desktop main process (`vault.ts` lost its copy), the MCP `vault-ops` (sidecar read/write) and the panel; the Go `NoteComment` struct and `normalizeComment` mirror it. `apps/desktop/src/mcp/comment-ops.ts` composes list/add/reply/resolve from a backend's `readNote`, `listComments` and `writeComments` (new `VaultBackend` members, local through vault-ops, remote through the existing `/api/comments/*` routes), and returns thread views a model can act on. `server.ts` registers the four tools and captures `clientInfo.name` in `oninitialized` for `commentAuthorForClient`. `CommentsPanel` renders threads (rows stay the top-level cards, so j/k walk conversations), `ReplyRow`, the reply composer and the `a` action; `VimNav` maps `a` to it; `EditorPane` draws markers for top-level comments only. Tests: `note-comments.test.ts`, `comment-ops.test.ts`, `server.test.ts` (tool list, author mapping, reply threading), `cli/commands/comments.test.ts`, and a Go round trip. Verified in the built app with a real `zn mcp` session driven as claude-code, sixteen checks. Captioned demo: `media/comment-threads-738.mp4` plus `media/comment-threads-738.vtt` (1920×1080). +- #731: the map helpers live in `packages/app-core/src/lib/saved-task-filters.ts` (`normalizeSavedTaskFilters` validates the file and localStorage, `findSavedTaskFilterName` and `savedTaskFilterQuery` match case-insensitively, `withSavedTaskFilter`, `withoutSavedTaskFilter` and `renameSavedTaskFilter` keep the insertion order, which is the chip order and the file order). The store carries `savedTaskFilters` as a portable pref (`PORTABLE_PREF_KEYS` in shared-domain, `[saved_filters]` in the desktop writer's map tables, seeded from `loadPrefs()` like every other pref) with `saveTaskFilter`, `renameSavedTaskFilter`, `deleteSavedTaskFilter` and `applySavedTaskFilter`. `TasksView` owns the chip row, the Save filter… chip, the `F` picker (`tasks.savedFilters`, a new view-actions keymap entry in both the catalog and `keymaps.ts`), and the `:filter`, `:savefilter` and `:delfilter` ex forms in its local ex line; `buildCommands` adds one "Tasks: name" entry per saved filter, opening the view first because `openTasksView` resets the filter. Tests: `saved-task-filters.test.ts`, the `[saved_filters]` round trip in `app-config.test.ts`, the palette entries in `commands.test.ts`, plus the keymap drift test. Verified in the built app over CDP with Vim on (23 checks) and off (9). Captioned demo: `media/saved-filters-731.mp4` plus `media/saved-filters-731.vtt` (34 seconds, 1920×1080). Note for the next person driving this file over CDP: the shell's `grep` wrapper skips `TasksView.tsx` as binary; use `/usr/bin/grep -a`. +- #743: the calendar is `packages/app-core/src/components/DatePickerModal.tsx` on `ui/Modal` (focus trap, Escape, focus restore), hosted by `DatePickerHost` next to the prompt and confirm hosts in `App.tsx`, and requested through `promptDate()` in `lib/date-prompt-requests.ts`, the calendar twin of `promptApp`: any code without React context (a CodeMirror completion's `apply`, an ex command) can await an ISO string or null. The date math is pure in `lib/date-picker.ts` (`parseISODate` rejects days the month does not have, `addMonths` clamps the day, `buildMonthGrid`, `moveDate`, and `datePickerMoveForKey`, which gates h/j/k/l and t on Vim mode). The Date… item in `cm-date-shortcuts.ts` removes the trigger before the calendar opens and inserts at the recorded position, re-clamped to the document length. The modal carries `data-prompt-modal`, so VimNav and the list views hand over the keyboard as they do for the text prompt. A month change replaces every grid cell, which drops focus to the body before React's effect runs, so the refocus decision is recorded by the move itself rather than read from `document.activeElement`; the built app caught this where jsdom did not. Tests: `date-picker.test.ts`, `DatePickerModal.test.ts` (keyboard, paging with focus, typed dates, Vim gating, cancel), and `cm-date-shortcuts.test.ts` (menu order, queries, the hand-off with a mocked prompt). Verified in the built app over CDP with Vim on and off, thirteen checks. Captioned demo: `media/date-picker-743.mp4` plus `media/date-picker-743.vtt` (31 seconds, 1920×1080). +- Tests for #723: Go (vault slug, path, de-duplication, symlink and listing rules; HTTP auth, CRUD, traversal, 413 and the capability; watcher scope and late walk), desktop (`server-client.test.ts` drives the four routes against a loopback server, `watcher.test.ts` sees the scope), app-core (`store.test.ts` for the scope, the resync fan-out and the post-connect re-read; `SettingsModal.test.ts` for the remote gate with and without the flag). Verified live: the web client against a real server (create, rename, delete, and a file dropped into the vault arriving through the WebSocket feed) and the desktop in remote mode over CDP through twelve checks. Captioned demo: `media/remote-templates.mp4` plus `media/remote-templates.vtt` (28 seconds, H.264, 1920×1080), recorded from the built app connected to a local ZenNotes server. + +--- + +Local-first and keyboard-first, as always. diff --git a/docs/releases/v2.46.0/media/cli-root-mode-745.mp4 b/docs/releases/v2.46.0/media/cli-root-mode-745.mp4 new file mode 100644 index 00000000..4ac6c7d4 Binary files /dev/null and b/docs/releases/v2.46.0/media/cli-root-mode-745.mp4 differ diff --git a/docs/releases/v2.46.0/media/cli-root-mode-745.vtt b/docs/releases/v2.46.0/media/cli-root-mode-745.vtt new file mode 100644 index 00000000..0fcb0f50 --- /dev/null +++ b/docs/releases/v2.46.0/media/cli-root-mode-745.vtt @@ -0,0 +1,13 @@ +WEBVTT + +00:00.000 --> 00:07.127 +A vault with notes in inbox/. In Settings, Vault, switch the primary notes location to Vault root. + +00:07.127 --> 00:12.335 +In a terminal: zn create --title "From the CLI". It follows the vault the app has open, and the old notes are still in inbox/. + +00:12.335 --> 00:16.177 +Before: it landed in inbox/. Now: at the vault root, where the app files its own notes, and the app lists it right away. + +00:16.177 --> 00:20.181 +The MCP create_note tool does the same, and vault_info now reports primaryNotesLocation: root. diff --git a/docs/releases/v2.46.0/media/comment-threads-738.mp4 b/docs/releases/v2.46.0/media/comment-threads-738.mp4 new file mode 100644 index 00000000..c9b8408d Binary files /dev/null and b/docs/releases/v2.46.0/media/comment-threads-738.mp4 differ diff --git a/docs/releases/v2.46.0/media/comment-threads-738.vtt b/docs/releases/v2.46.0/media/comment-threads-738.vtt new file mode 100644 index 00000000..2885ed69 --- /dev/null +++ b/docs/releases/v2.46.0/media/comment-threads-738.vtt @@ -0,0 +1,22 @@ +WEBVTT + +00:00.001 --> 00:06.479 +Discuss a note with your assistant through comments. Select a passage, press ⌘⌥M, and leave a comment anchored to it. + +00:06.479 --> 00:09.084 +Ask Claude Code (or any MCP client) to review the note. It reads the threads with list_comments and answers with reply_to_comment. + +00:09.084 --> 00:12.486 +The reply lands under your comment, live, signed with the assistant’s name. Its words and yours stay apart. + +00:12.486 --> 00:16.841 +It can raise points of its own: add_comment with a passage from the note anchors a new thread, and the editor marks the text. + +00:16.841 --> 00:23.481 +Answer from the panel: a opens a reply box under the selected thread, ⌘↵ sends it. + +00:23.481 --> 00:27.438 +resolve_comment (or r in the panel) closes a settled thread. It moves to Resolved and stays in the note’s history. + +00:27.438 --> 00:31.112 +Everything lives in the .comments.json beside the note, so zn comment, the web client and every MCP client see the same threads. diff --git a/docs/releases/v2.46.0/media/date-picker-743.mp4 b/docs/releases/v2.46.0/media/date-picker-743.mp4 new file mode 100644 index 00000000..8c82cfb3 Binary files /dev/null and b/docs/releases/v2.46.0/media/date-picker-743.mp4 differ diff --git a/docs/releases/v2.46.0/media/date-picker-743.vtt b/docs/releases/v2.46.0/media/date-picker-743.vtt new file mode 100644 index 00000000..612d7961 --- /dev/null +++ b/docs/releases/v2.46.0/media/date-picker-743.vtt @@ -0,0 +1,19 @@ +WEBVTT + +00:00.000 --> 00:04.051 +Type @ in a note. Today, Yesterday, Tomorrow and Now are still there. New at the end of the list: Date… + +00:04.051 --> 00:07.420 +Type "date" (or arrow down to the row) and press Enter. + +00:07.420 --> 00:12.297 +A calendar opens on today, already focused. Arrows move a day or a week, PageDown the month, Shift+PageUp a year back. + +00:12.297 --> 00:15.533 +Enter inserts the ISO date where the @ stood, and focus returns to the note. + +00:15.533 --> 00:22.956 +Or type the date outright: a digit jumps to the field, and the grid follows as you type. + +00:22.956 --> 00:30.751 +Escape leaves the text exactly as it was. With Vim mode on, h j k l move and t jumps back to today. diff --git a/docs/releases/v2.46.0/media/folder-board-730.mp4 b/docs/releases/v2.46.0/media/folder-board-730.mp4 new file mode 100644 index 00000000..0b79867c Binary files /dev/null and b/docs/releases/v2.46.0/media/folder-board-730.mp4 differ diff --git a/docs/releases/v2.46.0/media/folder-board-730.vtt b/docs/releases/v2.46.0/media/folder-board-730.vtt new file mode 100644 index 00000000..53467ede --- /dev/null +++ b/docs/releases/v2.46.0/media/folder-board-730.vtt @@ -0,0 +1,16 @@ +WEBVTT + +00:00.000 --> 00:04.210 +The Kanban Folder board now gives every note folder its own column: Projects/alpha, Projects/beta, Areas, the Inbox root, Quick Notes. + +00:04.210 --> 00:08.954 +Notes organised as Projects/? Point the board at that folder: :folderroot Projects (or Settings, Tasks, Folder board). + +00:08.954 --> 00:13.156 +Its children are the columns. Deeper notes roll up to their project, and notes outside it share one Other folders column. + +00:13.156 --> 00:17.171 +The chip in the header shows the root; click it to pick another folder from the ones that hold tasks. + +00:17.171 --> 00:23.502 +It travels in config.toml as kanban_folder_root, per vault. Bare :folderroot goes back to one column per folder. diff --git a/docs/releases/v2.46.0/media/ignored-keys-732.mp4 b/docs/releases/v2.46.0/media/ignored-keys-732.mp4 new file mode 100644 index 00000000..5eac812c Binary files /dev/null and b/docs/releases/v2.46.0/media/ignored-keys-732.mp4 differ diff --git a/docs/releases/v2.46.0/media/ignored-keys-732.vtt b/docs/releases/v2.46.0/media/ignored-keys-732.vtt new file mode 100644 index 00000000..eb327423 --- /dev/null +++ b/docs/releases/v2.46.0/media/ignored-keys-732.vtt @@ -0,0 +1,13 @@ +WEBVTT + +00:00.000 --> 00:03.866 +Kanata, QMK and ZMK tap-hold layers send a no-op key (KanaMode on Linux) with every keystroke. Watch it break jk: j, no-op, k. + +00:03.866 --> 00:07.800 +Still in insert mode, with a stray jk typed. The no-op reset the sequence; dd, leader chords and hints suffer the same way. + +00:07.800 --> 00:14.284 +Settings, Keymap, Ignored keys: press Record a key, then the no-op. It is listed by name and saved to config.toml as ignored_keys. + +00:14.284 --> 00:21.362 +Now the app never sees it: j, no-op, k leaves insert mode, and d, no-op, d deletes the line. :ignorekey does the same from the ex line. diff --git a/docs/releases/v2.46.0/media/keymap-unbind.mp4 b/docs/releases/v2.46.0/media/keymap-unbind.mp4 new file mode 100644 index 00000000..637c76fb Binary files /dev/null and b/docs/releases/v2.46.0/media/keymap-unbind.mp4 differ diff --git a/docs/releases/v2.46.0/media/keymap-unbind.vtt b/docs/releases/v2.46.0/media/keymap-unbind.vtt new file mode 100644 index 00000000..62b94493 --- /dev/null +++ b/docs/releases/v2.46.0/media/keymap-unbind.vtt @@ -0,0 +1,22 @@ +WEBVTT + +00:00.000 --> 00:04.401 +Every row in Settings, Keymap now has an Unbind button. + +00:04.401 --> 00:07.728 +The row reads Unbound. Nothing fires the action until it gets a key again. + +00:07.728 --> 00:10.552 +⇧⌘P used to open the command palette. Now it does nothing. + +00:10.552 --> 00:16.209 +Vim users: :unbind takes a key away from the ex line. + +00:16.209 --> 00:20.588 +The leader hints drop the unbound action: no more o for open buffers. + +00:20.588 --> 00:23.989 +It travels with your dotfiles: "vim.leaderOpenBuffers" = "" under [keymaps] in config.toml. + +00:23.989 --> 00:30.161 +Reset brings the shipped default back. diff --git a/docs/releases/v2.46.0/media/remote-templates.mp4 b/docs/releases/v2.46.0/media/remote-templates.mp4 new file mode 100644 index 00000000..8708bc38 Binary files /dev/null and b/docs/releases/v2.46.0/media/remote-templates.mp4 differ diff --git a/docs/releases/v2.46.0/media/remote-templates.vtt b/docs/releases/v2.46.0/media/remote-templates.vtt new file mode 100644 index 00000000..5c33bc46 --- /dev/null +++ b/docs/releases/v2.46.0/media/remote-templates.vtt @@ -0,0 +1,22 @@ +WEBVTT + +00:00.000 --> 00:03.530 +The desktop app on a remote vault: a self-hosted ZenNotes server, not a local folder. + +00:03.530 --> 00:06.355 +Settings, Templates now offers New template here (server 2.46 or later). + +00:06.355 --> 00:12.259 +Write the template in the editor and save it. + +00:12.259 --> 00:15.060 +Saved on the server as .zennotes/templates/team-retro.md, the same file a local vault keeps. + +00:15.060 --> 00:18.941 +A template saved by another client shows up on its own: the server’s change feed carries it. + +00:18.941 --> 00:23.120 +Both are in the picker like any template: Space t, or :template. + +00:23.120 --> 00:28.334 +Delete removes it from the server, and the editor edits it in place. diff --git a/docs/releases/v2.46.0/media/saved-filters-731.mp4 b/docs/releases/v2.46.0/media/saved-filters-731.mp4 new file mode 100644 index 00000000..601d747e Binary files /dev/null and b/docs/releases/v2.46.0/media/saved-filters-731.mp4 differ diff --git a/docs/releases/v2.46.0/media/saved-filters-731.vtt b/docs/releases/v2.46.0/media/saved-filters-731.vtt new file mode 100644 index 00000000..011af61b --- /dev/null +++ b/docs/releases/v2.46.0/media/saved-filters-731.vtt @@ -0,0 +1,19 @@ +WEBVTT + +00:00.000 --> 00:03.401 +Saved filters in the Tasks view. Two are already in config.toml under [saved_filters], so they show as chips under the header. + +00:03.401 --> 00:07.776 +Click a chip and its query lands in the filter box. Every sub-view narrows: the list, the calendar, the board. + +00:07.776 --> 00:13.535 +Keyboard: F opens the picker. Type to narrow, Enter applies. With :filter you can also type a saved name. + +00:13.535 --> 00:22.145 +A new query worth keeping: type it, then Save filter… (or :savefilter ). The chip appears, and config.toml gets the line. + +00:22.145 --> 00:30.282 +From any note, the command palette lists every saved filter as "Tasks: name". Pick one and the Tasks view opens already filtered. + +00:30.282 --> 00:33.925 +Right-click a chip to rename or delete it, or edit [saved_filters] in config.toml by hand: changes apply live and travel with your dotfiles. diff --git a/docs/releases/v2.46.0/media/typst-callout-748.mp4 b/docs/releases/v2.46.0/media/typst-callout-748.mp4 new file mode 100644 index 00000000..a398db20 Binary files /dev/null and b/docs/releases/v2.46.0/media/typst-callout-748.mp4 differ diff --git a/docs/releases/v2.46.0/media/typst-callout-748.vtt b/docs/releases/v2.46.0/media/typst-callout-748.vtt new file mode 100644 index 00000000..315edbeb --- /dev/null +++ b/docs/releases/v2.46.0/media/typst-callout-748.vtt @@ -0,0 +1,13 @@ +WEBVTT + +00:00.000 --> 00:04.000 +Before: with Typst selected, a $$ block inside a callout stayed raw in the editor while the one outside rendered. + +00:04.000 --> 00:08.500 +Before, reading view: the callout lost its body and a failing formula swallowed the rest of the note. + +00:08.500 --> 00:12.500 +After: the block inside the callout renders like the one outside, as part of the card. + +00:12.500 --> 00:16.500 +After, reading view: both render, inline math inside callouts included. diff --git a/docs/releases/v2.46.0/media/typst-size-746.mp4 b/docs/releases/v2.46.0/media/typst-size-746.mp4 new file mode 100644 index 00000000..0e48725f Binary files /dev/null and b/docs/releases/v2.46.0/media/typst-size-746.mp4 differ diff --git a/docs/releases/v2.46.0/media/typst-size-746.vtt b/docs/releases/v2.46.0/media/typst-size-746.vtt new file mode 100644 index 00000000..6b330490 --- /dev/null +++ b/docs/releases/v2.46.0/media/typst-size-746.vtt @@ -0,0 +1,13 @@ +WEBVTT + +00:00.000 --> 00:04.000 +KaTeX draws Computer Modern at 1.21 times the text size, the way its own stylesheet asks. + +00:04.000 --> 00:09.000 +Before: the same formulas under Typst were a fifth smaller, and a square root bar stayed black on a dark theme. + +00:09.000 --> 00:13.500 +After: Typst formulas are sized the way KaTeX sizes them, and every rule follows the text color. + +00:13.500 --> 00:17.500 +The reading view too, display blocks included. The Math size setting still scales on top. diff --git a/docs/releases/v2.46.0/twitter-post.md b/docs/releases/v2.46.0/twitter-post.md new file mode 100644 index 00000000..f5c7ac24 --- /dev/null +++ b/docs/releases/v2.46.0/twitter-post.md @@ -0,0 +1,58 @@ +# Twitter/X thread for ZenNotes 2.46.0 + +## Tweet 1 + +ZenNotes 2.46.0 is out. + +💬 Review a note with your assistant, inside the note. Comments now thread and carry a name, and over MCP Claude Code (or Codex, or Claude Desktop) reads a note's comment threads, answers under yours, opens threads of its own anchored to a passage, and resolves settled ones. Its replies land in the Comments panel live, signed with its name. Thanks @gverger (#738). + +https://github.com/ZenNotes/zennotes/releases/tag/v2.46.0 + +## Tweet 2 + +🗂 The Kanban Folder board now gives every note folder its own column, and :folderroot Projects turns one folder's children into the columns (deeper notes roll up, the rest share Other folders). No more @status tokens derived from paths. Thanks @andradejoelwp (#730). + +## Tweet 3 + +⌨️ Home-row mods on Kanata, QMK or ZMK? The no-op key your tap-hold layer sends used to reset jk, dd and leader chords. Record it under Settings → Keymap → Ignored keys (or :ignorekey KanaMode, or ignored_keys in config.toml) and the app never sees it again. Thanks @potter1402 (#732). + +## Tweet 4 + +⌨️ You can now remove a shortcut instead of remapping it onto some chord you will never press. Every row in Settings → Keymap has an Unbind button: the row reads Unbound, nothing fires the action any more, and Reset brings the default back whenever you want it. + +## Tweet 5 + +Vim users: `:unbind vim.leaderOpenBuffers` does the same from the ex line, with a toast naming the key it took away. The leader hints, the command palette and the manual stop advertising it. Bare :unbind opens the Keymap page, which lists every action id. + +## Tweet 6 + +🧳 It travels with your dotfiles. An unbind is `"global.commandPalette" = ""` under [keymaps] in config.toml, the file explains the convention next to the table, and a hand edit applies live. + +## Tweet 7 + +🌐 Self-hosting? Custom templates now work in the web client and on a desktop connected to your ZenNotes server. Same .zennotes/templates/ folder as a local vault, same editor, and a template saved on one device shows up on the others through the change feed. Thanks @ajselzilic for the spec (#723). + +## Tweet 8 + +📅 The @ menu now ends with Date…: a keyboard-first calendar for any day, next to Today, Yesterday, Tomorrow and Now. Arrows move, PageUp/PageDown change the month, Enter inserts the ISO date where the @ was. Know the date? Type it outright. h j k l and t work with Vim mode on. Thanks @uNyanda (#743). + +## Tweet 9 + +🔖 Tasks filters you can keep. Save a query under a name and it becomes a chip under the Tasks header, a "Tasks: name" entry in the command palette, and :filter in the view; F opens a picker with Vim on. The list lives in config.toml as [saved_filters], so it diffs and syncs with your dotfiles. Thanks @andradejoelwp (#731). + +## Tweet 10 + +🛠 Fixed: on a vault in root mode, zn create and the MCP create_note tool filed new notes into inbox/ and vault_info said inbox. They now follow vault.json like the app does. Thanks @diazkev314 (#745). + +## Tweet 11 + +🧮 Fixed: a $$ display block inside a callout now renders in both the editor and the reading view, with Typst and KaTeX alike. It used to stay raw in the editor and, with Typst, swallow the rest of the note in the reading view. Thanks @OstrichDowneyJr (#748). + +## Tweet 12 + +📐 Fixed: Typst formulas were a fifth smaller than KaTeX's next to the same text, and a square root bar stayed black on dark themes. They now use the same 1.21 size factor KaTeX uses for Computer Modern, and every rule follows the text color. Thanks @cyperion (#746). + +Free, open source, local-first Markdown notes. +https://zennotes.org + +Issues closed: #723, #730, #731, #732, #738, #743, #745, #746, #748. diff --git a/package-lock.json b/package-lock.json index cd85b377..8c6eb4f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.45.0", + "version": "2.46.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.45.0", + "version": "2.46.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.45.0", + "version": "2.46.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,11 +874,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.45.0" + "version": "2.46.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.45.0", + "version": "2.46.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16286,7 +16286,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.45.0", + "version": "2.46.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16363,11 +16363,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.45.0" + "version": "2.46.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.45.0", + "version": "2.46.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16378,7 +16378,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.45.0" + "version": "2.46.0" } } } diff --git a/package.json b/package.json index 95f6d3c8..3a38ed97 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.45.0", + "version": "2.46.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 9ffe9115..eec3d378 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.45.0", + "version": "2.46.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 336e6f8d..3864e36d 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -20,6 +20,7 @@ import { NoteList } from './components/NoteList' import { TitleBar } from './components/TitleBar' import { PromptHost } from './components/PromptHost' import { ConfirmHost } from './components/ConfirmHost' +import { DatePickerHost } from './components/DatePickerHost' import { PublishNoteHost } from './components/PublishNoteHost' import { CloudConflictReviewHost } from './components/CloudConflictReviewHost' import { ServerDirectoryPickerHost } from './components/ServerDirectoryPickerHost' @@ -59,6 +60,13 @@ import { } from './lib/app-update-state' import { ensureCloudAutoSyncStarted, stopCloudAutoSync } from './lib/cloud-auto-sync' import { installHarperRuntime } from './lib/harper-runtime' +import { installIgnoredKeysGuard } from './lib/ignored-keys' + +// The ignored-keys guard (#732) has to be on `window` before any component +// registers a capture listener there, so it is installed at import time, +// not from an effect: children's effects run before App's, and VimNav's +// would otherwise see the stray key first. Reads the live list on each key. +installIgnoredKeysGuard(() => useStore.getState().ignoredKeys) let editorModulePromise: Promise | null = null const EDITOR_MODULE_WARMUP_GRACE_MS = 40 @@ -1123,6 +1131,7 @@ function App(): JSX.Element { + @@ -1141,6 +1150,7 @@ function App(): JSX.Element { + @@ -1214,6 +1224,7 @@ function App(): JSX.Element { )} + diff --git a/packages/app-core/src/components/CommentsPanel.tsx b/packages/app-core/src/components/CommentsPanel.tsx index 73fe6b4b..0c51d180 100644 --- a/packages/app-core/src/components/CommentsPanel.tsx +++ b/packages/app-core/src/components/CommentsPanel.tsx @@ -10,6 +10,7 @@ import { import type { NoteComment, NoteContent } from '@shared/ipc' import { useStore } from '../store' import { commentQuote } from '../lib/comments' +import { threadNoteComments, type NoteCommentThread } from '@shared/note-comments' import { renderMarkdown } from '../lib/markdown' import { usePanelResize } from '../lib/use-panel-resize' import { PanelResizeHandle } from './PanelResizeHandle' @@ -98,6 +99,8 @@ export function CommentsPanel({ setBody('') setEditingId(null) setEditBody('') + setReplyingId(null) + setReplyBody('') onClearDraft() }, [note.path]) @@ -111,15 +114,23 @@ export function CommentsPanel({ return () => cancelAnimationFrame(raf) }, [activeCommentId, comments]) + // Threads (#738): a top-level comment with its replies. The panel's rows are + // the threads; a reply lives inside its card, so j/k walk conversations. + const threads = useMemo(() => threadNoteComments(comments), [comments]) const unresolved = useMemo( - () => comments.filter((comment) => comment.resolvedAt == null), - [comments] + () => threads.filter((thread) => thread.comment.resolvedAt == null), + [threads] ) const resolved = useMemo( - () => comments.filter((comment) => comment.resolvedAt != null), - [comments] + () => threads.filter((thread) => thread.comment.resolvedAt != null), + [threads] ) - const orderedComments = useMemo(() => [...unresolved, ...resolved], [resolved, unresolved]) + const orderedComments = useMemo( + () => [...unresolved, ...resolved].map((thread) => thread.comment), + [resolved, unresolved] + ) + const [replyingId, setReplyingId] = useState(null) + const [replyBody, setReplyBody] = useState('') useEffect(() => { if (!commentsFocused) return @@ -174,6 +185,31 @@ export function CommentsPanel({ setEditBody('') } + const startReply = (thread: NoteCommentThread): void => { + setReplyingId(thread.comment.id) + setReplyBody('') + setActiveCommentId(thread.comment.id) + } + + // A reply keeps the thread's anchor, so the editor keeps one marker per + // conversation and re-anchoring moves the whole thread together. + const submitReply = async (thread: NoteCommentThread): Promise => { + const trimmed = replyBody.trim() + if (!trimmed) return + const root = thread.comment + await addNoteComment({ + notePath: note.path, + anchorStart: root.anchorStart, + anchorEnd: root.anchorEnd, + anchorText: root.anchorText, + body: trimmed, + parentId: root.id + }) + setReplyingId(null) + setReplyBody('') + setActiveCommentId(root.id) + } + let rowIndex = 0 return ( @@ -223,6 +259,7 @@ export function CommentsPanel({ + @@ -291,30 +328,40 @@ export function CommentsPanel({ ) : (
- {unresolved.map((comment) => ( + {unresolved.map((thread) => ( startReply(thread)} + onCancelReply={() => { + setReplyingId(null) + setReplyBody('') + }} + onSubmitReply={() => void submitReply(thread)} onJump={() => { - setActiveCommentId(comment.id) - onJump(comment) + setActiveCommentId(thread.comment.id) + onJump(thread.comment) }} - onEdit={() => startEdit(comment)} + onEdit={() => startEdit(thread.comment)} onCancelEdit={() => { setEditingId(null) setEditBody('') }} - onSave={() => void saveEdit(comment)} + onSave={() => void saveEdit(thread.comment)} onResolve={() => - void updateNoteComment(note.path, comment.id, { resolvedAt: Date.now() }) + void updateNoteComment(note.path, thread.comment.id, { resolvedAt: Date.now() }) } - onDelete={() => void deleteNoteComment(note.path, comment.id)} + onDelete={() => void deleteNoteComment(note.path, thread.comment.id)} /> ))} {resolved.length > 0 && unresolved.length > 0 && ( @@ -322,30 +369,40 @@ export function CommentsPanel({ Resolved
)} - {resolved.map((comment) => ( + {resolved.map((thread) => ( startReply(thread)} + onCancelReply={() => { + setReplyingId(null) + setReplyBody('') + }} + onSubmitReply={() => void submitReply(thread)} onJump={() => { - setActiveCommentId(comment.id) - onJump(comment) + setActiveCommentId(thread.comment.id) + onJump(thread.comment) }} - onEdit={() => startEdit(comment)} + onEdit={() => startEdit(thread.comment)} onCancelEdit={() => { setEditingId(null) setEditBody('') }} - onSave={() => void saveEdit(comment)} + onSave={() => void saveEdit(thread.comment)} onResolve={() => - void updateNoteComment(note.path, comment.id, { resolvedAt: null }) + void updateNoteComment(note.path, thread.comment.id, { resolvedAt: null }) } - onDelete={() => void deleteNoteComment(note.path, comment.id)} + onDelete={() => void deleteNoteComment(note.path, thread.comment.id)} /> ))} @@ -355,14 +412,30 @@ export function CommentsPanel({ ) } +/** Display name and avatar letter: the vault's owner has no stored author. */ +function authorLabel(comment: Pick): string { + return comment.author?.trim() || 'You' +} + +function authorInitial(comment: Pick): string { + return authorLabel(comment).slice(0, 1).toUpperCase() +} + function CommentCard({ comment, + replies, rowIndex, active, commentsFocused, editing, editBody, onEditBody, + replying, + replyBody, + onReplyBody, + onReply, + onCancelReply, + onSubmitReply, onJump, onEdit, onCancelEdit, @@ -371,12 +444,19 @@ function CommentCard({ onDelete }: { comment: NoteComment + replies: NoteComment[] rowIndex: number active: boolean commentsFocused: boolean editing: boolean editBody: string onEditBody: (body: string) => void + replying: boolean + replyBody: string + onReplyBody: (body: string) => void + onReply: () => void + onCancelReply: () => void + onSubmitReply: () => void onJump: () => void onEdit: () => void onCancelEdit: () => void @@ -385,6 +465,7 @@ function CommentCard({ onDelete: () => void }): JSX.Element { const resolved = comment.resolvedAt != null + const assistant = !!comment.author // Render the comment body as Markdown (sanitized). Cached by renderMarkdown, // memoized per-body so card re-renders (hover/selection) don't re-parse. const bodyHtml = useMemo(() => renderMarkdown(comment.body), [comment.body]) @@ -412,12 +493,21 @@ function CommentCard({ ].join(' ')} >
-
- Y +
+ {authorInitial(comment)}
- You + + {authorLabel(comment)} + {dateFormatter.format(new Date(comment.updatedAt))} @@ -504,6 +594,78 @@ function CommentCard({ dangerouslySetInnerHTML={{ __html: bodyHtml }} /> )} + + {replies.length > 0 && ( +
+ {replies.map((reply) => ( + + ))} +
+ )} + + {replying && ( +
+