diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index cf33dca7..72b81d3e 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -4,6 +4,7 @@ import { resolve } from 'node:path' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import type { Plugin } from 'vite' import react from '@vitejs/plugin-react' +import { harperWasmAsset } from '../../tooling/vite/harper-wasm-asset' const INTERNAL_WORKSPACE_PACKAGES = [ '@zennotes/app-core', @@ -250,12 +251,15 @@ export default defineConfig({ renderer: { root: resolve(__dirname, 'src/renderer'), // Typst ships a WASM compiler loaded lazily via `?url` + dynamic import; keep - // it out of the esbuild dep pre-bundler so the wasm glue stays intact. + // it out of the esbuild dep pre-bundler so the wasm glue stays intact. The + // same goes for Harper, whose worker is an inline blob the pre-bundler + // would otherwise rewrite. optimizeDeps: { exclude: [ '@myriaddreamin/typst.ts', '@myriaddreamin/typst-ts-web-compiler', - '@myriaddreamin/typst-ts-renderer' + '@myriaddreamin/typst-ts-renderer', + 'harper.js' ] }, build: { @@ -281,6 +285,6 @@ export default defineConfig({ '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src') } }, - plugins: [onigurumaDataUrl(), react()] + plugins: [onigurumaDataUrl(), harperWasmAsset(), react()] } }) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fd38ddb2..c68646ad 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.43.0", + "version": "2.44.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", @@ -42,6 +42,7 @@ "@codemirror/lang-markdown": "^6.3.1", "@codemirror/language": "^6.10.6", "@codemirror/language-data": "^6.5.1", + "@codemirror/lint": "^6.9.7", "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", @@ -63,6 +64,7 @@ "font-list": "^2.0.2", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", + "harper.js": "^2.7.0", "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index b0f167d6..da3ebf41 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -205,6 +205,16 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'typst_tag_preambles', comment: 'true | false — prepend Typst definitions from notes in a `typst` folder, chosen by a note\'s tags' }, + harperEnabled: { + section: 'editor', + tomlKey: 'harper_enabled', + comment: 'true | false: grammar and spelling with Harper, checked on this device' + }, + harperDialect: { + section: 'editor', + tomlKey: 'harper_dialect', + comment: 'american | british | australian | canadian | indian' + }, looseMathDelimiters: { section: 'editor', tomlKey: 'loose_math_delimiters', diff --git a/apps/desktop/src/main/atomic-write.ts b/apps/desktop/src/main/atomic-write.ts new file mode 100644 index 00000000..a66e4a6d --- /dev/null +++ b/apps/desktop/src/main/atomic-write.ts @@ -0,0 +1,47 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +const ATOMIC_RENAME_ATTEMPTS = 20 + +function transientRenameError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'EACCES' || code === 'EPERM' || code === 'EBUSY' +} + +/** Wait out a reader that temporarily denies replacing the destination. */ +export async function renameWithRetry( + from: string, + to: string, + rename: (from: string, to: string) => Promise = fs.rename, + pause: (delayMs: number) => Promise = (delayMs) => + new Promise((resolve) => setTimeout(resolve, delayMs)) +): Promise { + for (let attempt = 1; ; attempt++) { + try { + await rename(from, to) + return + } catch (error) { + if (attempt >= ATOMIC_RENAME_ATTEMPTS || !transientRenameError(error)) throw error + await pause(Math.min(2 ** (attempt - 1), 25)) + } + } +} + +/** Follow a symlink to the file it points at, so an atomic write lands on the + * target instead of replacing the link. A dangling link resolves to the path + * it names, which is where a plain write would have created the file. */ +export async function atomicWriteTarget(absPath: string): Promise { + let stats + try { + stats = await fs.lstat(absPath) + } catch { + return absPath + } + if (!stats.isSymbolicLink()) return absPath + try { + return await fs.realpath(absPath) + } catch { + return path.resolve(path.dirname(absPath), await fs.readlink(absPath)) + } +} + diff --git a/apps/desktop/src/main/cloud-sync-filesystem.test.ts b/apps/desktop/src/main/cloud-sync-filesystem.test.ts index 76d00c43..c7ad71ff 100644 --- a/apps/desktop/src/main/cloud-sync-filesystem.test.ts +++ b/apps/desktop/src/main/cloud-sync-filesystem.test.ts @@ -1,12 +1,20 @@ import { afterEach, describe, expect, it } from 'vitest' -import { mkdtemp, readdir, readFile, rm, writeFile, mkdir } from 'node:fs/promises' +import { + chmod, + lstat, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + writeFile, + mkdir +} from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { createHash } from 'node:crypto' -import { - DesktopCloudSyncRepository, - DesktopCloudSyncStateStore -} from './cloud-sync-filesystem' +import { DesktopCloudSyncRepository, DesktopCloudSyncStateStore } from './cloud-sync-filesystem' import { CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES, cloudSyncUploadSource @@ -65,7 +73,9 @@ describe('DesktopCloudSyncRepository', () => { const root = await temporaryRoot() await mkdir(path.join(root, '.zennotes', 'sync'), { recursive: true }) await mkdir(path.join(root, '.git'), { recursive: true }) - await mkdir(path.join(root, 'node_modules', 'package'), { recursive: true }) + await mkdir(path.join(root, 'node_modules', 'package'), { + recursive: true + }) await writeFile(path.join(root, 'note.md'), '# Note') await writeFile(path.join(root, 'image.png'), Buffer.from([0, 1, 2, 3])) await writeFile(path.join(root, '.zennotes', 'sync', 'state.json'), '{}') @@ -185,11 +195,7 @@ describe('DesktopCloudSyncRepository', () => { }) }) - // The local file is never overwritten, and the incoming version is never - // thrown away: it lands beside it. Sync used to throw here instead, which - // stopped the whole run and, because the cursor never advanced, stopped - // every run after it too (#585 follow-up, reported on Discord). - it('keeps both versions when a remote change meets a local edit', async () => { + it('returns both versions without writing a conflict note into the vault', async () => { const root = await temporaryRoot() await writeFile(path.join(root, 'note.md'), 'local edit') const repository = new DesktopCloudSyncRepository(root) @@ -199,13 +205,162 @@ describe('DesktopCloudSyncRepository', () => { tracked('note.md', 'old contents') ) - expect(conflict).toEqual({ + expect(conflict).toMatchObject({ code: 'LOCAL_EDIT_CONFLICT', path: 'note.md', - conflict_copy_path: 'note (cloud conflict).md' + conflict_copy_path: null, + local: { path: 'note.md', content: { data: 'local edit' } } + }) + expect(await readFile(path.join(root, 'note.md'), 'utf8')).toBe('local edit') + expect(await readdir(root)).toEqual(['note.md']) + }) + + it('applies an explicit Cloud choice only while the local version is unchanged', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'note.md'), 'local edit') + const repository = new DesktopCloudSyncRepository(root) + const conflict = { + code: 'BOOTSTRAP_CONTENT_CONFLICT' as const, + item_id: 'item-remote', + path: 'note.md', + local_sha256: hash('local edit'), + remote_sha256: hash('cloud edit') + } + + await repository.resolveBootstrapConflict({ + path: 'note.md', + expectedLocalSha256: conflict.local_sha256, + cloudContent: upsert('note.md', 'cloud edit').content!, + resolution: { conflict, choice: 'cloud' } + }) + + expect(await readFile(path.join(root, 'note.md'), 'utf8')).toBe('cloud edit') + await expect( + repository.resolveBootstrapConflict({ + path: 'note.md', + expectedLocalSha256: conflict.local_sha256, + cloudContent: upsert('note.md', 'cloud edit').content!, + resolution: { conflict, choice: 'cloud' } + }) + ).rejects.toThrow('changed on this device') + }) + + it('keeps both bootstrap versions under explicit paths', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'note.md'), 'local edit') + const repository = new DesktopCloudSyncRepository(root) + const conflict = { + code: 'BOOTSTRAP_CONTENT_CONFLICT' as const, + item_id: 'item-remote', + path: 'note.md', + local_sha256: hash('local edit'), + remote_sha256: hash('cloud edit') + } + + await repository.resolveBootstrapConflict({ + path: 'note.md', + expectedLocalSha256: conflict.local_sha256, + cloudContent: upsert('note.md', 'cloud edit').content!, + resolution: { + conflict, + choice: 'both', + keep_both_path: 'note (this device).md' + } + }) + + expect(await readFile(path.join(root, 'note.md'), 'utf8')).toBe('cloud edit') + expect(await readFile(path.join(root, 'note (this device).md'), 'utf8')).toBe('local edit') + }) + + it('writes an explicit merged bootstrap result', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'note.md'), 'local edit') + const repository = new DesktopCloudSyncRepository(root) + const conflict = { + code: 'BOOTSTRAP_CONTENT_CONFLICT' as const, + item_id: 'item-remote', + path: 'note.md', + local_sha256: hash('local edit'), + remote_sha256: hash('cloud edit') + } + + await repository.resolveBootstrapConflict({ + path: 'note.md', + expectedLocalSha256: conflict.local_sha256, + cloudContent: upsert('note.md', 'cloud edit').content!, + resolution: { conflict, choice: 'merged', merged_text: 'merged result' } + }) + + expect(await readFile(path.join(root, 'note.md'), 'utf8')).toBe('merged result') + }) + + it('materializes moved and keep-both decisions without overwriting another file', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'note.md'), 'local edit') + await writeFile(path.join(root, 'existing.md'), 'leave me alone') + const repository = new DesktopCloudSyncRepository(root) + + await repository.applyConflictResolutionFiles({ + expected_path: 'note.md', + expected_sha256: hash('local edit'), + files: [ + { + path: 'archive/note.md', + content: upsert('archive/note.md', 'cloud edit').content! + }, + { + path: 'note from Mac.md', + content: upsert('note from Mac.md', 'local edit').content! + } + ] }) + + await expect(readFile(path.join(root, 'note.md'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + expect(await readFile(path.join(root, 'archive', 'note.md'), 'utf8')).toBe('cloud edit') + expect(await readFile(path.join(root, 'note from Mac.md'), 'utf8')).toBe('local edit') + await expect( + repository.applyConflictResolutionFiles({ + expected_path: 'archive/note.md', + expected_sha256: hash('cloud edit'), + files: [ + { + path: 'existing.md', + content: upsert('existing.md', 'cloud edit').content! + } + ] + }) + ).rejects.toThrow('already exists') + expect(await readFile(path.join(root, 'archive', 'note.md'), 'utf8')).toBe('cloud edit') + expect(await readFile(path.join(root, 'existing.md'), 'utf8')).toBe('leave me alone') + }) + + it('removes newly created resolution files when a later write fails', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'note.md'), 'local edit') + await writeFile(path.join(root, 'blocked'), 'not a directory') + const repository = new DesktopCloudSyncRepository(root) + + await expect( + repository.applyConflictResolutionFiles({ + expected_path: 'note.md', + expected_sha256: hash('local edit'), + files: [ + { path: 'copy.md', content: upsert('copy.md', 'safe copy').content! }, + { + path: 'blocked/note.md', + content: upsert('blocked/note.md', 'fails').content! + } + ] + }) + ).rejects.toThrow() + expect(await readFile(path.join(root, 'note.md'), 'utf8')).toBe('local edit') - expect(await readFile(path.join(root, 'note (cloud conflict).md'), 'utf8')).toBe('remote edit') + expect(await readFile(path.join(root, 'blocked'), 'utf8')).toBe('not a directory') + await expect(readFile(path.join(root, 'copy.md'))).rejects.toMatchObject({ + code: 'ENOENT' + }) }) // What wedged the reporter: the change feed carried a file this device had @@ -229,7 +384,7 @@ describe('DesktopCloudSyncRepository', () => { expect(await readdir(path.join(root, '.zennotes'))).toEqual(['vault.json']) }) - it('numbers conflict copies instead of overwriting an earlier one', async () => { + it('leaves an existing legacy conflict copy untouched and creates no new one', async () => { const root = await temporaryRoot() await writeFile(path.join(root, 'note.md'), 'local edit') await writeFile(path.join(root, 'note (cloud conflict).md'), 'an earlier conflict') @@ -237,11 +392,11 @@ describe('DesktopCloudSyncRepository', () => { const conflict = await repository.apply(upsert('note.md', 'remote edit'), undefined) - expect(conflict?.conflict_copy_path).toBe('note (cloud conflict 2).md') + expect(conflict?.conflict_copy_path).toBeNull() expect(await readFile(path.join(root, 'note (cloud conflict).md'), 'utf8')).toBe( 'an earlier conflict' ) - expect(await readFile(path.join(root, 'note (cloud conflict 2).md'), 'utf8')).toBe('remote edit') + expect((await readdir(root)).sort()).toEqual(['note (cloud conflict).md', 'note.md']) }) // Settings are a question, not a merge: a numbered copy inside a hidden @@ -257,7 +412,7 @@ describe('DesktopCloudSyncRepository', () => { upsert('.zennotes/vault.json', '{"favorites":["b"]}'), undefined ) - expect(first).toEqual({ + expect(first).toMatchObject({ code: 'SETTINGS_CONFLICT', path: '.zennotes/vault.json', conflict_copy_path: '.zennotes/vault.cloud-conflict.json' @@ -270,9 +425,9 @@ describe('DesktopCloudSyncRepository', () => { // A newer cloud version replaces the pending one instead of piling up. await repository.apply(upsert('.zennotes/vault.json', '{"favorites":["c"]}'), undefined) - expect( - await readFile(path.join(root, '.zennotes', 'vault.cloud-conflict.json'), 'utf8') - ).toBe('{"favorites":["c"]}') + expect(await readFile(path.join(root, '.zennotes', 'vault.cloud-conflict.json'), 'utf8')).toBe( + '{"favorites":["c"]}' + ) expect((await readdir(path.join(root, '.zennotes'))).sort()).toEqual([ 'vault.cloud-conflict.json', 'vault.json' @@ -296,10 +451,11 @@ describe('DesktopCloudSyncRepository', () => { tracked('note.md', 'old contents') ) - expect(conflict).toEqual({ + expect(conflict).toMatchObject({ code: 'LOCAL_EDIT_CONFLICT', path: 'note.md', - conflict_copy_path: null + conflict_copy_path: null, + local: { content: { data: 'local edit' } } }) expect(await readFile(path.join(root, 'note.md'), 'utf8')).toBe('local edit') }) @@ -329,7 +485,12 @@ describe('DesktopCloudSyncStateStore', () => { const root = await temporaryRoot() const stateDirectory = path.join(root, 'user-data', 'cloud-sync') const store = new DesktopCloudSyncStateStore(stateDirectory) - const state = { version: 1 as const, vault_id: 'vault-1', cursor: 7, items: {} } + const state = { + version: 1 as const, + vault_id: 'vault-1', + cursor: 7, + items: {} + } await store.save(state) @@ -337,3 +498,73 @@ describe('DesktopCloudSyncStateStore', () => { expect(await store.load('another-vault')).toBeNull() }) }) + +describe('DesktopCloudSyncRepository: decisions and writes', () => { + it('copies a file above the inline limit from disk when keeping both versions', async () => { + const root = await temporaryRoot() + const bytes = Buffer.alloc(CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1, 7) + await writeFile(path.join(root, 'Deck.pdf'), bytes) + const repository = new DesktopCloudSyncRepository(root) + const [local] = await repository.scan() + expect(local.content.data).toBe('') + + await repository.applyConflictResolutionFiles({ + expected_path: 'Deck.pdf', + expected_sha256: local.content.sha256, + files: [ + { path: 'Deck.pdf', content: upsert('Deck.pdf', 'cloud bytes').content! }, + { path: 'Deck (this device).pdf', content: local.content } + ] + }) + + expect((await readFile(path.join(root, 'Deck (this device).pdf'))).equals(bytes)).toBe(true) + expect(await readFile(path.join(root, 'Deck.pdf'), 'utf8')).toBe('cloud bytes') + }, 20_000) + + it('refuses to write a snapshot that carries no bytes and no source', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'Deck.pdf'), 'agreed') + const repository = new DesktopCloudSyncRepository(root) + + await expect( + repository.replaceConflictFile({ + path: 'Deck.pdf', + expectedSha256: hash('agreed'), + content: { + encoding: 'base64', + data: '', + sha256: 'missing', + byte_length: 10, + media_type: 'application/pdf' + } + }) + ).rejects.toThrow('too large to copy') + expect(await readFile(path.join(root, 'Deck.pdf'), 'utf8')).toBe('agreed') + }) + + it('writes through a symlinked note and keeps the file mode', async () => { + const root = await temporaryRoot() + const elsewhere = await temporaryRoot() + const target = path.join(elsewhere, 'linked.md') + await writeFile(target, 'agreed') + await symlink(target, path.join(root, 'linked.md')) + await writeFile(path.join(root, 'private.md'), 'agreed') + await chmod(path.join(root, 'private.md'), 0o600) + const repository = new DesktopCloudSyncRepository(root) + + await repository.replaceConflictFile({ + path: 'linked.md', + expectedSha256: hash('agreed'), + content: upsert('linked.md', 'from cloud').content! + }) + await repository.replaceConflictFile({ + path: 'private.md', + expectedSha256: hash('agreed'), + content: upsert('private.md', 'from cloud').content! + }) + + expect((await lstat(path.join(root, 'linked.md'))).isSymbolicLink()).toBe(true) + expect(await readFile(target, 'utf8')).toBe('from cloud') + expect((await stat(path.join(root, 'private.md'))).mode & 0o777).toBe(0o600) + }) +}) diff --git a/apps/desktop/src/main/cloud-sync-filesystem.ts b/apps/desktop/src/main/cloud-sync-filesystem.ts index 647553a0..98e398d0 100644 --- a/apps/desktop/src/main/cloud-sync-filesystem.ts +++ b/apps/desktop/src/main/cloud-sync-filesystem.ts @@ -1,15 +1,16 @@ import { createHash, randomUUID } from 'node:crypto' import { constants as fsConstants, createReadStream, promises as fs } from 'node:fs' import path from 'node:path' +import { atomicWriteTarget, renameWithRetry } from './atomic-write' import type { + CloudSyncBootstrapConflictResolution, CloudSyncChange, - CloudSyncContent, - CloudSyncLocalConflict + CloudSyncContent } from '@zennotes/bridge-contract/cloud-sync' import { CLOUD_SYNC_SETTINGS_CONFLICT_PATH, CLOUD_SYNC_VAULT_SETTINGS_PATH, - cloudSyncConflictCopyPath, + cloudSyncPathKey, isCloudSyncVaultSettingsPath, normalizeCloudSyncPath, shouldSyncVaultPath, @@ -19,6 +20,7 @@ import { CloudSyncCoordinator, type CloudSyncRemote, type CloudSyncRepository, + type CloudSyncRepositoryConflict, type CloudSyncStateStore } from '@zennotes/shared-domain/cloud-sync-coordinator' import type { @@ -28,6 +30,7 @@ import type { } from '@zennotes/shared-domain/cloud-sync-engine' import { CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES, + cloudSyncUploadSource, rememberCloudSyncUploadSource } from './cloud-sync-upload-source' @@ -101,7 +104,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { async apply( change: CloudSyncChange, previous: CloudSyncTrackedItem | undefined - ): Promise { + ): Promise { const affectedPaths = [change.path, change.previous_path, previous?.path].filter( (path): path is string => typeof path === 'string' ) @@ -120,9 +123,14 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { guardPath === change.path ? [change.path] : [guardPath, change.path], previous ) - if (unvouched) return await this.keepBoth(change.path, decodeContent(change.content)) + if (unvouched) { + if (isCloudSyncVaultSettingsPath(change.path)) { + return await this.keepBoth(change.path, await decodeContent(change.content)) + } + return localConflict(unvouched, await this.localItemOrNull(unvouched)) + } - await this.write(change.path, decodeContent(change.content)) + await this.write(change.path, await decodeContent(change.content)) return } @@ -130,7 +138,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { const unvouched = await this.firstUnvouchedPath([previousPath], previous) // A delete or a move carries no content to park, so keeping the local file // where it is IS the preserved version. The next push re-uploads it. - if (unvouched) return localConflict(unvouched, null) + if (unvouched) return localConflict(unvouched, await this.localItemOrNull(unvouched)) if (change.type === 'delete') { await fs.rm(this.resolve(previousPath), { force: true }) @@ -147,8 +155,129 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { } await fs.mkdir(path.dirname(destination), { recursive: true }) - if (await exists(destination)) return localConflict(change.path, null) + if (await exists(destination)) { + return localConflict(change.path, await this.localItemOrNull(change.path)) + } + await fs.rename(source, destination) + } + + async resolveBootstrapConflict(input: { + path: string + expectedLocalSha256: string + cloudContent: CloudSyncContent + resolution: CloudSyncBootstrapConflictResolution + }): Promise { + const current = await this.readIfExists(input.path) + if (!current || sha256(current) !== input.expectedLocalSha256) { + throw new Error( + 'This file changed on this device. Sync again to compare the latest versions.' + ) + } + + if (input.resolution.choice === 'cloud') { + await this.write(input.path, await decodeContent(input.cloudContent)) + return + } + + if (input.resolution.choice === 'merged') { + if (input.cloudContent.encoding !== 'utf8' || input.resolution.merged_text === undefined) { + throw new Error('Only text conflicts can be merged.') + } + await this.write(input.path, Buffer.from(input.resolution.merged_text, 'utf8')) + return + } + + if (input.resolution.choice !== 'both') return + if (!input.resolution.keep_both_path) { + throw new Error('Choose a filename for this device’s version.') + } + + const originalPath = normalizeCloudSyncPath(input.path) + const localCopyPath = normalizeCloudSyncPath(input.resolution.keep_both_path) + if ( + !shouldSyncVaultPath(localCopyPath) || + cloudSyncPathKey(localCopyPath) === cloudSyncPathKey(originalPath) + ) { + throw new Error('Choose a different filename inside the synced vault.') + } + + const source = this.resolve(originalPath) + const destination = this.resolve(localCopyPath) + if (await exists(destination)) throw new Error(`${localCopyPath} already exists.`) + await fs.mkdir(path.dirname(destination), { recursive: true }) await fs.rename(source, destination) + try { + await this.write(originalPath, await decodeContent(input.cloudContent)) + } catch (error) { + await fs.rename(destination, source).catch(() => undefined) + throw error + } + } + + async replaceConflictFile(input: { + path: string + expectedSha256: string | null + content: CloudSyncContent | null + }): Promise { + const current = await this.readIfExists(input.path) + if ((current ? sha256(current) : null) !== input.expectedSha256) { + throw new Error( + 'This file changed on this device. Review the latest changes before continuing.' + ) + } + if (input.content === null) { + if (current) await fs.rm(this.resolve(input.path), { force: true }) + return + } + await this.write(input.path, await decodeContent(input.content)) + } + + async applyConflictResolutionFiles(input: { + expected_path: string | null + expected_sha256: string | null + files: Array<{ path: string; content: CloudSyncContent }> + }): Promise { + const expectedPath = input.expected_path ? normalizeCloudSyncPath(input.expected_path) : null + const current = expectedPath ? await this.readIfExists(expectedPath) : null + if ((current ? sha256(current) : null) !== input.expected_sha256) { + throw new Error( + 'This file changed on this device. Review the latest changes before continuing.' + ) + } + + const files = normalizedResolutionFiles(input.files) + const expectedKey = expectedPath ? cloudSyncPathKey(expectedPath) : null + for (const file of files) { + if (cloudSyncPathKey(file.path) === expectedKey) continue + if (await exists(this.resolve(file.path))) { + throw new Error(`${file.path} already exists. Choose another filename.`) + } + } + + // Write new destinations first so the original remains recoverable if a + // later filesystem operation fails. Each write itself is an atomic rename. + const ordered = [...files].sort((left, right) => + cloudSyncPathKey(left.path) === expectedKey + ? 1 + : cloudSyncPathKey(right.path) === expectedKey + ? -1 + : 0 + ) + const newPaths: string[] = [] + try { + for (const file of ordered) { + if (cloudSyncPathKey(file.path) !== expectedKey) newPaths.push(file.path) + await this.write(file.path, await decodeContent(file.content)) + } + if (expectedPath && !files.some((file) => cloudSyncPathKey(file.path) === expectedKey)) { + await fs.rm(this.resolve(expectedPath), { force: true }) + } + } catch (error) { + for (const createdPath of newPaths.reverse()) { + await fs.rm(this.resolve(createdPath), { force: true }).catch(() => undefined) + } + throw error + } } private async walk( @@ -197,6 +326,19 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { } } + private async localItemOrNull(relPath: string): Promise { + const absolutePath = this.resolve(relPath) + if (!(await exists(absolutePath))) return null + // Streams anything above the inline limit, the same as a scan, so a + // conflicted attachment never has to fit in memory twice over. + const content = await encodeFileContent(relPath, absolutePath) + return { + path: normalizeCloudSyncPath(relPath), + kind: content.encoding === 'utf8' ? 'text' : 'binary', + content + } + } + /** * The first of these paths holding a file sync cannot vouch for, meaning it * is not the exact bytes we last agreed on with the server. A file that is @@ -215,7 +357,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { } /** Park the incoming version beside the local file rather than over it. */ - private async keepBoth(relPath: string, bytes: Buffer): Promise { + private async keepBoth(relPath: string, bytes: Buffer): Promise { // Settings are answered, not merged: the newest cloud version replaces any // older pending one at a fixed path, and the app asks which side to keep. if (isCloudSyncVaultSettingsPath(relPath)) { @@ -223,24 +365,23 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { return { code: 'SETTINGS_CONFLICT', path: relPath, - conflict_copy_path: CLOUD_SYNC_SETTINGS_CONFLICT_PATH + conflict_copy_path: CLOUD_SYNC_SETTINGS_CONFLICT_PATH, + local: await this.localItemOrNull(relPath) } } - for (let attempt = 1; attempt <= 100; attempt++) { - const candidate = cloudSyncConflictCopyPath(relPath, attempt) - if (await exists(this.resolve(candidate))) continue - await this.write(candidate, bytes) - return localConflict(relPath, candidate) - } - // A hundred conflict copies of one file means something is looping. Keep - // the local file and report it rather than filling the vault. - return localConflict(relPath, null) + return localConflict(relPath, await this.localItemOrNull(relPath)) } private async write(relPath: string, bytes: Buffer): Promise { - const destination = this.resolve(relPath) + // The same two rules as the vault's own writer: a symlinked note keeps + // pointing at its target, and the file keeps its mode. + const destination = await atomicWriteTarget(this.resolve(relPath)) const temporaryPath = `${destination}.${process.pid}.${randomUUID()}.tmp` await fs.mkdir(path.dirname(destination), { recursive: true }) + const existingMode = await fs + .stat(destination) + .then((stats) => stats.mode & 0o777) + .catch(() => null) const handle = await fs.open( temporaryPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY @@ -258,7 +399,8 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { } try { - await fs.rename(temporaryPath, destination) + if (existingMode !== null) await fs.chmod(temporaryPath, existingMode) + await renameWithRetry(temporaryPath, destination) } catch (error) { await fs.rm(temporaryPath, { force: true }) throw error @@ -266,6 +408,25 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { } } +function normalizedResolutionFiles( + files: Array<{ path: string; content: CloudSyncContent }> +): Array<{ path: string; content: CloudSyncContent }> { + const normalized = files.map((file) => ({ + ...file, + path: normalizeCloudSyncPath(file.path) + })) + const keys = new Set() + for (const file of normalized) { + if (!shouldSyncVaultPath(file.path)) { + throw new Error('Choose a filename inside the synced vault.') + } + const key = cloudSyncPathKey(file.path) + if (keys.has(key)) throw new Error('Choose a different filename for each version.') + keys.add(key) + } + return normalized +} + export class DesktopCloudSyncStateStore implements CloudSyncStateStore { constructor(private readonly directory: string) {} @@ -393,10 +554,27 @@ function directUploadContent(relPath: string, absolutePath: string, bytes: Buffe ) } -function decodeContent(content: CloudSyncContent): Buffer { - if (content.encoding === 'utf8') return Buffer.from(content.data, 'utf8') - if (content.encoding === 'base64') return Buffer.from(content.data, 'base64') - throw new Error('Encrypted cloud sync content must be decrypted before filesystem apply') +async function decodeContent(content: CloudSyncContent): Promise { + if (content.encoding !== 'utf8' && content.encoding !== 'base64') { + throw new Error('Encrypted cloud sync content must be decrypted before filesystem apply') + } + if (content.data === '' && content.byte_length > 0) { + // A scan snapshot of a file above the inline limit carries no bytes; the + // file it was taken from is the source. Decoding it as empty once wrote a + // 0-byte file over a 6 MB attachment. + const source = cloudSyncUploadSource(content) + if (!source) { + throw new Error('This file is too large to copy from its sync snapshot. Sync again and retry.') + } + const bytes = await fs.readFile(source) + if (bytes.byteLength !== content.byte_length || sha256(bytes) !== content.sha256) { + throw new Error( + 'This file changed on this device. Review the latest changes before continuing.' + ) + } + return bytes + } + return Buffer.from(content.data, content.encoding) } function isText(relPath: string, bytes: Buffer): boolean { @@ -414,8 +592,11 @@ function mediaType(relPath: string, text: boolean): string { (text ? 'text/plain' : 'application/octet-stream') } -function localConflict(path: string, conflictCopyPath: string | null): CloudSyncLocalConflict { - return { code: 'LOCAL_EDIT_CONFLICT', path, conflict_copy_path: conflictCopyPath } +function localConflict( + path: string, + local: CloudSyncLocalItem | null +): CloudSyncRepositoryConflict { + return { code: 'LOCAL_EDIT_CONFLICT', path, conflict_copy_path: null, local } } function sha256(bytes: Buffer): string { diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts index 0d1a22af..d762bc21 100644 --- a/apps/desktop/src/main/cloud-sync-service.test.ts +++ b/apps/desktop/src/main/cloud-sync-service.test.ts @@ -1,8 +1,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' import os from 'node:os' import path from 'node:path' import type { CloudAccountStatus, + CloudSyncManifestResponse, CloudSyncMutationRequest, CloudSyncVault } from '@zennotes/bridge-contract/cloud-sync' @@ -14,9 +16,9 @@ const temporaryDirectories: string[] = [] afterEach(async () => { await Promise.all( - temporaryDirectories.splice(0).map((directory) => - rm(directory, { recursive: true, force: true }) - ) + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) ) }) @@ -68,8 +70,33 @@ async function setup( } })), deleteVault: vi.fn(async () => {}), - manifest: vi.fn(async () => ({ data: [], cursor: 0, next_page: null })), + manifest: vi.fn( + async (): Promise => ({ + data: [], + cursor: 0, + next_page: null + }) + ), changes: vi.fn(async () => ({ data: [], cursor: 0, has_more: false })), + revision: vi.fn(async (_vaultId: string, itemId: string, revision: number) => { + const data = 'Last synced copy' + return { + data: { + item_id: itemId, + revision, + path: 'note.md', + kind: 'text' as const, + deleted: false, + content: { + encoding: 'utf8' as const, + data, + sha256: createHash('sha256').update(data).digest('hex'), + byte_length: Buffer.byteLength(data), + media_type: 'text/markdown' + } + } + } + }), mutate: vi.fn(async (_vaultId: string, body: CloudSyncMutationRequest) => ({ acknowledged: body.mutations.map((mutation, index) => ({ operation_id: mutation.operation_id, @@ -296,6 +323,63 @@ describe('DesktopCloudSyncService', () => { expect(result).toMatchObject({ pulled: 0, pushed: 1, conflicts: [] }) }) + it('inspects and resolves a same-path first-sync conflict through the host service', async () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', + name: 'Notes', + cursor: 1, + created_at: '2026-08-10T12:00:00.000Z', + updated_at: '2026-08-10T12:00:00.000Z' + } + const { service, client, localRoot } = await setup([remoteVault]) + await service.link(localRoot, remoteVault.id) + await writeFile(path.join(localRoot, 'Note.md'), 'latest local edit') + const cloudText = 'older cloud edit' + const cloudHash = createHash('sha256').update(cloudText).digest('hex') + client.manifest.mockResolvedValue({ + data: [ + { + item_id: 'item-remote', + path: 'Note.md', + kind: 'text', + revision: 3, + sha256: cloudHash, + byte_length: Buffer.byteLength(cloudText), + media_type: 'text/markdown', + content: { + encoding: 'utf8', + data: cloudText, + sha256: cloudHash, + byte_length: Buffer.byteLength(cloudText), + media_type: 'text/markdown' + } + } + ], + cursor: 1, + next_page: null + }) + + const summary = await service.sync(localRoot) + const conflict = summary.pending_conflicts![0]! + await expect(service.getConflict(localRoot, conflict.id)).resolves.toMatchObject({ + local: { text: 'latest local edit' }, + cloud: { text: cloudText } + }) + + await service.resolveConflict(localRoot, { + conflict_id: conflict.id, + choice: 'cloud', + expected_local_sha256: createHash('sha256').update('latest local edit').digest('hex'), + expected_cloud_revision: 3 + }) + expect(await readFile(path.join(localRoot, 'Note.md'), 'utf8')).toBe(cloudText) + await expect(service.sync(localRoot)).resolves.toMatchObject({ + bootstrap_conflicts: [], + pending_conflicts: [], + pushed: 0 + }) + }) + it('deletes the remote vault before removing the local device link', async () => { const remoteVault: CloudSyncVault = { id: 'vault-1', @@ -329,6 +413,139 @@ describe('DesktopCloudSyncService', () => { expect(client.manifest).toHaveBeenCalledTimes(1) }) + it('waits for an active sync before saving a conflict draft', async () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', + name: 'Notes', + cursor: 1, + created_at: '2026-08-10T12:00:00.000Z', + updated_at: '2026-08-10T12:00:00.000Z' + } + const { service, client, localRoot } = await setup([remoteVault]) + await service.link(localRoot, remoteVault.id) + await writeFile(path.join(localRoot, 'Note.md'), 'latest local edit') + const cloudText = 'older cloud edit' + const cloudHash = createHash('sha256').update(cloudText).digest('hex') + client.manifest.mockResolvedValue({ + data: [ + { + item_id: 'item-remote', + path: 'Note.md', + kind: 'text', + revision: 3, + sha256: cloudHash, + byte_length: Buffer.byteLength(cloudText), + media_type: 'text/markdown', + content: { + encoding: 'utf8', + data: cloudText, + sha256: cloudHash, + byte_length: Buffer.byteLength(cloudText), + media_type: 'text/markdown' + } + } + ], + cursor: 1, + next_page: null + }) + const conflict = (await service.sync(localRoot)).pending_conflicts![0]! + + let releaseChanges!: (value: { data: []; cursor: number; has_more: false }) => void + client.changes.mockReset().mockImplementationOnce( + () => + new Promise((resolve) => { + releaseChanges = resolve + }) + ) + const syncing = service.sync(localRoot) + await vi.waitFor(() => expect(client.changes).toHaveBeenCalledOnce()) + let draftSaved = false + const saving = service.saveConflictDraft(localRoot, conflict.id, 'careful draft').then(() => { + draftSaved = true + }) + + await Promise.resolve() + expect(draftSaved).toBe(false) + releaseChanges({ data: [], cursor: 1, has_more: false }) + await syncing + await saving + + await expect(service.getConflict(localRoot, conflict.id)).resolves.toMatchObject({ + draft_text: 'careful draft' + }) + }) + + it('does not start a sync while a conflict decision is being saved', async () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', + name: 'Notes', + cursor: 1, + created_at: '2026-08-10T12:00:00.000Z', + updated_at: '2026-08-10T12:00:00.000Z' + } + const { service, client, localRoot } = await setup([remoteVault]) + await service.link(localRoot, remoteVault.id) + await writeFile(path.join(localRoot, 'Note.md'), 'latest local edit') + const cloudText = 'older cloud edit' + const cloudHash = createHash('sha256').update(cloudText).digest('hex') + client.manifest.mockResolvedValue({ + data: [ + { + item_id: 'item-remote', + path: 'Note.md', + kind: 'text', + revision: 3, + sha256: cloudHash, + byte_length: Buffer.byteLength(cloudText), + media_type: 'text/markdown', + content: { + encoding: 'utf8', + data: cloudText, + sha256: cloudHash, + byte_length: Buffer.byteLength(cloudText), + media_type: 'text/markdown' + } + } + ], + cursor: 1, + next_page: null + }) + const conflict = (await service.sync(localRoot)).pending_conflicts![0]! + + let releaseMutation!: () => void + client.mutate.mockImplementationOnce(async (_vaultId, body) => { + await new Promise((resolve) => { + releaseMutation = resolve + }) + return { + acknowledged: body.mutations.map((mutation) => ({ + operation_id: mutation.operation_id, + item_id: mutation.item_id, + revision: 4, + sequence: 2 + })), + conflicts: [], + cursor: 2 + } + }) + const resolving = service.resolveConflict(localRoot, { + conflict_id: conflict.id, + choice: 'local', + expected_local_sha256: createHash('sha256').update('latest local edit').digest('hex'), + expected_cloud_revision: 3 + }) + await vi.waitFor(() => expect(client.mutate).toHaveBeenCalledOnce()) + + client.changes.mockClear() + const syncing = service.sync(localRoot) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(client.changes).not.toHaveBeenCalled() + + releaseMutation() + await resolving + await syncing + }) + it('manages backups only through the linked cloud vault', async () => { const remoteVault: CloudSyncVault = { id: 'vault-1', @@ -341,7 +558,9 @@ describe('DesktopCloudSyncService', () => { await service.link(localRoot, remoteVault.id) await expect(service.listBackups(localRoot)).resolves.toEqual([]) - await expect(service.backupSchedule(localRoot)).resolves.toMatchObject({ enabled: false }) + await expect(service.backupSchedule(localRoot)).resolves.toMatchObject({ + enabled: false + }) await expect(service.updateBackupSchedule(localRoot, true)).resolves.toMatchObject({ enabled: true }) @@ -380,11 +599,12 @@ describe('DesktopCloudSyncService', () => { created_at: '2026-08-10T12:00:00.000Z', updated_at: '2026-08-10T12:00:00.000Z' } - const fetchImplementation = vi.fn(async () => - new Response(new Uint8Array([31, 139, 8, 0]), { - status: 200, - headers: { 'Content-Type': 'application/gzip' } - }) + const fetchImplementation = vi.fn( + async () => + new Response(new Uint8Array([31, 139, 8, 0]), { + status: 200, + headers: { 'Content-Type': 'application/gzip' } + }) ) const { service, localRoot } = await setup([remoteVault], fetchImplementation) const destination = path.join(localRoot, 'backup.json.gz') @@ -392,11 +612,13 @@ describe('DesktopCloudSyncService', () => { await service.downloadBackup(localRoot, 'backup-1', destination) - expect([...await readFile(destination)]).toEqual([31, 139, 8, 0]) + expect([...(await readFile(destination))]).toEqual([31, 139, 8, 0]) expect(fetchImplementation).toHaveBeenCalledWith( 'https://zennotes.org/api/v1/vaults/vault-1/backups/backup-1/download', expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer secret-token' }) + headers: expect.objectContaining({ + Authorization: 'Bearer secret-token' + }) }) ) }) @@ -424,7 +646,8 @@ describe('DesktopCloudSyncService', () => { local_sha256: 'a'.repeat(64), remote_sha256: 'b'.repeat(64) } - ], local_conflicts: [] + ], + local_conflicts: [] }) await expect(service.createBackup(localRoot)).rejects.toThrow('Resolve sync conflicts') diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts index 07b89c69..fe6a2927 100644 --- a/apps/desktop/src/main/cloud-sync-service.ts +++ b/apps/desktop/src/main/cloud-sync-service.ts @@ -12,6 +12,11 @@ import type { CloudPublishedNoteResult, CloudPublishNoteInput, CloudServiceAccount, + CloudSyncBootstrapConflict, + CloudSyncBootstrapConflictDetails, + CloudSyncBootstrapConflictResolution, + CloudSyncPendingConflictDetails, + CloudSyncPendingConflictResolution, CloudSyncRunSummary, CloudSyncSettingsChoice, CloudSyncSettingsConflict, @@ -40,6 +45,7 @@ type SyncClient = Pick< | 'deleteVault' | 'manifest' | 'changes' + | 'revision' | 'mutate' | 'listBackups' | 'backupSchedule' @@ -65,6 +71,7 @@ export interface DesktopCloudSyncServiceDependencies { /** Main-process orchestration for linking one local vault to one cloud vault. */ export class DesktopCloudSyncService { private readonly runs = new Map>() + private readonly operations = new Map>() private readonly now: () => Date private readonly fetchImplementation: typeof fetch @@ -288,7 +295,7 @@ export class DesktopCloudSyncService { const existing = this.runs.get(runKey) if (existing) return existing - const running = this.run(localRoot).finally(() => { + const running = this.exclusive(runKey, () => this.run(localRoot)).finally(() => { this.runs.delete(runKey) }) this.runs.set(runKey, running) @@ -321,10 +328,125 @@ export class DesktopCloudSyncService { pushed: result.pushed, conflicts: result.conflicts, bootstrap_conflicts: result.bootstrapConflicts, - local_conflicts: result.localConflicts + local_conflicts: result.localConflicts, + pending_conflicts: result.pendingConflicts, + legacy_conflict_copies: result.legacyConflictCopies } } + async getBootstrapConflict( + localRoot: string, + conflict: CloudSyncBootstrapConflict + ): Promise { + return await this.exclusive(path.resolve(localRoot), async () => { + const { account, client, link } = await this.linkedConnection(localRoot) + return await createDesktopCloudSyncCoordinator({ + root: localRoot, + stateDirectory: path.join( + this.dependencies.storageDirectory, + 'states', + rootFingerprint(localRoot), + fingerprint(account.base_url) + ), + vaultId: link.vault_id, + remote: client + }).getBootstrapConflict(conflict) + }) + } + + async resolveBootstrapConflict( + localRoot: string, + resolution: CloudSyncBootstrapConflictResolution + ): Promise { + await this.exclusive(path.resolve(localRoot), async () => { + const { account, client, link } = await this.linkedConnection(localRoot) + await createDesktopCloudSyncCoordinator({ + root: localRoot, + stateDirectory: path.join( + this.dependencies.storageDirectory, + 'states', + rootFingerprint(localRoot), + fingerprint(account.base_url) + ), + vaultId: link.vault_id, + remote: client + }).resolveBootstrapConflict(resolution) + }) + } + + async getConflict( + localRoot: string, + conflictId: string + ): Promise { + return await this.exclusive(path.resolve(localRoot), async () => { + const { account, client, link } = await this.linkedConnection(localRoot) + return await createDesktopCloudSyncCoordinator({ + root: localRoot, + stateDirectory: path.join( + this.dependencies.storageDirectory, + 'states', + rootFingerprint(localRoot), + fingerprint(account.base_url) + ), + vaultId: link.vault_id, + remote: client + }).getConflict(conflictId) + }) + } + + async saveConflictDraft( + localRoot: string, + conflictId: string, + draftText: string | null + ): Promise { + await this.exclusive(path.resolve(localRoot), async () => { + const { account, client, link } = await this.linkedConnection(localRoot) + await createDesktopCloudSyncCoordinator({ + root: localRoot, + stateDirectory: path.join( + this.dependencies.storageDirectory, + 'states', + rootFingerprint(localRoot), + fingerprint(account.base_url) + ), + vaultId: link.vault_id, + remote: client + }).saveConflictDraft(conflictId, draftText) + }) + } + + async resolveConflict( + localRoot: string, + resolution: CloudSyncPendingConflictResolution + ): Promise { + await this.exclusive(path.resolve(localRoot), async () => { + const { account, client, link } = await this.linkedConnection(localRoot) + await createDesktopCloudSyncCoordinator({ + root: localRoot, + stateDirectory: path.join( + this.dependencies.storageDirectory, + 'states', + rootFingerprint(localRoot), + fingerprint(account.base_url) + ), + vaultId: link.vault_id, + remote: client + }).resolveConflict(resolution) + }) + } + + private exclusive(key: string, operation: () => Promise): Promise { + const previous = this.operations.get(key) + let current!: Promise + current = (previous ? previous.catch(() => undefined) : Promise.resolve()) + .then(operation) + .finally(() => { + if (this.operations.get(key) === current) this.operations.delete(key) + }) + this.operations.set(key, current) + return current + } + /** The pending settings question, if sync parked a cloud version. It lives * in the vault rather than in memory, so closing the app does not answer * it by accident. */ @@ -463,7 +585,11 @@ function isCloudVaultLink(value: unknown): value is CloudVaultLink { } function assertBackupReady(summary: CloudSyncRunSummary): void { - if (summary.conflicts.length > 0 || summary.bootstrap_conflicts.length > 0) { + if ( + summary.conflicts.length > 0 || + summary.bootstrap_conflicts.length > 0 || + (summary.pending_conflicts?.length ?? 0) > 0 + ) { throw new Error('Resolve sync conflicts before creating a cloud backup.') } } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 9db6ac09..19cbc263 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -25,6 +25,9 @@ import { createRequire } from "node:module"; import { IPC } from "@shared/ipc"; import type { CloudPublishNoteInput, + CloudSyncBootstrapConflict, + CloudSyncBootstrapConflictResolution, + CloudSyncPendingConflictResolution, CloudSyncSettingsChoice, } from "@zennotes/bridge-contract/cloud-sync"; import type { @@ -279,6 +282,8 @@ const EXCALIDRAW_ASSET_SCHEME = "zen-excalidraw"; // through this scheme instead (added to connect-src in the renderer's // index.html). Web keeps fetching the same-origin http assets. const TYPST_ASSET_SCHEME = "zen-typst"; +// Harper's grammar checker fetches its wasm from a worker; same delivery as Typst. +const HARPER_ASSET_SCHEME = "zen-harper"; const PRIVILEGED_ASSET_PRIVILEGES = { standard: true, @@ -293,6 +298,7 @@ protocol.registerSchemesAsPrivileged([ { scheme: THEME_ASSET_SCHEME, privileges: PRIVILEGED_ASSET_PRIVILEGES }, { scheme: EXCALIDRAW_ASSET_SCHEME, privileges: PRIVILEGED_ASSET_PRIVILEGES }, { scheme: TYPST_ASSET_SCHEME, privileges: PRIVILEGED_ASSET_PRIVILEGES }, + { scheme: HARPER_ASSET_SCHEME, privileges: PRIVILEGED_ASSET_PRIVILEGES }, ]); // The archive this process booted from, watched for a package manager @@ -2896,6 +2902,42 @@ function registerIpc(): void { handle(IPC.CLOUD_VAULT_SYNC, () => getCloudSyncService().sync(requireLocalCloudVaultRoot()), ); + handle( + IPC.CLOUD_VAULT_BOOTSTRAP_CONFLICT_GET, + (_event, conflict: CloudSyncBootstrapConflict) => + getCloudSyncService().getBootstrapConflict( + requireLocalCloudVaultRoot(), + conflict, + ), + ); + handle( + IPC.CLOUD_VAULT_BOOTSTRAP_CONFLICT_RESOLVE, + (_event, resolution: CloudSyncBootstrapConflictResolution) => + getCloudSyncService().resolveBootstrapConflict( + requireLocalCloudVaultRoot(), + resolution, + ), + ); + handle(IPC.CLOUD_VAULT_CONFLICT_GET, (_event, conflictId: string) => + getCloudSyncService().getConflict(requireLocalCloudVaultRoot(), conflictId), + ); + handle( + IPC.CLOUD_VAULT_CONFLICT_DRAFT_SAVE, + (_event, conflictId: string, draftText: string | null) => + getCloudSyncService().saveConflictDraft( + requireLocalCloudVaultRoot(), + conflictId, + draftText, + ), + ); + handle( + IPC.CLOUD_VAULT_CONFLICT_RESOLVE, + (_event, resolution: CloudSyncPendingConflictResolution) => + getCloudSyncService().resolveConflict( + requireLocalCloudVaultRoot(), + resolution, + ), + ); handle(IPC.CLOUD_VAULT_SETTINGS_CONFLICT_GET, () => getCloudSyncService().settingsConflict(requireLocalCloudVaultRoot()), ); @@ -5223,11 +5265,12 @@ app.whenReady().then(async () => { }); }); - protocol.handle(TYPST_ASSET_SCHEME, async (request) => { - // zen-typst://asset/ -> out/renderer/assets/ (the renderer's own - // bundled assets, next to its JS chunks). The renderer only ever requests - // the hashed Typst wasm and .otf fonts it imported, so this is a fixed, - // read-only view of the build output, scoped to those two asset kinds. + // ://asset/ -> out/renderer/assets/ (the renderer's own + // bundled assets, next to its JS chunks). The renderer only ever requests + // the hashed wasm engines and .otf fonts it imported, so this is a fixed, + // read-only view of the build output, scoped to those two asset kinds. One + // handler serves every scheme so there is exactly one traversal check. + const serveRendererAsset = async (request: Request): Promise => { const rel = decodeURIComponent(new URL(request.url).pathname).replace( /^\/+/, "", @@ -5235,7 +5278,7 @@ app.whenReady().then(async () => { const root = path.resolve(__dirname, "../renderer/assets"); const abs = path.resolve(root, rel); if (abs !== root && !abs.startsWith(root + path.sep)) { - throw new Error(`Invalid Typst asset URL: ${request.url}`); + throw new Error(`Invalid renderer asset URL: ${request.url}`); } const contentType = /\.wasm$/i.test(abs) ? "application/wasm" @@ -5243,7 +5286,7 @@ app.whenReady().then(async () => { ? "font/otf" : null; if (!contentType) - throw new Error(`Invalid Typst asset URL: ${request.url}`); + throw new Error(`Invalid renderer asset URL: ${request.url}`); const data = await fsp.readFile(abs); return new Response(data, { headers: { @@ -5251,7 +5294,9 @@ app.whenReady().then(async () => { "cache-control": "public, max-age=31536000, immutable", }, }); - }); + }; + protocol.handle(TYPST_ASSET_SCHEME, serveRendererAsset); + protocol.handle(HARPER_ASSET_SCHEME, serveRendererAsset); // Permissions this app grants to its own renderer (deny everything else — // it's our app talking to our own vault, no third-party surface): diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index e54664ad..73211d7e 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -85,6 +85,22 @@ describe('rootContentHiddenByInboxMode (#195)', () => { }) describe('daily-notes task settings round-trip (#288)', () => { + it('persists the Harper dictionary and ignored suggestions through set/get', async () => { + const root = await makeTempDir('zennotes-vault-harper-') + await mkdir(root, { recursive: true }) + const base = await getVaultSettings(root) + const returned = await setVaultSettings(root, { + ...base, + harper: { words: ['zennotes', ' zennotes '], ignoredLints: ['9722060015410969502', 'x'] } + }) + // The renderer keeps the returned value, so it must carry the field too. + expect(returned.harper).toEqual({ words: ['zennotes'], ignoredLints: ['9722060015410969502'] }) + const saved = await getVaultSettings(root) + expect(saved.harper).toEqual({ words: ['zennotes'], ignoredLints: ['9722060015410969502'] }) + await setVaultSettings(root, { ...saved, harper: { words: [], ignoredLints: [] } }) + expect((await getVaultSettings(root)).harper).toBeUndefined() + }) + it('persists tasksDueOnNoteDate + rolloverUnfinishedTasks through set/get', async () => { const root = await makeTempDir('zennotes-vault-dailytasks-') await mkdir(root, { recursive: true }) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index a4a15813..de4e608e 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -7,6 +7,9 @@ import { app, shell } from 'electron' import { recordMainPerf } from './perf' import { resolveCommandViaLoginShell } from './login-shell-path' import { isEphemeralRoot } from './ephemeral-vaults' +import { atomicWriteTarget, renameWithRetry } from './atomic-write' + +export { renameWithRetry } import { resolveWikilinkTarget, rewriteWikilinksForRename, @@ -84,6 +87,7 @@ import { normalizeTypstPreambleSettings, resolveTypstPreambleFolder } from '@shared/typst-preamble-folder' +import { normalizeHarperVaultState } from '@shared/harper-settings' const CONFIG_FILE = 'zennotes.config.json' const FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash'] @@ -666,54 +670,10 @@ export function isAtomicWriteTempPath(p: string): boolean { return ATOMIC_WRITE_TEMP_PATTERN.test(path.basename(p)) } -const ATOMIC_RENAME_ATTEMPTS = 20 - -function transientRenameError(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException | null)?.code - return code === 'EACCES' || code === 'EPERM' || code === 'EBUSY' -} - -/** Wait out a reader that temporarily denies replacing the destination. */ -export async function renameWithRetry( - from: string, - to: string, - rename: (from: string, to: string) => Promise = fs.rename, - pause: (delayMs: number) => Promise = (delayMs) => - new Promise((resolve) => setTimeout(resolve, delayMs)) -): Promise { - for (let attempt = 1; ; attempt++) { - try { - await rename(from, to) - return - } catch (error) { - if (attempt >= ATOMIC_RENAME_ATTEMPTS || !transientRenameError(error)) throw error - await pause(Math.min(2 ** (attempt - 1), 25)) - } - } -} - /** Same millisecond, same path, two writers: the stamp alone would name one * temp file for both and let them interleave into it. */ let atomicWriteSequence = 0 -/** Follow a symlink to the file it points at, so an atomic write lands on the - * target instead of replacing the link. A dangling link resolves to the path - * it names, which is where a plain write would have created the file. */ -async function atomicWriteTarget(absPath: string): Promise { - let stats - try { - stats = await fs.lstat(absPath) - } catch { - return absPath - } - if (!stats.isSymbolicLink()) return absPath - try { - return await fs.realpath(absPath) - } catch { - return path.resolve(path.dirname(absPath), await fs.readlink(absPath)) - } -} - /** * Atomically write a file: temp file + fsync + rename. The rename is atomic, so * readers never see a half-written file, which is what stops a note save from @@ -875,6 +835,31 @@ function cloneVaultSettings(settings: VaultSettings): VaultSettings { : {}), ...(settings.databasesLocation ? { databasesLocation: { ...settings.databasesLocation } } + : {}), + // The value handed back is what the renderer keeps until its next full + // reload, so every optional field that was written has to come back too. + ...(settings.tasksLocation ? { tasksLocation: { ...settings.tasksLocation } } : {}), + ...(settings.systemFolderPaths + ? { systemFolderPaths: { ...settings.systemFolderPaths } } + : {}), + ...(settings.tasks + ? { + tasks: { + ...settings.tasks, + ...(settings.tasks.excludedFolders + ? { excludedFolders: [...settings.tasks.excludedFolders] } + : {}) + } + } + : {}), + ...(settings.typstPreambles ? { typstPreambles: { ...settings.typstPreambles } } : {}), + ...(settings.harper + ? { + harper: { + words: [...settings.harper.words], + ignoredLints: [...settings.harper.ignoredLints] + } + } : {}) } } @@ -1118,6 +1103,7 @@ function normalizeVaultSettings( systemFolderPaths?: unknown tasks?: unknown typstPreambles?: unknown + harper?: unknown } const folderIcons: Record = {} if (candidate.folderIcons && typeof candidate.folderIcons === 'object') { @@ -1174,7 +1160,8 @@ function normalizeVaultSettings( view: normalizeVaultViewSettings(candidate.view), systemFolderPaths: normalizeSystemFolderPaths(candidate.systemFolderPaths), tasks: normalizeTasksSettings(candidate.tasks), - typstPreambles: normalizeTypstPreambleSettings(candidate.typstPreambles) + typstPreambles: normalizeTypstPreambleSettings(candidate.typstPreambles), + harper: normalizeHarperVaultState(candidate.harper) } } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index dfb7a65a..cf174741 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -22,6 +22,11 @@ import type { CloudPublishedNoteResult, CloudPublishNoteInput, CloudServiceAccount, + CloudSyncBootstrapConflict, + CloudSyncBootstrapConflictDetails, + CloudSyncBootstrapConflictResolution, + CloudSyncPendingConflictDetails, + CloudSyncPendingConflictResolution, CloudSyncRunSummary, CloudSyncSettingsChoice, CloudSyncSettingsConflict, @@ -93,6 +98,7 @@ import type { } from '@shared/mcp-clients' const DESKTOP_CAPABILITIES: ZenCapabilities = { + supportsHarper: true, supportsUpdater: true, supportsNativeMenus: true, supportsFloatingWindows: true, @@ -250,6 +256,20 @@ const api: ZenBridge = { unlinkCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_LINK_DELETE), deleteCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_DELETE), syncCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_SYNC), + getCloudBootstrapConflict: ( + conflict: CloudSyncBootstrapConflict + ): Promise => + ipcRenderer.invoke(IPC.CLOUD_VAULT_BOOTSTRAP_CONFLICT_GET, conflict), + resolveCloudBootstrapConflict: ( + resolution: CloudSyncBootstrapConflictResolution + ): Promise => + ipcRenderer.invoke(IPC.CLOUD_VAULT_BOOTSTRAP_CONFLICT_RESOLVE, resolution), + getCloudConflict: (conflictId: string): Promise => + ipcRenderer.invoke(IPC.CLOUD_VAULT_CONFLICT_GET, conflictId), + saveCloudConflictDraft: (conflictId: string, draftText: string | null): Promise => + ipcRenderer.invoke(IPC.CLOUD_VAULT_CONFLICT_DRAFT_SAVE, conflictId, draftText), + resolveCloudConflict: (resolution: CloudSyncPendingConflictResolution): Promise => + ipcRenderer.invoke(IPC.CLOUD_VAULT_CONFLICT_RESOLVE, resolution), getCloudSettingsConflict: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_SETTINGS_CONFLICT_GET), resolveCloudSettingsConflict: (choice: CloudSyncSettingsChoice): Promise => diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index ea3fd5cd..53ac0280 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -25,7 +25,7 @@ --> ZenNotes diff --git a/apps/server/internal/vault/types.go b/apps/server/internal/vault/types.go index a61ae79d..0c1492f7 100644 --- a/apps/server/internal/vault/types.go +++ b/apps/server/internal/vault/types.go @@ -291,6 +291,18 @@ type VaultSettings struct { // VaultSettings.typstPreambles; a first-class field for the same round-trip // reason as Tasks above. TypstPreambles *TypstPreambleSettings `json:"typstPreambles,omitempty"` + // Harper grammar-checker data that belongs to the vault (dictionary words + // and ignored-suggestion hashes). Mirrors shared/ipc.ts VaultSettings.harper; + // a first-class field for the same round-trip reason as Tasks above. + Harper *HarperSettings `json:"harper,omitempty"` +} + +// HarperSettings mirrors shared/ipc.ts VaultSettings.harper. IgnoredLints are +// Harper's unsigned 64-bit context hashes carried as digit strings, because +// the browser clients cannot hold them as numbers without rounding. +type HarperSettings struct { + Words []string `json:"words"` + IgnoredLints []string `json:"ignoredLints"` } // TasksSettings mirrors shared/ipc.ts VaultSettings.tasks (#458). diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go index 648f0e12..f13bf06e 100644 --- a/apps/server/internal/vault/vault.go +++ b/apps/server/internal/vault/vault.go @@ -342,6 +342,20 @@ func cloneSettings(settings VaultSettings) VaultSettings { copy(excluded, settings.Tasks.ExcludedFolders) tasks = &TasksSettings{ExcludedFolders: excluded} } + // The same rule for the two later pointer fields: a caller reading the + // value SetSettings hands back must see what was written, not nil. + var typstPreambles *TypstPreambleSettings + if settings.TypstPreambles != nil { + typstPreambles = &TypstPreambleSettings{Folder: settings.TypstPreambles.Folder} + } + var harper *HarperSettings + if settings.Harper != nil { + words := make([]string, len(settings.Harper.Words)) + copy(words, settings.Harper.Words) + ignored := make([]string, len(settings.Harper.IgnoredLints)) + copy(ignored, settings.Harper.IgnoredLints) + harper = &HarperSettings{Words: words, IgnoredLints: ignored} + } dailyLegacyPatterns := make([]DateNotePatternSettings, len(settings.DailyNotes.LegacyPatterns)) copy(dailyLegacyPatterns, settings.DailyNotes.LegacyPatterns) weeklyLegacyPatterns := make([]DateNotePatternSettings, len(settings.WeeklyNotes.LegacyPatterns)) @@ -384,6 +398,8 @@ func cloneSettings(settings VaultSettings) VaultSettings { Favorites: favorites, SystemFolderPaths: systemFolderPaths, Tasks: tasks, + TypstPreambles: typstPreambles, + Harper: harper, } } @@ -586,7 +602,49 @@ func normalizeVaultSettings(value VaultSettings, fallbackPrimary PrimaryNotesLoc SystemFolderPaths: normalizeSystemFolderPaths(value.SystemFolderPaths), Tasks: normalizeTasksSettings(value.Tasks), TypstPreambles: normalizeTypstPreambleSettings(value.TypstPreambles), + Harper: normalizeHarperSettings(value.Harper), + } +} + +// normalizeHarperSettings mirrors shared-domain's normalizeHarperVaultState: +// trimmed, de-duplicated words; ignored lints kept only when they are digit +// strings; nil when nothing is left so vault.json carries no empty block. +func normalizeHarperSettings(value *HarperSettings) *HarperSettings { + if value == nil { + return nil + } + words := uniqueTrimmedStrings(value.Words, func(string) bool { return true }) + ignored := uniqueTrimmedStrings(value.IgnoredLints, isDigitString) + if len(words) == 0 && len(ignored) == 0 { + return nil + } + return &HarperSettings{Words: words, IgnoredLints: ignored} +} + +func uniqueTrimmedStrings(values []string, keep func(string) bool) []string { + seen := map[string]struct{}{} + result := []string{} + for _, entry := range values { + cleaned := strings.TrimSpace(entry) + if cleaned == "" || !keep(cleaned) { + continue + } + if _, dup := seen[cleaned]; dup { + continue + } + seen[cleaned] = struct{}{} + result = append(result, cleaned) } + return result +} + +func isDigitString(value string) bool { + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true } // normalizeFileLocation mirrors app-core's normalizeFileLocation: validate the diff --git a/apps/server/internal/vault/vault_test.go b/apps/server/internal/vault/vault_test.go index 01757fc4..12193675 100644 --- a/apps/server/internal/vault/vault_test.go +++ b/apps/server/internal/vault/vault_test.go @@ -1249,3 +1249,43 @@ func TestCreateNoteSeedsTheTitleHeadingLikeTheDesktopApp(t *testing.T) { t.Fatalf("deduped body = %q, want %q", string(body), "# Note 2\n\n") } } + +func TestHarperSettingsRoundTripAndNormalize(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + saved, err := v.SetSettings(VaultSettings{ + Harper: &HarperSettings{ + Words: []string{" zennotes ", "zennotes", ""}, + IgnoredLints: []string{"9722060015410969502", "not-a-hash", "9722060015410969502"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if saved.Harper == nil { + t.Fatal("harper settings dropped on save") + } + if len(saved.Harper.Words) != 1 || saved.Harper.Words[0] != "zennotes" { + t.Errorf("words = %v, want [zennotes]", saved.Harper.Words) + } + if len(saved.Harper.IgnoredLints) != 1 || saved.Harper.IgnoredLints[0] != "9722060015410969502" { + t.Errorf("ignoredLints = %v, want the one digit string", saved.Harper.IgnoredLints) + } + reloaded, err := v.GetSettings() + if err != nil { + t.Fatal(err) + } + if reloaded.Harper == nil || reloaded.Harper.Words[0] != "zennotes" { + t.Errorf("reloaded harper = %+v", reloaded.Harper) + } + cleared, err := v.SetSettings(VaultSettings{Harper: &HarperSettings{}}) + if err != nil { + t.Fatal(err) + } + if cleared.Harper != nil { + t.Errorf("empty harper block should be dropped, got %+v", cleared.Harper) + } +} diff --git a/apps/server/package.json b/apps/server/package.json index d5c157e6..ed94d4fd 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.43.0", + "version": "2.44.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 764baca2..3ea6aa8d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.43.0", + "version": "2.44.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", @@ -18,6 +18,7 @@ "@codemirror/lang-markdown": "^6.3.1", "@codemirror/language": "^6.10.6", "@codemirror/language-data": "^6.5.1", + "@codemirror/lint": "^6.9.7", "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", @@ -34,6 +35,7 @@ "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", + "harper.js": "^2.7.0", "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 468e998e..58eda022 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -86,6 +86,7 @@ import type { } from '@shared/custom-code-languages' const WEB_CAPABILITIES: ZenCapabilities = { + supportsHarper: true, supportsUpdater: false, supportsNativeMenus: false, supportsFloatingWindows: false, @@ -1410,6 +1411,11 @@ export const httpBridge: ZenBridge = { unlinkCloudVault: async () => notImplemented('unlinkCloudVault'), deleteCloudVault: async () => notImplemented('deleteCloudVault'), syncCloudVault: async () => notImplemented('syncCloudVault'), + getCloudBootstrapConflict: async () => notImplemented('getCloudBootstrapConflict'), + resolveCloudBootstrapConflict: async () => notImplemented('resolveCloudBootstrapConflict'), + getCloudConflict: async () => notImplemented('getCloudConflict'), + saveCloudConflictDraft: async () => notImplemented('saveCloudConflictDraft'), + resolveCloudConflict: async () => notImplemented('resolveCloudConflict'), getCloudSettingsConflict: async () => null, resolveCloudSettingsConflict: async () => notImplemented('resolveCloudSettingsConflict'), listCloudBackups: async () => notImplemented('listCloudBackups'), diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 366ad005..5bc76c3d 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,6 +4,7 @@ import { createRequire } from 'node:module' import { dirname, resolve, sep } from 'node:path' import { defineConfig, type Plugin } from 'vite' import react from '@vitejs/plugin-react' +import { harperWasmAsset } from '../../tooling/vite/harper-wasm-asset' // Excalidraw resolves its hand-drawn fonts from a base URL. With // EXCALIDRAW_ASSET_PATH unset it falls back to the esm.sh CDN, which the @@ -306,14 +307,17 @@ export default defineConfig({ } } }, - plugins: [onigurumaDataUrl(), react(), excalidrawFonts()], + plugins: [onigurumaDataUrl(), harperWasmAsset(), react(), excalidrawFonts()], // Typst ships a WASM compiler loaded lazily via `?url` + dynamic import; keep - // it out of the esbuild dep pre-bundler so the wasm glue stays intact. + // it out of the esbuild dep pre-bundler so the wasm glue stays intact. The + // same goes for Harper, whose worker is an inline blob the pre-bundler + // would otherwise rewrite. optimizeDeps: { exclude: [ '@myriaddreamin/typst.ts', '@myriaddreamin/typst-ts-web-compiler', - '@myriaddreamin/typst-ts-renderer' + '@myriaddreamin/typst-ts-renderer', + 'harper.js' ] }, build: { diff --git a/docs/releases/v2.44.0/RELEASE_NOTES.md b/docs/releases/v2.44.0/RELEASE_NOTES.md new file mode 100644 index 00000000..ef891d1f --- /dev/null +++ b/docs/releases/v2.44.0/RELEASE_NOTES.md @@ -0,0 +1,30 @@ +ZenNotes 2.44.0: Cloud conflicts become clear choices, and Harper checks your writing + +> ZenNotes now combines safe edits automatically and asks for help only when two devices changed the same words. Every version stays safe until you choose, and sync no longer creates surprise `(cloud conflict)` notes. This release also adds Harper, an offline grammar and spell checker for the editor, off by default. + +## ✨ New + +- **Review sync changes where you already work.** (#683, reported by @uNyanda) A persistent **1 file needs review · Review now** action appears in the workspace status bar, with the same queue available in Settings → Cloud. The resolver lists every file waiting for a decision and moves to the next one automatically. While files are waiting, the command palette entry **Review Cloud Sync Conflicts** and the Vim leader binding `Space r` open the same queue. +- **Resolve only the ambiguous part.** ZenNotes three-way merges edits made in different places without interrupting you. For a real overlap, the resolver labels the versions **This device**, **Other device**, and **Last synced**, shows the suggested combined note, and asks which wording to keep for each ambiguous change. You can edit the combined note directly or choose one complete version instead. +- **An honest first-sync choice.** When there is no earlier shared version, ZenNotes does not pretend it can infer a merge. It shows both complete notes, explains why it cannot know which is newer, and offers clear choices to keep either version, keep both under a name you choose, or combine them yourself. Replacing one complete version always requires a separate confirmation. +- **Finish later without losing progress.** Leaving an unresolved note requires an explicit confirmation. Your local note remains in the vault; its Cloud comparison, last-synced version, and current draft stay in private app storage. The conflicted path waits while unrelated notes continue syncing, and reopening the queue restores the draft. +- **No task or vault pollution.** New conflicts never become numbered conflict files. While a note waits for your decision, its tasks are withheld from the app's own task surfaces: the Tasks view (list, calendar, and Kanban modes) and the calendar panel. Tools that read the vault straight from disk, such as MCP, the `zn` CLI, and the self-hosted server, still report that note's tasks as the local file has them. Existing conflict copies from older releases are left untouched and can be opened or moved to Trash explicitly. +- **Safe choices for every file type.** Text, binary, delete, move, and filename collisions use the same durable queue. Before saving, ZenNotes verifies both the local file and current Cloud revision. A local multi-file decision rolls back newly created destinations if a later write fails. + +- **Grammar and spelling with Harper.** Turn on Settings, Editor, **Grammar and spelling with Harper** and the editor underlines misspellings, typos, and grammar slips as you write. Harper (writewithharper.com) runs entirely on your device; no text leaves the app. Hover an underline, or press `z=` in Vim mode, for a card with the fixes: a digit or Enter applies one, `j` and `k` move the highlight, `]s` and `[s` walk the problems, `zg` teaches the vault's dictionary a word, and `zG` ignores one suggestion. Choose the English dialect below the toggle. Code, links, and frontmatter are never checked, and the dictionary and ignored suggestions live in `vault.json`, so they travel and sync with the vault. Off by default; the command palette and `:harper on|off` toggle it too. Harper is a desktop and web feature: the phone apps keep the system keyboard's own spelling and grammar help and never load it. + +## 🧰 For contributors + +- The shared coordinator now owns durable conflict-only snapshots, automatic line-based three-way merging, per-path sync pauses, stale-choice protection, and auto-next summaries. Desktop, iOS, and Android expose the same inspect, draft, and resolve bridge operations. +- ZenNotes Cloud adds an authorized historical-revision read so upgraded clients can recover the last agreed text when it is still retained. Older servers and expired revisions fall back safely to a two-version choice. +- Captioned real-app demo: `media/cloud-conflict-resolution-683.mp4` plus `media/cloud-conflict-resolution-683.vtt` (18 seconds, H.264, 1280×800). The clip uses the built Electron app against an isolated local Cloud fixture and was assembled with FFmpeg. +- Review of the resolver before release found and fixed three data-loss cases: the three-way merge glued a line onto an unterminated last line and pushed the result as the agreed revision; a move conflict could never be resolved because its Cloud snapshot was built from a field nothing wrote, and loosening the check would have deleted the note; and Use this device on a file above the 5 MB inline limit wrote it back as 0 bytes. Conflict snapshots above 256 KB now keep only metadata in state, an automatic merge that loses to a save queues the conflict instead of failing the run, and the cloud sync writer follows symlinked notes and keeps file modes through the electron-free `apps/desktop/src/main/atomic-write.ts` it shares with the vault writer. +- The resolver dialog is now in every global key-handler bail list, `ui/Modal` gained the focus trap and restore the design system had promised, the Settings resolver is keyed per conflict, Save combined note requires every hunk, and the queue opens from the palette and `Space r` through an app-wide host so it works in zen mode. The unreachable bootstrap resolver panel was deleted; the bridge methods stay because the mobile shells implement them. +- Harper ships as a 15.6 MB WebAssembly asset fetched by Harper's worker through a `zen-harper://` scheme that shares the Typst asset handler, loaded only once the toggle is on and never on the boot path (the entry chunk only dynamic-imports Harper's chunk). The wasm reaches both renderer builds through one shared Vite plugin, `tooling/vite/harper-wasm-asset.ts`, because harper.js hides its `dist/` behind an exports map. The session, editor extension, and runtime glue live in `packages/app-core/src/lib/harper-*.ts` and `cm-harper.ts`; dialects, rule config, and the vault state normalizer are in `packages/shared-domain/src/harper-settings.ts`; the dictionary and ignore hashes are a `harper` field on `VaultSettings`, mirrored in desktop main and the Go server. Ignore hashes are unsigned 64-bit integers carried as digit strings, since JSON.parse would round them. Real-engine tests run Harper under Node: spans are UTF-16, code and link targets are skipped, and one word can carry two rules, so an ignore is per rule. +- Harper is gated on a new optional host capability, `supportsHarper`, true on desktop and web and absent on the phones, which hides the setting, the commands, and the editor extension there. The iOS and Android repos carry matching edits for their next source-pin bump: the settings normalizer keeps the `harper` field so a phone-side save cannot erase the vault dictionary, and a Vite stub resolves Harper's two imports to an empty module so the phone bundles never contain the binary. +- Captioned demo: `media/harper-grammar-check.mp4` plus `media/harper-grammar-check.vtt` (38 seconds, H.264, 1280×800), recorded from the built app. +- Verification, this pass: monorepo typecheck clean; forced, uncached test runs green (shared-domain 1556, app-core 1888, desktop 706, Go server); desktop and web production builds; the built desktop app driven over CDP with isolated userData, config, and vault through the Harper flow (underlines, hover card, `]s`, `z=`, `j`/`k`/Enter across a background re-lint, `zG`, `zg` writing `vault.json`, `:harper off` and `on`); the Harper card checked in all eleven theme families in light and dark; both phone shells typechecked, built, and tested against this source (30 iOS and 35 Android tests). Earlier in the cycle: 621 Cloud API tests, the Xcode simulator build, and the Gradle debug APK. Not repeated after these changes: the packaged-app launch check (`npm run pack` plus a CDP page target) and `npm run perf:desktop-runtime`, which currently stops before measuring because the app opens on the Home view. Dependency audits report no known vulnerabilities. + +--- + +Local-first and keyboard-first, as always. diff --git a/docs/releases/v2.44.0/media/cloud-conflict-resolution-683.mp4 b/docs/releases/v2.44.0/media/cloud-conflict-resolution-683.mp4 new file mode 100644 index 00000000..03891d1f Binary files /dev/null and b/docs/releases/v2.44.0/media/cloud-conflict-resolution-683.mp4 differ diff --git a/docs/releases/v2.44.0/media/cloud-conflict-resolution-683.vtt b/docs/releases/v2.44.0/media/cloud-conflict-resolution-683.vtt new file mode 100644 index 00000000..08dc61b2 --- /dev/null +++ b/docs/releases/v2.44.0/media/cloud-conflict-resolution-683.vtt @@ -0,0 +1,13 @@ +WEBVTT + +00:00.000 --> 00:04.500 +When a note changes on two devices, Review now appears where you already work. + +00:04.500 --> 00:09.000 +ZenNotes combines safe edits and asks only about the words that overlap. + +00:09.000 --> 00:13.500 +Choose either device, keep both changes, or edit the combined note yourself. + +00:13.500 --> 00:18.000 +Save once. Sync continues with one clean note and no surprise conflict copy. diff --git a/docs/releases/v2.44.0/twitter-post.md b/docs/releases/v2.44.0/twitter-post.md new file mode 100644 index 00000000..600a2f2f --- /dev/null +++ b/docs/releases/v2.44.0/twitter-post.md @@ -0,0 +1,39 @@ +# ZenNotes 2.44.0: Twitter / X + +## Launch thread draft + +**1/4**, attach `media/cloud-conflict-resolution-683.mp4` + +ZenNotes 2.44.0 turns Cloud conflicts into clear choices. + +Safe edits merge automatically. If two devices changed the same words, **Review now** appears in the workspace and shows only what needs your decision. No rename workaround. No surprise duplicate note. + +**2/4** + +Choose this device, the other device, both changes, or edit the combined note yourself. Resolve several files in one queue, or finish later with every version and your draft preserved. + +First sync is honest too: when no shared earlier version exists, ZenNotes shows both complete notes instead of guessing. + +**3/4** + +While a note waits, its tasks stay out of Tasks, Calendar, and Kanban, but unrelated notes keep syncing. ZenNotes checks both devices again before saving so a stale choice cannot overwrite a newer edit. + +Thanks @uNyanda for the report and #683. + +**4/4**, attach `media/harper-grammar-check.mp4` + +Also new: grammar and spelling with Harper, entirely on your device. Turn it on in Settings, hover an underline or press z= for the fixes, ]s and [s walk the problems, zg teaches the vault dictionary. Off by default, no text leaves the app. + +Free, open source, local-first Markdown notes. +https://zennotes.org + +## Single-post alternative + +ZenNotes 2.44.0 turns Cloud conflicts into clear choices. Safe edits merge automatically; real overlaps open from **Review now** with per-change choices, an editable combined note, saved drafts, and no surprise duplicate files. Plus Harper: offline grammar and spelling in the editor, off by default. Thanks @uNyanda for #683. https://zennotes.org + +## Notes + +- Captioned clip: `media/cloud-conflict-resolution-683.mp4` plus `.vtt` (18 seconds, built Electron app, isolated local fixture, status-bar entry point through clean sync). +- Harper clip: `media/harper-grammar-check.mp4` plus `.vtt` (38 seconds, built Electron app, isolated vault): underlines, hover card, `]s` and `z=`, Vim motions plus Enter, `zg`, and the Settings toggle. +- Harper is desktop and web only; the phone apps keep the system keyboard's spelling help and never load it. +- The release covers the full queued workflow requested in #683: ongoing and first-sync conflicts, per-change decisions, auto-next, safe deferral, task isolation, and no new conflict-copy files. diff --git a/docs/specs/cloud-conflict-resolution.md b/docs/specs/cloud-conflict-resolution.md new file mode 100644 index 00000000..75e86f4f --- /dev/null +++ b/docs/specs/cloud-conflict-resolution.md @@ -0,0 +1,117 @@ +# Spec: Cloud conflict resolution + +## Objective + +ZenNotes must resolve first-sync and ongoing multi-device conflicts without creating user-visible `(cloud conflict)` files. Most people should never see the resolver: text edits that do not overlap are merged automatically. When a choice is necessary, one durable conflict queue explains the situation in plain language and preserves every version until the user finishes. + +The feature is complete when the desktop/mobile scenario from GitHub issue #683 produces one safe conflict entry, no duplicate note, no leaked task, and a recoverable resolution flow from the main workspace. + +## Product behavior + +- Sync continues for every unaffected path while a conflicted path is paused. +- Non-overlapping text changes are combined automatically and uploaded as a new agreed revision. +- A true overlapping edit becomes a durable conflict containing the last agreed version, this device's version, and the latest Cloud version. +- The primary UI labels versions as **This device**, **Other device**, and **Last synced**. Revisions and hashes remain diagnostic details, not decision copy. +- The primary workflow is **Save combined note**. Per-change controls use **Use this device**, **Use other device**, and **Keep both changes**; whole-file actions remain available for text, binary, delete, and move conflicts. +- **Finish later** opens a plain-language warning, and the confirming action saves the current draft before closing. Backdrop and Escape cannot accidentally dismiss a pending resolution. +- After resolving one item, the next unresolved item opens automatically. The status bar always exposes the queue. +- The extra Cloud and last-synced snapshots live in private app storage, outside the vault, so they cannot appear as duplicate notes or enter search, backups, or third-party file sync. The user's existing local note stays visible and editable, while its unresolved task data is withheld from Tasks, Calendar, and Kanban until the conflict is resolved. +- Existing conflict-copy files are never deleted automatically. ZenNotes detects likely legacy copies and offers a separate, explicit review and cleanup action. +- If either side changes while the resolver is open, ZenNotes refreshes the conflict and never overwrites the newer change silently. + +## Tech stack + +- TypeScript shared sync domain and bridge contracts +- React 18 app-core UI with the existing Modal/Button design system +- Electron IPC and private desktop app-data persistence +- Capacitor host persistence for iOS and Android +- Laravel Cloud API for authorized historical-revision reads +- Vitest, Node test runner, PHPUnit/Pest, and production builds + +No new runtime dependency is required. Line differencing and three-way merge behavior remain a small, tested shared-domain module. + +## Commands + +- Focused shared tests: `npm run test:run --workspace @zennotes/shared-domain` +- Focused UI tests: `npm run test:run --workspace @zennotes/app-core` +- Desktop tests: `npm run test:run --workspace @zennotes/desktop` +- Workspace typecheck: `npm run typecheck` +- Desktop build: `npm run build --workspace @zennotes/desktop` +- Cloud API tests: `php artisan test --filter=SyncApiTest` +- Mobile checks: `npm test && npm run typecheck && npm run build` + +## Project structure + +- `packages/shared-domain/src/`: conflict records, diff/merge logic, coordinator behavior, portable filesystem adapter +- `packages/bridge-contract/src/`: cross-platform conflict summaries, details, resolutions, and bridge methods +- `packages/app-core/src/components/`: responsive conflict queue and merge experience +- `apps/desktop/src/main|preload/`: desktop persistence and IPC +- `apps/web/src/bridge/`: explicit unsupported implementations where local-vault Cloud sync is unavailable +- `docs/releases/v2.44.0/`: user-facing release notes and verified demo media +- ZenNotes Cloud Laravel repository: historical revision endpoint and authorization tests +- iOS/Android repositories: native persistence/bridge adapters and responsive runtime verification + +## Code style + +Use explicit, portable records and outcome-oriented names: + +```ts +if (merge.status === "clean") { + await applyResolution({ + conflict_id: conflict.id, + choice: "merged", + text: merge.text, + }); +} else { + await conflicts.save({ ...conflict, draft_text: merge.preview }); +} +``` + +Keep filesystem and network effects behind existing repository/remote interfaces. Prefer immutable state transforms, discriminated unions, snake_case wire fields, and project formatting conventions. + +## Testing strategy + +- Unit-test line diffs, clean three-way merges, overlapping hunks, newline preservation, and complexity limits. +- Coordinator tests reproduce bootstrap, pull, rejected-push, delete, move, repeated remote update, restart, deferral, and stale-resolution scenarios. +- Filesystem tests prove no conflict copy is written and private snapshots never enter a vault scan. +- Component tests cover plain-language labels, queue navigation, per-hunk choices, persisted drafts, exit warning, keyboard use, and mobile-width layout. +- Bridge/IPC tests prove all conflict operations are available on desktop and portable hosts. +- Cloud API tests prove revision content is owner-scoped, revision-scoped, and unavailable across accounts. +- End-to-end demo uses the built Electron app and a real multi-device-shaped conflict, with captions. + +## Boundaries + +- Always: preserve every version, verify local and remote freshness, keep unrelated sync moving, keep pending data outside the vault, and provide keyboard-accessible controls. +- Ask first: adding dependencies, deleting legacy user files, changing account authorization, or committing/pushing. +- Never: silently pick a winner for overlapping changes, expose private conflict snapshots as notes, claim issue #683 is complete without runtime multi-device coverage, or auto-delete a legacy conflict copy. + +## Implementation tasks + +- [x] Add durable unified conflict records and conflict-only snapshots to shared sync state. +- [x] Replace runtime conflict-copy creation with queued conflicts and path-scoped sync pauses. +- [x] Convert rejected local mutations into queued conflicts using current Cloud content. +- [x] Add automatic three-way text merging and unresolved-change choices. +- [x] Unify bootstrap conflicts with the durable queue. +- [x] Add generic inspect, draft-save, and resolve bridge operations on desktop and portable hosts. +- [x] Build the responsive queue, plain-language merge workflow, auto-next behavior, and exit protection. +- [x] Detect legacy conflict-copy names and expose non-destructive review/cleanup guidance. +- [x] Add the authorized historical revision API used when an upgraded client lacks a local base snapshot. +- [x] Wire iOS and Android adapters. +- [x] Run complete tests/builds, update release documentation, and replace the captioned demo. + +## Success criteria + +1. Simultaneous edits on desktop and mobile never create another `(cloud conflict)` file. +2. Disjoint text edits merge without prompting and converge on both devices. +3. Overlapping edits remain byte-for-byte recoverable across sync runs and app restarts. +4. The resolver shows a real last-synced/local/Cloud comparison and lets the user choose each overlapping change. +5. Delete, move, binary, and path conflicts have safe, understandable whole-file choices. +6. Unresolved task data is excluded from Tasks, Calendar, and Kanban, while the user's local note remains visible and recoverable. +7. Unrelated files continue syncing while conflicts wait. +8. A stale action cannot overwrite a newer local or Cloud revision. +9. Desktop, iOS, and Android use the same conflict model and expose a reachable resolver. +10. Existing conflict copies remain untouched unless the user explicitly confirms cleanup. + +## Open questions + +None blocking. The approved direction is the broadest safe behavior: automatic clean merges, explicit decisions only for overlap, cross-platform support, and no destructive legacy migration. diff --git a/package-lock.json b/package-lock.json index a88f8769..39b8d375 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.43.0", + "version": "2.44.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.43.0", + "version": "2.44.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.43.0", + "version": "2.44.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -31,6 +31,7 @@ "@codemirror/lang-markdown": "^6.3.1", "@codemirror/language": "^6.10.6", "@codemirror/language-data": "^6.5.1", + "@codemirror/lint": "^6.9.7", "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", @@ -52,6 +53,7 @@ "font-list": "^2.0.2", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", + "harper.js": "^2.7.0", "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", @@ -99,6 +101,17 @@ "node": ">=22" } }, + "apps/desktop/node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, "apps/desktop/node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -861,17 +874,18 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.43.0" + "version": "2.44.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.43.0", + "version": "2.44.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-markdown": "^6.3.1", "@codemirror/language": "^6.10.6", "@codemirror/language-data": "^6.5.1", + "@codemirror/lint": "^6.9.7", "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", @@ -888,6 +902,7 @@ "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", + "harper.js": "^2.7.0", "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", @@ -922,6 +937,17 @@ "vite": "^6.4.3" } }, + "apps/web/node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, "apps/web/node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -6445,9 +6471,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", - "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6585,9 +6611,9 @@ "license": "Apache-2.0" }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -6605,11 +6631,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -6751,9 +6777,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001788", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", - "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -8408,9 +8434,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.340", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", - "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -8993,6 +9019,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -9539,6 +9571,15 @@ "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", "license": "MIT" }, + "node_modules/harper.js": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/harper.js/-/harper.js-2.7.0.tgz", + "integrity": "sha512-INDnUMNJvQzv5Zv9lhgGuIRYNIpDvOXcieCbo5ED/dwn8V/02zVW5kKvU6jSJJHq1MpY0VDyg+5RjE7z5D+FeA==", + "license": "Apache-2.0", + "dependencies": { + "fflate": "^0.8.2" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -12224,11 +12265,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/node-tikzjax": { "version": "1.0.5", @@ -13060,9 +13104,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13248,9 +13292,9 @@ "license": "Apache-2.0" }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -15489,9 +15533,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -16242,7 +16286,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.43.0", + "version": "2.44.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16262,6 +16306,7 @@ "@codemirror/lang-yaml": "^6.1.3", "@codemirror/language": "^6.10.6", "@codemirror/language-data": "^6.5.1", + "@codemirror/lint": "^6.9.7", "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", @@ -16275,6 +16320,7 @@ "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", + "harper.js": "^2.7.0", "hast": "^1.0.0", "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", @@ -16304,13 +16350,24 @@ "vitest": "^3.2.6" } }, + "packages/app-core/node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.43.0" + "version": "2.44.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.43.0", + "version": "2.44.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16321,7 +16378,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.43.0" + "version": "2.44.0" } } } diff --git a/package.json b/package.json index ec42ecca..51585696 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.43.0", + "version": "2.44.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 c8ee9a42..7ec7a49b 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.43.0", + "version": "2.44.0", "type": "module", "exports": { "./main": "./src/main.tsx" @@ -25,6 +25,7 @@ "@codemirror/lang-yaml": "^6.1.3", "@codemirror/language": "^6.10.6", "@codemirror/language-data": "^6.5.1", + "@codemirror/lint": "^6.9.7", "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", @@ -38,6 +39,7 @@ "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", + "harper.js": "^2.7.0", "hast": "^1.0.0", "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index ce3635ce..336e6f8d 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -21,6 +21,7 @@ import { TitleBar } from './components/TitleBar' import { PromptHost } from './components/PromptHost' import { ConfirmHost } from './components/ConfirmHost' import { PublishNoteHost } from './components/PublishNoteHost' +import { CloudConflictReviewHost } from './components/CloudConflictReviewHost' import { ServerDirectoryPickerHost } from './components/ServerDirectoryPickerHost' import { ToastHost } from './components/ui' import { ExcalidrawEmbedMenuHost } from './components/ExcalidrawEmbedMenuHost' @@ -57,6 +58,7 @@ import { useAppUpdateState } from './lib/app-update-state' import { ensureCloudAutoSyncStarted, stopCloudAutoSync } from './lib/cloud-auto-sync' +import { installHarperRuntime } from './lib/harper-runtime' let editorModulePromise: Promise | null = null const EDITOR_MODULE_WARMUP_GRACE_MS = 40 @@ -396,6 +398,7 @@ function App(): JSX.Element { ensureCloudAutoSyncStarted() return stopCloudAutoSync }, [vault?.root]) + useEffect(() => installHarperRuntime(), []) useEffect(() => { if (!vault) return undefined @@ -738,7 +741,8 @@ function App(): JSX.Element { state.outlinePaletteOpen || document.querySelector('[data-ctx-menu]') || document.querySelector('[data-prompt-modal]') || - document.querySelector('[data-confirm-modal]') + document.querySelector('[data-confirm-modal]') || + document.querySelector('[data-cloud-conflict-dialog]') if (!tabSelectBlocked) { for (let i = 0; i < TAB_SELECT_KEYMAP_IDS.length; i += 1) { const id = TAB_SELECT_KEYMAP_IDS[i] @@ -946,7 +950,8 @@ function App(): JSX.Element { const modalOrMenuOpen = !!document.querySelector('[data-ctx-menu]') || !!document.querySelector('[data-prompt-modal]') || - !!document.querySelector('[data-confirm-modal]') + !!document.querySelector('[data-confirm-modal]') || + !!document.querySelector('[data-cloud-conflict-dialog]') // Search Notes is a toggle: its own shortcut closes the palette it // opened (#510 moved it here from the bubble handler, which had no // overlay guard at all). A confirm on top of the palette, such as the @@ -1210,6 +1215,7 @@ function App(): JSX.Element { + diff --git a/packages/app-core/src/components/CloudConflictDialog.tsx b/packages/app-core/src/components/CloudConflictDialog.tsx new file mode 100644 index 00000000..c457f1ec --- /dev/null +++ b/packages/app-core/src/components/CloudConflictDialog.tsx @@ -0,0 +1,104 @@ +import { useRef, useState } from "react"; +import type { CloudSyncRunSummary } from "@zennotes/bridge-contract/cloud-sync"; +import { CloudPendingConflictResolver } from "./CloudPendingConflictResolver"; +import { Modal } from "./ui/Modal"; + +const TITLE_ID = "cloud-conflict-dialog-title"; + +export function CloudConflictDialog({ + summary, + vaultName, + onClose, +}: { + summary: CloudSyncRunSummary; + vaultName: string; + onClose: () => void; +}): JSX.Element { + const conflicts = summary.pending_conflicts ?? []; + const [selectedId, setSelectedId] = useState(() => conflicts[0]?.id ?? ""); + const selected = + conflicts.find((conflict) => conflict.id === selectedId) ?? conflicts[0]; + // Focus lands on the review area rather than on the first button, which is + // "Finish later": the queue opens on the decision, not on the way out. + const body = useRef(null); + const selectNext = (nextSummary: CloudSyncRunSummary): void => { + const nextPending = nextSummary.pending_conflicts?.[0]; + if (nextPending) { + setSelectedId(nextPending.id); + return; + } + onClose(); + }; + + return ( + + + +
+ {conflicts.length > 1 && ( +
+
+ Files to resolve +
+
+ {conflicts.map((conflict) => { + const active = conflict.id === selected?.id; + return ( + + ); + })} +
+
+ )} + + {selected && ( + + )} +
+
+
+ ); +} diff --git a/packages/app-core/src/components/CloudConflictReviewHost.tsx b/packages/app-core/src/components/CloudConflictReviewHost.tsx new file mode 100644 index 00000000..09c02eb2 --- /dev/null +++ b/packages/app-core/src/components/CloudConflictReviewHost.tsx @@ -0,0 +1,27 @@ +import { + closeCloudConflictReview, + resolvableCloudConflictCount, + useCloudSyncStatusStore, +} from "../lib/cloud-auto-sync"; +import { CloudConflictDialog } from "./CloudConflictDialog"; + +/** + * Mounts the Cloud conflict queue for the whole app. It hung off the status bar + * before, which zen mode hides: the command palette entry and the leader + * binding open the queue from anywhere, so the dialog must not depend on the + * status bar being on screen. The status bar now only asks for it. + */ +export function CloudConflictReviewHost(): JSX.Element | null { + const open = useCloudSyncStatusStore((state) => state.conflictReviewOpen); + const summary = useCloudSyncStatusStore((state) => state.lastSummary); + const vaultName = useCloudSyncStatusStore((state) => state.vaultName); + if (!open || summary === null) return null; + if (resolvableCloudConflictCount(summary) === 0) return null; + return ( + + ); +} diff --git a/packages/app-core/src/components/CloudPendingConflictResolver.test.ts b/packages/app-core/src/components/CloudPendingConflictResolver.test.ts new file mode 100644 index 00000000..76ef68ee --- /dev/null +++ b/packages/app-core/src/components/CloudPendingConflictResolver.test.ts @@ -0,0 +1,454 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + CloudSyncPendingConflict, + CloudSyncPendingConflictDetails, + CloudSyncRunSummary, +} from "@zennotes/bridge-contract/cloud-sync"; +import { CloudPendingConflictResolver } from "./CloudPendingConflictResolver"; + +const bridge = vi.hoisted(() => ({ + getCloudConflict: vi.fn(), + saveCloudConflictDraft: vi.fn(), + resolveCloudConflict: vi.fn(), + syncCloudVault: vi.fn(), +})); + +vi.mock("@zennotes/bridge-contract/bridge", () => ({ + getZenBridge: () => bridge, +})); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const conflict: CloudSyncPendingConflict = { + id: "item-1", + item_id: "item-1", + path: "Plans/Trip.md", + cloud_path: "Plans/Trip.md", + kind: "content", + can_merge: true, + has_base: true, +}; + +const details: CloudSyncPendingConflictDetails = { + conflict, + base: version("Plans/Trip.md", "Pack a coat.\n"), + local: version("Plans/Trip.md", "Pack a warm coat.\n"), + cloud: version("Plans/Trip.md", "Pack a rain coat.\n"), + suggested_text: "# Trip\nPack a warm coat.\n", + draft_text: null, + changes: [ + { + id: "change-1", + base_text: "Pack a coat.\n", + local_text: "Pack a warm coat.\n", + cloud_text: "Pack a rain coat.\n", + }, + ], + parts: [ + { type: "text", text: "# Trip\n" }, + { type: "change", change_id: "change-1" }, + ], +}; + +const synced: CloudSyncRunSummary = { + cursor: 8, + pulled: 0, + pushed: 1, + conflicts: [], + bootstrap_conflicts: [], + local_conflicts: [], + pending_conflicts: [], +}; + +beforeEach(() => { + bridge.getCloudConflict.mockReset().mockResolvedValue(details); + bridge.saveCloudConflictDraft.mockReset().mockResolvedValue(undefined); + bridge.resolveCloudConflict.mockReset().mockResolvedValue(undefined); + bridge.syncCloudVault.mockReset().mockResolvedValue(synced); +}); + +describe("CloudPendingConflictResolver", () => { + it("uses plain labels and requires an explicit choice for overlapping text", async () => { + const onResolved = vi.fn(); + const view = mount({ onResolved }); + + await act(async () => Promise.resolve()); + + expect(view.host.textContent).toContain("This device"); + expect(view.host.textContent).toContain("Other device"); + expect(view.host.textContent).toContain("Last synced"); + expect(view.host.textContent).not.toContain("revision"); + expect(view.host.textContent).not.toContain("sha256"); + + const save = button(view.host, "Save combined note"); + expect(save.disabled).toBe(true); + + await act(async () => button(view.host, "Use other device").click()); + expect(textarea(view.host).value).toBe("# Trip\nPack a rain coat.\n"); + expect(save.disabled).toBe(false); + + await act(async () => { + save.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(bridge.resolveCloudConflict).toHaveBeenCalledWith({ + conflict_id: "item-1", + choice: "merged", + expected_local_sha256: details.local.sha256, + expected_cloud_revision: details.cloud.revision, + merged_text: "# Trip\nPack a rain coat.\n", + resolved_path: "Plans/Trip.md", + }); + expect(onResolved).toHaveBeenCalledWith(synced); + view.unmount(); + }); + + it("gives first sync a direct two-version choice without implying an automatic merge", async () => { + bridge.getCloudConflict.mockResolvedValue({ + ...details, + conflict: { + ...conflict, + can_merge: false, + has_base: false, + }, + base: { + path: "Plans/Trip.md", + revision: null, + sha256: null, + byte_length: 0, + media_type: null, + text: null, + deleted: false, + }, + suggested_text: null, + changes: [], + parts: [], + }); + const view = mount({ + conflict: { ...conflict, can_merge: false, has_base: false }, + }); + await act(async () => Promise.resolve()); + + expect(view.host.textContent).toContain( + "This is the first sync, so ZenNotes cannot tell which one is newer.", + ); + expect(view.host.textContent).toContain("Use this device’s version"); + expect(view.host.textContent).toContain("Use other device’s version"); + expect(view.host.textContent).not.toContain("Combined note"); + expect(view.host.textContent).not.toContain("Last synced"); + + await act(async () => button(view.host, "Combine them myself…").click()); + expect(view.host.textContent).toContain("Combined note"); + expect(button(view.host, "Save combined note").disabled).toBe(false); + view.unmount(); + }); + + it("confirms before replacing one complete version with the other", async () => { + bridge.getCloudConflict.mockResolvedValue({ + ...details, + conflict: { + ...conflict, + can_merge: false, + has_base: false, + }, + base: { + path: "Plans/Trip.md", + revision: null, + sha256: null, + byte_length: 0, + media_type: null, + text: null, + deleted: false, + }, + suggested_text: null, + changes: [], + parts: [], + }); + const view = mount({ + conflict: { ...conflict, can_merge: false, has_base: false }, + }); + await act(async () => Promise.resolve()); + + await act(async () => button(view.host, "Use other device’s version").click()); + + expect(bridge.resolveCloudConflict).not.toHaveBeenCalled(); + const confirmation = view.host.querySelector( + '[role="alertdialog"]', + ); + expect(confirmation?.textContent).toContain( + "Use the other device’s complete version?", + ); + expect(confirmation?.textContent).toContain( + "Changes that exist only in this device’s version will not be included.", + ); + expect(document.activeElement).toBe(confirmation); + + await act(async () => { + button(view.host, "Replace with other device’s version").click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(bridge.resolveCloudConflict).toHaveBeenCalledWith({ + conflict_id: "item-1", + choice: "cloud", + expected_local_sha256: details.local.sha256, + expected_cloud_revision: details.cloud.revision, + }); + view.unmount(); + }); + + it("saves the latest draft before finishing later", async () => { + const onClose = vi.fn(); + const view = mount({ onClose }); + await act(async () => Promise.resolve()); + + await act(async () => { + const editor = textarea(view.host); + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + setter?.call(editor, "My careful combination\n"); + editor.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + button(view.host, "Finish later").click(); + await Promise.resolve(); + }); + + const warning = view.host.querySelector( + '[role="alertdialog"]', + ); + expect(warning?.textContent).toContain("Resolve this note later?"); + expect(warning?.textContent).toContain("both complete versions stay safe"); + expect(document.activeElement).toBe(warning); + expect(view.host.querySelector("textarea")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + + await act(async () => { + button(view.host, "Keep reviewing").click(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + expect(document.activeElement).toBe(button(view.host, "Finish later")); + + await act(async () => { + button(view.host, "Finish later").click(); + await Promise.resolve(); + }); + await act(async () => { + button(view.host, "Save & finish later").click(); + await Promise.resolve(); + }); + + expect(bridge.saveCloudConflictDraft).toHaveBeenCalledWith( + "item-1", + "My careful combination\n", + ); + expect(onClose).toHaveBeenCalledTimes(1); + view.unmount(); + }); + + it("keeps the combined note locked until every change is answered", async () => { + bridge.getCloudConflict.mockResolvedValue({ + ...details, + changes: [ + { + id: "change-1", + base_text: "Pack a coat.\n", + local_text: "Pack a warm coat.\n", + cloud_text: "Pack a rain coat.\n", + }, + { + id: "change-2", + base_text: "Leave Monday.\n", + local_text: "Leave Tuesday.\n", + cloud_text: "Leave Wednesday.\n", + }, + ], + parts: [ + { type: "text", text: "# Trip\n" }, + { type: "change", change_id: "change-1" }, + { type: "change", change_id: "change-2" }, + ], + }); + const view = mount({}); + await act(async () => Promise.resolve()); + + const save = button(view.host, "Save combined note"); + expect(save.disabled).toBe(true); + + await act(async () => button(view.host, "Use this device").click()); + expect(save.disabled).toBe(true); + expect(view.host.textContent).toContain("the remaining change"); + + // Typing is an edit, not an answer: the second change would still be + // saved as the last synced wording. + await act(async () => { + const editor = textarea(view.host); + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + setter?.call(editor, "# Trip\nPack a warm coat.\nLeave Tuesday.\n"); + editor.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(save.disabled).toBe(true); + expect(view.host.textContent).toContain("the remaining change"); + + await act(async () => + [...view.host.querySelectorAll("button")] + .filter((candidate) => candidate.textContent?.trim() === "Use other device")[1] + .click(), + ); + expect(save.disabled).toBe(false); + expect(view.host.textContent).not.toContain("the remaining change"); + view.unmount(); + }); + + it("reloads both versions after a rejected save so the retry is not stale", async () => { + bridge.resolveCloudConflict.mockRejectedValueOnce( + new Error("This file changed again while you were reviewing it."), + ); + const view = mount({}); + await act(async () => Promise.resolve()); + expect(bridge.getCloudConflict).toHaveBeenCalledTimes(1); + + await act(async () => button(view.host, "Use other device").click()); + await act(async () => { + button(view.host, "Save combined note").click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(view.host.textContent).toContain( + "This file changed again while you were reviewing it.", + ); + expect(bridge.getCloudConflict).toHaveBeenCalledTimes(2); + + // The refetched details drive the next attempt, and the error stays up + // until it succeeds. + await act(async () => Promise.resolve()); + expect(view.host.textContent).toContain( + "This file changed again while you were reviewing it.", + ); + expect(button(view.host, "Save combined note")).toBeInstanceOf( + HTMLButtonElement, + ); + view.unmount(); + }); + + it("explains a delete conflict as a human choice", async () => { + bridge.getCloudConflict.mockResolvedValue({ + ...details, + conflict: { + ...conflict, + kind: "delete", + cloud_path: null, + can_merge: false, + }, + cloud: { + ...details.cloud, + path: null, + sha256: null, + text: null, + deleted: true, + }, + suggested_text: null, + changes: [], + parts: [], + }); + const view = mount({ + conflict: { + ...conflict, + kind: "delete", + cloud_path: null, + can_merge: false, + }, + }); + await act(async () => Promise.resolve()); + + expect(view.host.textContent).toContain("deleted on another"); + expect(view.host.textContent).toContain("Keep this note"); + expect(view.host.textContent).toContain("Delete everywhere"); + + await act(async () => button(view.host, "Delete everywhere").click()); + expect(bridge.resolveCloudConflict).not.toHaveBeenCalled(); + expect(view.host.querySelector('[role="alertdialog"]')?.textContent).toContain( + "Delete this file everywhere?", + ); + + await act(async () => { + button(view.host, "Delete file everywhere").click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(bridge.resolveCloudConflict).toHaveBeenCalledWith( + expect.objectContaining({ choice: "cloud" }), + ); + view.unmount(); + }); +}); + +function version(path: string, text: string) { + return { + path, + revision: 7, + sha256: `hash-${text}`, + byte_length: text.length, + media_type: "text/markdown", + text, + deleted: false, + }; +} + +function mount(overrides: { + conflict?: CloudSyncPendingConflict; + onResolved?: (summary: CloudSyncRunSummary) => void; + onClose?: () => void; +}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => + root.render( + createElement(CloudPendingConflictResolver, { + conflict: overrides.conflict ?? conflict, + vaultName: "Cloud Notes", + onResolved: overrides.onResolved ?? vi.fn(), + onClose: overrides.onClose ?? vi.fn(), + }), + ), + ); + return { + host, + unmount() { + act(() => root.unmount()); + host.remove(); + }, + }; +} + +function button(host: HTMLElement, label: string): HTMLButtonElement { + const match = [...host.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ); + if (!(match instanceof HTMLButtonElement)) + throw new Error(`Missing button: ${label}`); + return match; +} + +function textarea(host: HTMLElement): HTMLTextAreaElement { + const value = host.querySelector("textarea"); + if (!(value instanceof HTMLTextAreaElement)) + throw new Error("Missing textarea"); + return value; +} diff --git a/packages/app-core/src/components/CloudPendingConflictResolver.tsx b/packages/app-core/src/components/CloudPendingConflictResolver.tsx new file mode 100644 index 00000000..420958fb --- /dev/null +++ b/packages/app-core/src/components/CloudPendingConflictResolver.tsx @@ -0,0 +1,951 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { + CloudSyncMergeChange, + CloudSyncPendingConflict, + CloudSyncPendingConflictDetails, + CloudSyncPendingConflictResolution, + CloudSyncRunSummary, +} from "@zennotes/bridge-contract/cloud-sync"; +import { getZenBridge } from "@zennotes/bridge-contract/bridge"; +import { syncCloudVaultWithStatus } from "../lib/cloud-auto-sync"; +import { Button } from "./ui/Button"; + +type ChangeChoice = "local" | "cloud" | "both"; +type WholeVersionChoice = "local" | "cloud"; + +export function CloudPendingConflictResolver({ + conflict, + vaultName, + onResolved, + onClose, +}: { + conflict: CloudSyncPendingConflict; + vaultName: string; + onResolved: (summary: CloudSyncRunSummary) => void; + onClose: () => void; +}): JSX.Element { + const [bridge] = useState(() => getZenBridge()); + const [details, setDetails] = + useState(null); + const [choices, setChoices] = useState>({}); + const [draft, setDraft] = useState(""); + const [manualDraft, setManualDraft] = useState(false); + const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">( + "idle", + ); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [keepBothOpen, setKeepBothOpen] = useState(false); + const [finishLaterOpen, setFinishLaterOpen] = useState(false); + const [combineOpen, setCombineOpen] = useState(false); + const [wholeVersionChoice, setWholeVersionChoice] = + useState(null); + const [keepBothPath, setKeepBothPath] = useState(() => + localCopyPath(conflict.path), + ); + // Bumped after a failed resolve. A rejected save means the file moved under + // us, so the next attempt needs fresh hashes: without a refetch every retry + // fails with the same stale-choice error and the queue is a dead end. + const [reload, setReload] = useState({ nonce: 0, keepError: false }); + const [reloading, setReloading] = useState(false); + const [resolvedPath, setResolvedPath] = useState(conflict.path); + const loadedDraft = useRef(null); + const finishLaterButton = useRef(null); + const finishLaterDialog = useRef(null); + const wholeVersionSource = useRef(null); + const wholeVersionDialog = useRef(null); + + useEffect(() => { + let cancelled = false; + setDetails(null); + setChoices({}); + if (!reload.keepError) setError(null); + setReloading(reload.keepError); + setKeepBothOpen(false); + setFinishLaterOpen(false); + setCombineOpen(false); + setWholeVersionChoice(null); + void bridge + .getCloudConflict(conflict.id) + .then((next) => { + if (cancelled) return; + setReloading(false); + const initialDraft = + next.draft_text ?? + (next.parts.length > 0 + ? combinedText(next, {}) + : (next.suggested_text ?? + next.local.text ?? + next.cloud.text ?? + "")); + loadedDraft.current = initialDraft; + setDetails(next); + setResolvedPath(next.local.path ?? next.cloud.path ?? conflict.path); + setDraft(initialDraft); + setManualDraft(next.draft_text !== null); + setSaveState(next.draft_text !== null ? "saved" : "idle"); + }) + .catch((cause) => { + if (cancelled) return; + setReloading(false); + setError(message(cause)); + }); + return () => { + cancelled = true; + }; + }, [bridge, conflict.id, reload]); + + useEffect(() => { + if (finishLaterOpen) finishLaterDialog.current?.focus(); + }, [finishLaterOpen]); + + useEffect(() => { + if (wholeVersionChoice) wholeVersionDialog.current?.focus(); + }, [wholeVersionChoice]); + + useEffect(() => { + if (!details || draft === loadedDraft.current) return undefined; + setSaveState("saving"); + const timeout = window.setTimeout(() => { + void bridge + .saveCloudConflictDraft(conflict.id, draft) + .then(() => { + loadedDraft.current = draft; + setSaveState("saved"); + }) + .catch((cause) => { + setSaveState("idle"); + setError(message(cause)); + }); + }, 500); + return () => window.clearTimeout(timeout); + }, [bridge, conflict.id, details, draft]); + + const unresolvedChanges = + details?.changes.filter((change) => choices[change.id] === undefined) + .length ?? 0; + // Editing the text by hand does not answer an overlapping change: an + // unanswered one still renders as the last synced wording, so saving with + // any left would silently drop both devices' edits to that part. + const canSaveCombined = Boolean( + details !== null && + details.local.text !== null && + details.cloud.text !== null && + resolvedPath.trim().length > 0 && + unresolvedChanges === 0 && + (details.changes.length > 0 || + manualDraft || + (conflict.has_base && details.suggested_text !== null)), + ); + const titleId = useMemo( + () => `cloud-pending-conflict-${safeId(conflict.id)}`, + [conflict.id], + ); + + const chooseChange = (changeId: string, choice: ChangeChoice): void => { + if (!details) return; + setWholeVersionChoice(null); + const nextChoices = { ...choices, [changeId]: choice }; + setChoices(nextChoices); + setDraft(combinedText(details, nextChoices)); + setManualDraft(false); + }; + + const chooseWholeVersion = (choice: WholeVersionChoice): void => { + wholeVersionSource.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + setKeepBothOpen(false); + setWholeVersionChoice(choice); + }; + + const cancelWholeVersion = (): void => { + setWholeVersionChoice(null); + window.setTimeout(() => wholeVersionSource.current?.focus(), 0); + }; + + const resolve = async ( + resolution: Pick< + CloudSyncPendingConflictResolution, + "choice" | "keep_both_path" | "merged_text" | "resolved_path" + >, + ): Promise => { + if (!details) return; + setBusy(true); + setError(null); + try { + await bridge.resolveCloudConflict({ + conflict_id: conflict.id, + expected_local_sha256: details.local.sha256, + expected_cloud_revision: details.cloud.revision, + ...resolution, + }); + onResolved(await syncCloudVaultWithStatus(bridge, vaultName)); + } catch (cause) { + setError(message(cause)); + setReload((current) => ({ nonce: current.nonce + 1, keepError: true })); + } finally { + setBusy(false); + } + }; + + const finishLater = async (): Promise => { + if (details && draft !== loadedDraft.current) { + setSaveState("saving"); + try { + await bridge.saveCloudConflictDraft(conflict.id, draft); + loadedDraft.current = draft; + } catch (cause) { + setError(message(cause)); + setSaveState("idle"); + return; + } + } + onClose(); + }; + + return ( +
+
+
+

+ {fileName(conflict.path)} +

+
+ {conflict.path} +
+

+ {conflictExplanation(conflict)} +

+
+ +
+ + {finishLaterOpen && ( +
+

+ Resolve this note later? +

+

+ This note will wait to sync. Your draft and both complete versions + stay safe, while your other notes keep syncing. +

+
+ + +
+
+ )} + + {error && ( +
+ {error} +
+ )} + + {!details && (!error || reloading) && ( +
+ Loading the versions kept for you… +
+ )} + + {details && !finishLaterOpen && ( + <> + {!details.conflict.has_base && ( + chooseWholeVersion("local")} + onChooseCloud={() => chooseWholeVersion("cloud")} + onKeepBoth={() => { + setWholeVersionChoice(null); + setKeepBothOpen(true); + }} + onCombine={ + details.local.text !== null && details.cloud.text !== null + ? () => { + setWholeVersionChoice(null); + setCombineOpen(true); + setManualDraft(true); + } + : undefined + } + /> + )} + + {!details.conflict.has_base && keepBothOpen && ( + + void resolve({ + choice: "both", + keep_both_path: keepBothPath.trim(), + }) + } + onBack={() => setKeepBothOpen(false)} + /> + )} + + {details.changes.length > 0 && ( +
+
+

+ Choose what to keep +

+

+ ZenNotes already combined edits made in different parts. For + each part below, choose what the combined note should say. + Nothing is replaced until you save. +

+
+ {details.changes.map((change, index) => ( + chooseChange(change.id, choice)} + /> + ))} +
+ )} + + {details.local.text !== null && + details.cloud.text !== null && + (details.conflict.has_base || combineOpen) && ( +
+
+
+ +
+ {saveState === "saving" + ? "Saving draft…" + : saveState === "saved" + ? "Draft saved" + : "Your original versions are safe"} +
+
+ +
+