Skip to content

Commit f97a739

Browse files
authored
Merge branch 'main' into feat/asset-download
2 parents 3b10cc6 + a3e638f commit f97a739

183 files changed

Lines changed: 16797 additions & 993 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ jobs:
7777
- name: Install dependencies
7878
run: npm ci
7979

80+
- name: Carry the electron-builder keychain fix
81+
if: matrix.name == 'macOS'
82+
# electron-builder 26.15 and 26.16 hand `security set-key-partition-list`
83+
# the certificate's import password instead of the temporary keychain's
84+
# own password; the macOS 26.6 runner image verifies it and signing
85+
# dies with "SecKeychainUnlock: The user name or passphrase you entered
86+
# is not correct". The fix is merged upstream (electron-builder #10101)
87+
# but no v26 release carries it yet, so apply it to the installed copy.
88+
# The script is a no-op once the installed version has the fix.
89+
run: node tooling/scripts/patch-electron-builder-keychain.mjs
90+
8091
- name: Verify mac signing secrets
8192
if: matrix.name == 'macOS'
8293
shell: bash

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ RUN npm ci --no-audit --no-fund --loglevel=error
2828

2929
COPY apps apps
3030
COPY packages packages
31+
# The renderer configs import shared Vite plugins from tooling/vite (the
32+
# Harper wasm asset resolver); without this copy the web build cannot resolve them.
33+
COPY tooling/vite tooling/vite
3134

3235
RUN npm run build --workspace @zennotes/web
3336

apps/desktop/electron.vite.config.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { resolve } from 'node:path'
44
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
55
import type { Plugin } from 'vite'
66
import react from '@vitejs/plugin-react'
7+
import { harperWasmAsset } from '../../tooling/vite/harper-wasm-asset'
78

89
const INTERNAL_WORKSPACE_PACKAGES = [
910
'@zennotes/app-core',
@@ -250,12 +251,15 @@ export default defineConfig({
250251
renderer: {
251252
root: resolve(__dirname, 'src/renderer'),
252253
// Typst ships a WASM compiler loaded lazily via `?url` + dynamic import; keep
253-
// it out of the esbuild dep pre-bundler so the wasm glue stays intact.
254+
// it out of the esbuild dep pre-bundler so the wasm glue stays intact. The
255+
// same goes for Harper, whose worker is an inline blob the pre-bundler
256+
// would otherwise rewrite.
254257
optimizeDeps: {
255258
exclude: [
256259
'@myriaddreamin/typst.ts',
257260
'@myriaddreamin/typst-ts-web-compiler',
258-
'@myriaddreamin/typst-ts-renderer'
261+
'@myriaddreamin/typst-ts-renderer',
262+
'harper.js'
259263
]
260264
},
261265
build: {
@@ -281,6 +285,6 @@ export default defineConfig({
281285
'@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src')
282286
}
283287
},
284-
plugins: [onigurumaDataUrl(), react()]
288+
plugins: [onigurumaDataUrl(), harperWasmAsset(), react()]
285289
}
286290
})

apps/desktop/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@zennotes/desktop",
33
"productName": "ZenNotes",
4-
"version": "2.43.0",
4+
"version": "2.46.0",
55
"description": "ZenNotes desktop shell",
66
"private": true,
77
"main": "./out/main/index.js",
@@ -42,6 +42,7 @@
4242
"@codemirror/lang-markdown": "^6.3.1",
4343
"@codemirror/language": "^6.10.6",
4444
"@codemirror/language-data": "^6.5.1",
45+
"@codemirror/lint": "^6.9.7",
4546
"@codemirror/search": "^6.5.8",
4647
"@codemirror/state": "^6.5.0",
4748
"@codemirror/view": "^6.35.3",
@@ -63,6 +64,7 @@
6364
"font-list": "^2.0.2",
6465
"function-plot": "^1.25.3",
6566
"gray-matter": "^4.0.3",
67+
"harper.js": "^2.7.0",
6668
"highlight.js": "^11.10.0",
6769
"jsxgraph": "^1.12.2",
6870
"katex": "^0.16.15",

apps/desktop/src/cli/backend.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
prependToNote,
4141
readDatabaseVaultLayout,
4242
readNote,
43+
readNoteComments,
4344
readPrimaryNotesLocation,
4445
readVaultFileTextOrNull,
4546
renameFolder,
@@ -55,7 +56,10 @@ import {
5556
toggleTaskInBody,
5657
unarchiveNote,
5758
writeNote,
59+
writeNoteComments,
5860
writeVaultFileText,
61+
type NoteComment,
62+
type NoteCommentInput,
5963
type NoteContent,
6064
type NoteFolder,
6165
type NoteMeta,
@@ -148,6 +152,9 @@ export interface VaultBackend {
148152
backlinks(rel: string): Promise<NoteMeta[]>
149153
scanAllTasks(opts?: { includeExcluded?: boolean }): Promise<VaultTask[]>
150154
toggleTask(taskId: string): Promise<VaultTask | null>
155+
/** A note's comments as stored (#738); `writeComments` replaces the list. */
156+
listComments(rel: string): Promise<NoteComment[]>
157+
writeComments(rel: string, comments: NoteCommentInput[]): Promise<NoteComment[]>
151158
/** Database (`.base`) operations, composed from this backend's file IO via
152159
* @shared/database-ops — the same composition the web and desktop remote
153160
* clients use, so `zn base` writes the identical on-disk format. (#556) */
@@ -270,6 +277,9 @@ class LocalBackend implements VaultBackend {
270277
scanAllTasks = (opts?: { includeExcluded?: boolean }): Promise<VaultTask[]> =>
271278
scanAllTasks(this.root, opts)
272279
toggleTask = (taskId: string): Promise<VaultTask | null> => toggleTask(this.root, taskId)
280+
listComments = (rel: string): Promise<NoteComment[]> => readNoteComments(this.root, rel)
281+
writeComments = (rel: string, comments: NoteCommentInput[]): Promise<NoteComment[]> =>
282+
writeNoteComments(this.root, rel, comments)
273283

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

430+
listComments = (rel: string): Promise<NoteComment[]> =>
431+
this.client.readComments(normalizeRelPath(rel))
432+
writeComments = (rel: string, comments: NoteCommentInput[]): Promise<NoteComment[]> =>
433+
this.client.writeComments(normalizeRelPath(rel), comments)
434+
420435
/** No task-toggle endpoint exists, so the note is read, the same transform a
421436
* local toggle applies is applied here, and the server re-parses the result
422437
* — which keeps the Go and TypeScript task parsers honest with each other. */
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { promises as fsp } from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { createBackend, type VaultBackend } from '../backend'
6+
import type { ParsedArgs } from '../args'
7+
import { cmdCommentAdd, cmdCommentList, cmdCommentReply, cmdCommentResolve } from './comments'
8+
9+
// `zn comment` (#738) against a folder vault: the same sidecar the app and
10+
// the MCP tools read, so a thread started here shows up in the panel.
11+
12+
function args(positionals: string[], flags: Array<[string, string]> = []): ParsedArgs {
13+
return { positionals, flags: new Map(flags.map(([k, v]) => [k, [v]])) }
14+
}
15+
16+
let root: string
17+
let backend: VaultBackend
18+
let out: string[]
19+
20+
beforeEach(async () => {
21+
root = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-comment-cli-'))
22+
await fsp.mkdir(path.join(root, 'inbox'), { recursive: true })
23+
await fsp.writeFile(path.join(root, 'inbox', 'Plan.md'), '# Plan\n\nShip the beta in October.\n')
24+
backend = createBackend({ kind: 'local', root })
25+
out = []
26+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
27+
out.push(String(chunk))
28+
return true
29+
})
30+
})
31+
32+
afterEach(async () => {
33+
vi.restoreAllMocks()
34+
await fsp.rm(root, { recursive: true, force: true })
35+
})
36+
37+
describe('zn comment', () => {
38+
it('adds, lists, answers and resolves a thread', async () => {
39+
await cmdCommentAdd(
40+
backend,
41+
args(['inbox/Plan.md', 'Still realistic?'], [['anchor', 'Ship the beta in October.']])
42+
)
43+
expect(out.join('')).toMatch(/Commented on inbox\/Plan\.md \(.+, line 3\)/)
44+
45+
out = []
46+
await cmdCommentList(backend, args(['inbox/Plan.md'], [['json', 'true']]))
47+
const threads = JSON.parse(out.join('')) as Array<{ id: string; author: string | null; line: number }>
48+
expect(threads).toHaveLength(1)
49+
expect(threads[0].author).toBeNull()
50+
expect(threads[0].line).toBe(3)
51+
52+
out = []
53+
await cmdCommentReply(
54+
backend,
55+
args(['inbox/Plan.md', threads[0].id, 'Yes, the blocker runs at night.'], [['author', 'Claude']])
56+
)
57+
expect(out.join('')).toContain('Replied in')
58+
59+
out = []
60+
await cmdCommentList(backend, args(['inbox/Plan.md']))
61+
const text = out.join('')
62+
expect(text).toContain('You')
63+
expect(text).toContain('> Ship the beta in October.')
64+
expect(text).toContain('Claude')
65+
expect(text).toContain('Yes, the blocker runs at night.')
66+
67+
out = []
68+
await cmdCommentResolve(backend, args(['inbox/Plan.md', threads[0].id]))
69+
expect(out.join('')).toContain('Resolved')
70+
out = []
71+
await cmdCommentList(backend, args(['inbox/Plan.md']))
72+
expect(out.join('')).toContain('No open comments')
73+
out = []
74+
await cmdCommentList(backend, args(['inbox/Plan.md'], [['all', 'true']]))
75+
expect(out.join('')).toContain('(resolved)')
76+
})
77+
78+
it('explains usage when the path or body is missing', async () => {
79+
await expect(cmdCommentAdd(backend, args([]))).rejects.toThrow(/Usage: zn comment add/)
80+
await expect(cmdCommentAdd(backend, args(['inbox/Plan.md']))).rejects.toThrow(/Usage: zn comment add/)
81+
await expect(cmdCommentReply(backend, args(['inbox/Plan.md']))).rejects.toThrow(/Usage: zn comment reply/)
82+
await expect(cmdCommentResolve(backend, args(['inbox/Plan.md']))).rejects.toThrow(/Usage: zn comment resolve/)
83+
})
84+
})
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* `zn comment ...` (#738): list, add, reply to and resolve the comments on a
3+
* note, the same operations the MCP tools expose, so a script or an agent
4+
* without MCP can join a review thread.
5+
*/
6+
7+
import type { VaultBackend } from '../backend.js'
8+
import { getBool, getString, type ParsedArgs } from '../args.js'
9+
import { emitJson, emitLine, emitOk } from '../format.js'
10+
import {
11+
addComment,
12+
listCommentThreads,
13+
replyToComment,
14+
resolveComment,
15+
type CommentThreadView
16+
} from '../../mcp/comment-ops.js'
17+
18+
function requirePath(args: ParsedArgs, usage: string): string {
19+
const rel = getString(args, 'path') ?? args.positionals[0]
20+
if (!rel) throw new Error(`Usage: ${usage}`)
21+
return rel
22+
}
23+
24+
function requireBody(args: ParsedArgs, positionalIndex: number, usage: string): string {
25+
const body = getString(args, 'body') ?? args.positionals[positionalIndex]
26+
if (!body || !body.trim()) throw new Error(`Usage: ${usage}`)
27+
return body
28+
}
29+
30+
function when(ms: number): string {
31+
return new Date(ms).toISOString().replace('T', ' ').slice(0, 16)
32+
}
33+
34+
function printThread(thread: CommentThreadView): void {
35+
const who = thread.author ?? 'You'
36+
const state = thread.resolved ? ' (resolved)' : ''
37+
emitLine(`${thread.id} ${who} ${when(thread.createdAt)} line ${thread.line}${state}`)
38+
if (thread.anchorText) emitLine(` > ${thread.anchorText}`)
39+
emitLine(` ${thread.body.replace(/\n/g, '\n ')}`)
40+
for (const reply of thread.replies) {
41+
emitLine(` ${reply.id} ${reply.author ?? 'You'} ${when(reply.createdAt)}`)
42+
emitLine(` ${reply.body.replace(/\n/g, '\n ')}`)
43+
}
44+
}
45+
46+
export async function cmdCommentList(vault: VaultBackend, args: ParsedArgs): Promise<void> {
47+
const rel = requirePath(args, 'zn comment list <path> [--all] [--json]')
48+
const threads = await listCommentThreads(vault, rel, { includeResolved: getBool(args, 'all') })
49+
if (getBool(args, 'json')) {
50+
emitJson(threads)
51+
return
52+
}
53+
if (threads.length === 0) {
54+
emitLine(getBool(args, 'all') ? 'No comments.' : 'No open comments. Pass --all to include resolved ones.')
55+
return
56+
}
57+
threads.forEach((thread, index) => {
58+
if (index > 0) emitLine('')
59+
printThread(thread)
60+
})
61+
}
62+
63+
export async function cmdCommentAdd(vault: VaultBackend, args: ParsedArgs): Promise<void> {
64+
const usage = 'zn comment add <path> "<body>" [--anchor "<text from the note>"] [--author <name>]'
65+
const rel = requirePath(args, usage)
66+
const body = requireBody(args, 1, usage)
67+
const thread = await addComment(vault, {
68+
path: rel,
69+
body,
70+
anchorText: getString(args, 'anchor'),
71+
author: getString(args, 'author')
72+
})
73+
if (getBool(args, 'json')) {
74+
emitJson(thread)
75+
return
76+
}
77+
emitOk(`Commented on ${rel} (${thread.id}${thread.anchorText ? `, line ${thread.line}` : ''})`)
78+
}
79+
80+
export async function cmdCommentReply(vault: VaultBackend, args: ParsedArgs): Promise<void> {
81+
const usage = 'zn comment reply <path> <id> "<body>" [--author <name>]'
82+
const rel = requirePath(args, usage)
83+
const id = getString(args, 'id') ?? args.positionals[1]
84+
if (!id) throw new Error(`Usage: ${usage}`)
85+
const body = requireBody(args, 2, usage)
86+
const thread = await replyToComment(vault, { path: rel, id, body, author: getString(args, 'author') })
87+
if (getBool(args, 'json')) {
88+
emitJson(thread)
89+
return
90+
}
91+
emitOk(`Replied in ${thread.id} on ${rel} (${thread.replies.length} ${thread.replies.length === 1 ? 'reply' : 'replies'})`)
92+
}
93+
94+
export async function cmdCommentResolve(vault: VaultBackend, args: ParsedArgs): Promise<void> {
95+
const usage = 'zn comment resolve <path> <id> [--reopen]'
96+
const rel = requirePath(args, usage)
97+
const id = getString(args, 'id') ?? args.positionals[1]
98+
if (!id) throw new Error(`Usage: ${usage}`)
99+
const reopen = getBool(args, 'reopen')
100+
const thread = await resolveComment(vault, { path: rel, id, resolved: !reopen })
101+
if (getBool(args, 'json')) {
102+
emitJson(thread)
103+
return
104+
}
105+
emitOk(`${reopen ? 'Reopened' : 'Resolved'} ${thread.id} on ${rel}`)
106+
}

apps/desktop/src/cli/help.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [
138138
{ name: 'task toggle <id>', description: 'Flip a task checkbox by stable id' }
139139
]
140140
},
141+
{
142+
heading: 'COMMENTS',
143+
rows: [
144+
{ name: 'comment list <path>', description: 'Comment threads on a note, with anchors and replies', flags: '--all --json' },
145+
{ name: 'comment add <path> "<body>"', description: 'Start a thread, optionally anchored to text from the note', flags: '--anchor <text> --author <name> --json' },
146+
{ name: 'comment reply <path> <id> "<body>"', description: 'Answer in a thread', flags: '--author <name> --json' },
147+
{ name: 'comment resolve <path> <id>', description: 'Resolve a thread (or reopen it)', flags: '--reopen --json' }
148+
]
149+
},
141150
{
142151
heading: 'VAULT',
143152
rows: [
@@ -203,6 +212,8 @@ const EXAMPLES: string[] = [
203212
'zn list --server home # a self-hosted ZenNotes server',
204213
'zn capture "from CI" --server https://notes.example.com',
205214
'zn task list --unchecked --tag work',
215+
'zn comment list inbox/Plan.md',
216+
'zn comment reply inbox/Plan.md <id> "Agreed, fixed in the second paragraph." --author Claude',
206217
'zn open ~/Downloads/notes.md',
207218
'zn open ~/code/project/docs # focus a folder as a session'
208219
]

apps/desktop/src/cli/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ import {
4545
cmdFolderRename
4646
} from './commands/folders.js'
4747
import { cmdTaskList, cmdTaskToggle } from './commands/tasks.js'
48+
import {
49+
cmdCommentAdd,
50+
cmdCommentList,
51+
cmdCommentReply,
52+
cmdCommentResolve
53+
} from './commands/comments.js'
4854
import { cmdTagFind, cmdTagList } from './commands/tags.js'
4955
import { cmdVaultInfo, cmdVaultList } from './commands/vault.js'
5056
import { cmdCapture } from './commands/capture.js'
@@ -132,6 +138,10 @@ async function main(argv: string[]): Promise<number> {
132138
'tag find': cmdTagFind,
133139
'task list': cmdTaskList,
134140
'task toggle': cmdTaskToggle,
141+
'comment list': cmdCommentList,
142+
'comment add': cmdCommentAdd,
143+
'comment reply': cmdCommentReply,
144+
'comment resolve': cmdCommentResolve,
135145
'vault info': cmdVaultInfo,
136146
'base list': cmdBaseList,
137147
'base create': cmdBaseCreate,
@@ -163,6 +173,7 @@ function peelSubcommand(
163173
folder: ['list', 'create', 'rename', 'delete'],
164174
tag: ['list', 'find'],
165175
task: ['list', 'toggle'],
176+
comment: ['list', 'add', 'reply', 'resolve'],
166177
vault: ['info', 'list'],
167178
base: ['list', 'create', 'rows', 'get', 'add', 'set']
168179
}

0 commit comments

Comments
 (0)