diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6b88af84..776c2f23 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.71.0", + "version": "2.72.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/cloud-auth-loopback.ts b/apps/desktop/src/main/cloud-auth-loopback.ts index 41a9f312..d1337807 100644 --- a/apps/desktop/src/main/cloud-auth-loopback.ts +++ b/apps/desktop/src/main/cloud-auth-loopback.ts @@ -81,7 +81,11 @@ export class CloudAuthLoopbackServer { ): Promise { const callbackUrl = this.callbackUrl; if (!callbackUrl) { - this.respond(response, 410, "This ZenNotes sign-in request has expired."); + this.respond( + response, + 410, + "This sign-in has expired. Return to ZenNotes and choose Connect ZenNotes Cloud again.", + ); return; } @@ -93,7 +97,7 @@ export class CloudAuthLoopbackServer { const expectedUrl = new URL(callbackUrl); if (host !== expectedUrl.host || !requestUrl.startsWith("/")) { - this.respond(response, 400, "Invalid ZenNotes callback."); + this.respond(response, 400, "This sign-in link isn't valid. Return to ZenNotes and choose Connect ZenNotes Cloud again."); return; } @@ -101,12 +105,12 @@ export class CloudAuthLoopbackServer { try { parsed = new URL(requestUrl, callbackUrl); } catch { - this.respond(response, 400, "Invalid ZenNotes callback."); + this.respond(response, 400, "This sign-in link isn't valid. Return to ZenNotes and choose Connect ZenNotes Cloud again."); return; } if (parsed.origin !== expectedUrl.origin) { - this.respond(response, 404, "ZenNotes callback not found."); + this.respond(response, 404, "This sign-in link isn't valid. Return to ZenNotes and choose Connect ZenNotes Cloud again."); return; } @@ -134,7 +138,7 @@ export class CloudAuthLoopbackServer { } if (parsed.pathname !== CALLBACK_PATH) { - this.respond(response, 404, "ZenNotes callback not found."); + this.respond(response, 404, "This sign-in link isn't valid. Return to ZenNotes and choose Connect ZenNotes Cloud again."); return; } @@ -152,7 +156,7 @@ export class CloudAuthLoopbackServer { !/^[A-Za-z0-9._-]+$/.test(state) || state.length > 128 ) { - this.respond(response, 400, "Invalid ZenNotes callback."); + this.respond(response, 400, "This sign-in link isn't valid. Return to ZenNotes and choose Connect ZenNotes Cloud again."); return; } @@ -160,7 +164,7 @@ export class CloudAuthLoopbackServer { this.respond( response, 410, - "This ZenNotes sign-in request was already used.", + "This sign-in is already finished. You can close this tab.", ); return; } diff --git a/apps/desktop/src/main/cloud-auth.test.ts b/apps/desktop/src/main/cloud-auth.test.ts index 0aef4b49..f25e4d98 100644 --- a/apps/desktop/src/main/cloud-auth.test.ts +++ b/apps/desktop/src/main/cloud-auth.test.ts @@ -7,6 +7,11 @@ import { normalizeCloudBaseUrl, resolveCloudBaseUrl, } from "./cloud-auth"; +import { + CLOUD_SIGN_IN_OFFLINE_MESSAGE, + CLOUD_SIGN_IN_TROUBLE_MESSAGE, + describeCloudError, +} from "@zennotes/shared-domain/cloud-error-copy"; const temporaryDirectories: string[] = []; @@ -21,6 +26,7 @@ afterEach(async () => { async function setup( allowInsecureLocalDevelopment = false, revokeTimeoutMs?: number, + keyringAnswers = true, ) { const storageDirectory = await mkdtemp( path.join(os.tmpdir(), "zennotes-cloud-auth-"), @@ -37,6 +43,7 @@ async function setup( fetchImplementation, getSecret: async (baseUrl) => secrets.get(baseUrl) ?? null, setSecret: async (baseUrl, token) => { + if (!keyringAnswers) return false; secrets.set(baseUrl, token); return true; }, @@ -148,8 +155,79 @@ describe("CloudAuthManager", () => { await expect( manager.complete({ code: "OneTimeCode", state: "other-state" }), - ).rejects.toThrow("invalid or has expired"); + ).rejects.toThrow("This sign-in has expired."); expect(fetchImplementation).not.toHaveBeenCalled(); + // A stale link leaves the request this device is waiting on alone. + expect((await manager.status()).state).toBe("connecting"); + }); + + it("words a refused exchange, keeps the server's text as the detail, and stops waiting", async () => { + const { manager, fetchImplementation } = await setup(); + await manager.connect("https://zennotes.org"); + fetchImplementation.mockResolvedValueOnce( + new Response(JSON.stringify({ message: "Invalid or expired code." }), { + status: 422, + headers: { "Content-Type": "application/json" }, + }), + ); + + const failure = await manager + .complete({ code: "OneTimeCode", state: "fixed-state" }) + .catch((error: unknown) => error as Error); + expect(describeCloudError(failure)).toEqual({ + message: expect.stringMatching(/^This sign-in didn't go through/), + detail: "HTTP 422 · Invalid or expired code.", + kind: "other", + }); + expect(await manager.status()).toEqual({ state: "disconnected", account: null }); + }); + + it("asks to connect again when Cloud is busy or unreachable during sign-in, never promising a retry", async () => { + const { manager, fetchImplementation } = await setup(); + await manager.connect("https://zennotes.org"); + fetchImplementation.mockResolvedValueOnce( + new Response(JSON.stringify({ message: "Server Error" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + ); + const trouble = await manager + .complete({ code: "OneTimeCode", state: "fixed-state" }) + .catch((error: unknown) => error); + expect(describeCloudError(trouble)).toEqual({ + message: CLOUD_SIGN_IN_TROUBLE_MESSAGE, + detail: "HTTP 503 · Server Error", + kind: "other", + }); + expect(await manager.status()).toEqual({ state: "disconnected", account: null }); + + await manager.connect("https://zennotes.org"); + fetchImplementation.mockRejectedValueOnce(new TypeError("fetch failed")); + const offline = await manager + .complete({ code: "OneTimeCode", state: "fixed-state" }) + .catch((error: unknown) => error); + expect(describeCloudError(offline).message).toBe(CLOUD_SIGN_IN_OFFLINE_MESSAGE); + expect(await manager.status()).toEqual({ state: "disconnected", account: null }); + }); + + it("says plainly when the keyring cannot keep the sign-in, and stops waiting", async () => { + const { manager, fetchImplementation } = await setup(false, undefined, false); + await manager.connect("https://zennotes.org"); + fetchImplementation.mockResolvedValueOnce( + new Response( + JSON.stringify({ + token: "secret-token", + user: { name: "Ada", email: "ada@example.com" }, + device: { id: "device-1", name: "Test Mac", platform: "desktop" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await expect( + manager.complete({ code: "OneTimeCode", state: "fixed-state" }), + ).rejects.toThrow(/^ZenNotes couldn't save your sign-in safely/); + expect((await manager.status()).state).toBe("disconnected"); }); it("clears pending authorization when the browser cannot open", async () => { @@ -199,7 +277,7 @@ describe("CloudAuthManager", () => { }); await expect( manager.complete({ code: "OneTimeCode", state: "fixed-state" }), - ).rejects.toThrow("invalid or has expired"); + ).rejects.toThrow("This sign-in has expired."); }); }); diff --git a/apps/desktop/src/main/cloud-auth.ts b/apps/desktop/src/main/cloud-auth.ts index 39990e2d..7f6701ac 100644 --- a/apps/desktop/src/main/cloud-auth.ts +++ b/apps/desktop/src/main/cloud-auth.ts @@ -7,6 +7,14 @@ import type { CloudAccountStatus, } from "@zennotes/bridge-contract/cloud-sync"; import type { CloudAuthDeepLinkRequest } from "./deep-links"; +import { + CLOUD_SIGN_IN_EXPIRED_MESSAGE, + CLOUD_SIGN_IN_REFUSED_MESSAGE, + CLOUD_SIGN_IN_UNEXPECTED_MESSAGE, + cloudSignInExchangeError, + encodeCloudErrorMessage, +} from "@zennotes/shared-domain/cloud-error-copy"; +import { CloudServiceRequestError } from "./cloud-sync-client"; const PENDING_AUTH_FILE = "cloud-auth-pending.json"; const CLOUD_ACCOUNT_FILE = "cloud-account.json"; @@ -136,40 +144,70 @@ export class CloudAuthManager { ): Promise { const pending = await this.readPending(); if (!pending || !safeEquals(request.state, pending.state)) { - throw new Error( - "This ZenNotes Cloud sign-in request is invalid or has expired.", - ); + throw new Error(CLOUD_SIGN_IN_EXPIRED_MESSAGE); + } + try { + return await this.exchange(pending, request); + } catch (error) { + // The code is spent and the browser says the sign-in failed, so this + // request can no longer finish: stop showing "Finish signing in in + // your browser" for the minutes it has left. + await this.removeFile(PENDING_AUTH_FILE); + throw error; } + } - const response = await this.fetchImplementation( - `${pending.base_url}/api/v1/app/exchange`, - { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", + private async exchange( + pending: PendingCloudAuth, + request: CloudAuthDeepLinkRequest, + ): Promise { + let response: Response; + try { + response = await this.fetchImplementation( + `${pending.base_url}/api/v1/app/exchange`, + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: request.code, + state: request.state, + code_verifier: pending.code_verifier, + device_name: this.dependencies.deviceName, + platform: "desktop", + app_version: this.dependencies.appVersion, + }), + signal: AbortSignal.timeout(30_000), }, - body: JSON.stringify({ - code: request.code, - state: request.state, - code_verifier: pending.code_verifier, - device_name: this.dependencies.deviceName, - platform: "desktop", - app_version: this.dependencies.appVersion, - }), - signal: AbortSignal.timeout(30_000), - }, - ); + ); + } catch (error) { + throw cloudSignInExchangeError(error); + } const payload = await parseExchangeResponse(response); if (!response.ok) { + const refusal = exchangeRefusal(payload); + // Busy or broken service: "connect again", since sign-in never retries. + if (response.status === 429 || response.status >= 500) { + throw cloudSignInExchangeError( + new CloudServiceRequestError( + refusal.message ?? `ZenNotes Cloud sign-in failed (${response.status}).`, + response.status, + refusal.code, + ), + ); + } throw new Error( - typeof payload.message === "string" - ? payload.message - : "ZenNotes Cloud rejected this sign-in request.", + encodeCloudErrorMessage({ + message: CLOUD_SIGN_IN_REFUSED_MESSAGE, + detail: `HTTP ${response.status} · ${refusal.message ?? "no message"}`, + kind: "other", + }), ); } if (!isExchangeResponse(payload)) { - throw new Error("ZenNotes Cloud returned an invalid sign-in response."); + throw new Error(CLOUD_SIGN_IN_UNEXPECTED_MESSAGE); } if (!(await this.dependencies.setSecret(pending.base_url, payload.token))) { // On Linux this almost always means Chromium settled on its plaintext @@ -177,10 +215,13 @@ export class CloudAuthManager { // point at the override instead of leaving a dead end. throw new Error( process.platform === "linux" - ? "ZenNotes could not store the cloud credential securely on this device. " + - "If a Secret Service keyring (such as gnome-keyring) is running, " + - "launch ZenNotes with --password-store=gnome-libsecret and sign in again." - : "ZenNotes could not store the cloud credential securely on this device.", + ? "ZenNotes couldn't save your sign-in safely, so this device is not signed in. " + + "ZenNotes keeps it in your system keyring, and none answered. Make sure a " + + "keyring app such as GNOME Keyring is running. If it is, quit ZenNotes, start " + + "it with the option --password-store=gnome-libsecret, and sign in again." + : "ZenNotes couldn't save your sign-in safely in this computer's keychain, so " + + "this device is not signed in. Quit and reopen ZenNotes, then choose Connect " + + "ZenNotes Cloud again.", ); } @@ -407,6 +448,24 @@ function isLoopbackCallbackUrl(value: string): boolean { return Number.isInteger(port) && port <= 65_535; } +/** The exchange answers in Laravel's flat `{message}` shape for a bad code + * and in the API's `{error: {code, message}}` shape for everything else. */ +function exchangeRefusal(payload: Record): { + message: string | null; + code: string | null; +} { + const nested = + payload.error && typeof payload.error === "object" + ? (payload.error as Record) + : null; + const message = nested?.message ?? payload.message; + const code = nested?.code ?? payload.code; + return { + message: typeof message === "string" && message ? message : null, + code: typeof code === "string" && code ? code : null, + }; +} + async function parseExchangeResponse( response: Response, ): Promise> { diff --git a/apps/desktop/src/main/cloud-sync-client.ts b/apps/desktop/src/main/cloud-sync-client.ts index 69bb23ba..f67efea6 100644 --- a/apps/desktop/src/main/cloud-sync-client.ts +++ b/apps/desktop/src/main/cloud-sync-client.ts @@ -12,6 +12,7 @@ import { chmod, mkdtemp, open, rm, stat } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { zne1CiphertextLength } from '@zennotes/shared-domain/cloud-e2ee' +import { cloudProtocolError } from '@zennotes/shared-domain/cloud-error-copy' import type { CloudVaultCipher, CloudVaultCipherSource } from '@zennotes/shared-domain/cloud-e2ee-vault' import type { CloudPublishUploadTarget, @@ -269,7 +270,7 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { if (source.bytes.byteLength !== source.byteLength) throw directUploadSizeMismatch() body = source.bytes as FetchBody } else { - throw new Error('A publish upload needs a file or its bytes.') + throw cloudProtocolError('A publish upload needs a file or its bytes.') } let response: Response @@ -319,7 +320,7 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { } } - throw new Error('ZenNotes Cloud upload completion ended unexpectedly.') + throw new CloudServiceRequestError('ZenNotes Cloud upload completion ended unexpectedly.', 0, 'INVALID_DIRECT_UPLOAD_RESPONSE') } } @@ -497,7 +498,7 @@ async function prepareEncryptedDirectUpload( let offset = 0 while (offset < piece.byteLength) { const written = await file.write(piece, offset) - if (written.bytesWritten === 0) throw new Error('Encrypted upload staging made no progress.') + if (written.bytesWritten === 0) throw Object.assign(new Error('Encrypted upload staging made no progress.'), { code: 'EIO' }) offset += written.bytesWritten } } diff --git a/apps/desktop/src/main/cloud-sync-download.ts b/apps/desktop/src/main/cloud-sync-download.ts index 729a2b68..f7c33db0 100644 --- a/apps/desktop/src/main/cloud-sync-download.ts +++ b/apps/desktop/src/main/cloud-sync-download.ts @@ -3,6 +3,7 @@ import { createReadStream, promises as fs } from 'node:fs' import os from 'node:os' import path from 'node:path' import type { CloudSyncContentMetadata } from '@zennotes/bridge-contract/cloud-sync' +import { CloudIssueError, cloudProtocolError } from '@zennotes/shared-domain/cloud-error-copy' import { registerCloudSyncStagedFile, validateCloudSyncContentReference, @@ -34,7 +35,7 @@ export async function stageDesktopCloudContent( // plaintext metadata like any other download. const encrypted = reference.encrypted const decryptor = source.decryptor - if (encrypted && !decryptor) throw new Error('This encrypted download has no key to open it.') + if (encrypted && !decryptor) throw cloudProtocolError('This encrypted download has no key to open it.') throwIfCloudSyncCancelled(source.signal) const directory = await fs.mkdtemp( path.join(options.stagingDirectory ?? os.tmpdir(), 'zennotes-download-') @@ -51,7 +52,7 @@ export async function stageDesktopCloudContent( ) if (Date.parse(instruction.download.expires_at) <= Date.now()) { if (attempt === 0) continue - throw new Error('Cloud returned an expired download URL.') + throw new CloudIssueError('download-incomplete', 'Cloud returned an expired download URL.') } const timeout = AbortSignal.timeout(300_000) const signal = source.signal ? AbortSignal.any([source.signal, timeout]) : timeout @@ -65,12 +66,12 @@ export async function stageDesktopCloudContent( if (response.redirected || !response.ok) { await response.body?.cancel() if (!response.redirected && attempt === 0 && [401, 403].includes(response.status)) continue - throw Object.assign(new Error('Cloud object download failed.'), { status: response.status }) + throw Object.assign(new CloudIssueError('download-incomplete', 'Cloud object download failed.'), { status: response.status }) } const length = response.headers.get('content-length') if (length !== null && Number(length) !== (encrypted ?? reference).byte_length) { await response.body?.cancel() - throw new Error('Cloud download length did not match its reference.') + throw new CloudIssueError('download-incomplete', 'Cloud download length did not match its reference.') } const reader = response.body?.getReader() async function* chunks(): AsyncGenerator { @@ -116,7 +117,7 @@ export async function stageDesktopCloudContent( preview ) } - throw new Error('Cloud download could not be refreshed.') + throw new CloudIssueError('download-incomplete', 'Cloud download could not be refreshed.') } catch (error) { await fs.rm(directory, { recursive: true, force: true }) throw error @@ -136,12 +137,12 @@ async function* verifiedCiphertext( let length = 0 for await (const chunk of chunks) { length += chunk.byteLength - if (length > expected.byte_length) throw new Error('Cloud download exceeded its encrypted length.') + if (length > expected.byte_length) throw new CloudIssueError('download-damaged', 'Cloud download exceeded its encrypted length.') hash.update(chunk) yield chunk } if (length !== expected.byte_length || hash.digest('hex') !== expected.sha256) { - throw new Error('Cloud download failed length or SHA-256 verification.') + throw new CloudIssueError('download-damaged', 'Cloud download failed length or SHA-256 verification.') } } @@ -169,17 +170,17 @@ async function writeVerified( throwIfCloudSyncCancelled(signal) length += chunk.byteLength if (length > expected.byte_length) - throw new Error('Cloud download exceeded its reference length.') + throw new CloudIssueError('download-damaged', 'Cloud download exceeded its reference length.') hash.update(chunk) let offset = 0 while (offset < chunk.byteLength) { const written = await file.write(chunk, offset) - if (written.bytesWritten === 0) throw new Error('Cloud staging write made no progress.') + if (written.bytesWritten === 0) throw Object.assign(new Error('Cloud staging write made no progress.'), { code: 'EIO' }) offset += written.bytesWritten } } if (length !== expected.byte_length || hash.digest('hex') !== expected.sha256) { - throw new Error('Cloud download failed length or SHA-256 verification.') + throw new CloudIssueError('download-damaged', 'Cloud download failed length or SHA-256 verification.') } await file.sync() } finally { diff --git a/apps/desktop/src/main/cloud-sync-filesystem.test.ts b/apps/desktop/src/main/cloud-sync-filesystem.test.ts index d3af0116..9b98a5c7 100644 --- a/apps/desktop/src/main/cloud-sync-filesystem.test.ts +++ b/apps/desktop/src/main/cloud-sync-filesystem.test.ts @@ -15,6 +15,7 @@ import os from 'node:os' import path from 'node:path' import { createHash } from 'node:crypto' import { DesktopCloudSyncRepository, DesktopCloudSyncStateStore } from './cloud-sync-filesystem' +import { createCloudSyncClient } from './cloud-sync-client' import { CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES, cloudSyncUploadSource @@ -793,3 +794,150 @@ describe('DesktopCloudSyncRepository: a Cloud restore from Trash', () => { expect(await readdir(path.join(root, 'trash'))).toEqual([]) }) }) + +describe('DesktopCloudSyncRepository: back after Cloud pruned its change log', () => { + it('rebuilds from the manifest without losing an edit made while away', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'Plan.md'), 'v1') + await writeFile(path.join(root, 'Draft.md'), 'draft, edited here') + await writeFile(path.join(root, 'Trip.md'), 'trip, edited here') + const item = (id: string, relPath: string, revision: number, contents: string) => ({ + ...tracked(relPath, contents), item_id: id, revision + }) + // The state the desktop kept from before the trip, reused on relink. + const states = new DesktopCloudSyncStateStore(await temporaryRoot()) + await states.save({ + version: 1, + vault_id: 'vault', + cursor: 3, + items: { + plan: item('plan', 'Plan.md', 1, 'v1'), + draft: item('draft', 'Draft.md', 1, 'draft'), + trip: item('trip', 'Trip.md', 1, 'trip') + } + }) + const listed = [item('plan', 'Plan.md', 7, 'v7'), item('draft', 'Draft.md', 1, 'draft')] + const remote: CloudSyncRemote = { + manifest: vi.fn(async () => ({ + data: listed.map((entry, index) => ({ ...entry, content: upsert(entry.path, index === 0 ? 'v7' : 'draft').content })), + cursor: 40, + next_page: null + })), + changes: vi.fn(async (_vault: string, after: number) => { + if (after < 30) { + throw Object.assign(new Error('This cursor is older than the retained change log.'), { + name: 'CloudServiceRequestError', status: 410, code: 'CURSOR_EXPIRED', details: { pruned_through: 30, cursor: 40 } + }) + } + return { data: [], cursor: after, has_more: false } + }), + mutate: vi.fn(async (_vault, body) => ({ + acknowledged: body.mutations.map((mutation) => ({ + operation_id: mutation.operation_id, item_id: mutation.item_id, revision: 2, sequence: 41 + })), + cursor: 41, + conflicts: [] + })) + } + let operation = 0 + const coordinator = new CloudSyncCoordinator('vault', remote, new DesktopCloudSyncRepository(root), states, { + itemId: () => 'item-new', operationId: () => `operation-${++operation}` + }) + + const result = await coordinator.sync() + + expect(await readFile(path.join(root, 'Plan.md'), 'utf8')).toBe('v7') + expect(await readFile(path.join(root, 'Draft.md'), 'utf8')).toBe('draft, edited here') + // Deleted in Cloud, edited here: kept, and waiting for a decision. + expect(await readFile(path.join(root, 'Trip.md'), 'utf8')).toBe('trip, edited here') + expect(vi.mocked(remote.mutate).mock.calls.flatMap((call) => call[1].mutations)).toEqual([ + expect.objectContaining({ type: 'upsert', item_id: 'draft', base_revision: 1, path: 'Draft.md' }) + ]) + expect(result.pendingConflicts).toEqual([ + expect.objectContaining({ item_id: 'trip', kind: 'delete', path: 'Trip.md', cloud_path: null }) + ]) + expect((await states.load('vault'))?.cursor).toBe(40) + }) + + it('reads the 410 off the wire through the desktop client and catches up', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'Plan.md'), 'v1') + await writeFile(path.join(root, 'Draft.md'), 'draft, edited here') + const states = new DesktopCloudSyncStateStore(await temporaryRoot()) + await states.save({ + version: 1, + vault_id: 'vault', + cursor: 3, + items: { + plan: { ...tracked('Plan.md', 'v1'), item_id: 'plan' }, + draft: { ...tracked('Draft.md', 'draft'), item_id: 'draft' } + } + }) + const body = (contents: string) => upsert('x', contents).content! + const json = (status: number, payload: unknown) => + new Response(JSON.stringify(payload), { status, headers: { 'Content-Type': 'application/json' } }) + const requests: string[] = [] + // Cloud's own answers, as the website sends them. + const fetchImplementation = vi.fn(async (input, init) => { + const url = new URL(String(input)) + requests.push(`${init?.method ?? 'GET'} ${url.pathname}${url.search}`) + if (url.pathname === '/api/v1/vaults/vault/changes') { + const after = Number(url.searchParams.get('after')) + if (after < 30) { + return json(410, { error: { + code: 'CURSOR_EXPIRED', + message: 'This cursor is older than the retained change log. Rebuild this vault from its manifest.', + details: { pruned_through: 30, cursor: 40 } + } }) + } + return json(200, { data: [], cursor: after, has_more: false }) + } + if (url.pathname === '/api/v1/vaults/vault/manifest') { + const metadata = (id: string, relPath: string, revision: number, contents: string) => { + const { data: _data, encoding: _encoding, ...content } = body(contents) + return { item_id: id, path: relPath, kind: 'text', revision, ...content } + } + return json(200, { + data: [metadata('draft', 'Draft.md', 1, 'draft'), metadata('plan', 'Plan.md', 7, 'v7')], + cursor: 40, + next_page: null + }) + } + if (url.pathname === '/api/v1/vaults/vault/items/plan/revisions/7') { + return json(200, { data: { item_id: 'plan', revision: 7, path: 'Plan.md', kind: 'text', deleted: false, content: body('v7') } }) + } + if (url.pathname === '/api/v1/vaults/vault/mutations') { + const { mutations } = JSON.parse(String(init?.body)) + return json(200, { + acknowledged: mutations.map((mutation: { operation_id: string; item_id: string }) => ({ + operation_id: mutation.operation_id, item_id: mutation.item_id, revision: 2, sequence: 41 + })), + conflicts: [], + cursor: 41 + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + let operation = 0 + const coordinator = new CloudSyncCoordinator( + 'vault', + createCloudSyncClient('https://zennotes.test', 'token', fetchImplementation), + new DesktopCloudSyncRepository(root), + states, + { itemId: () => 'item-new', operationId: () => `operation-${++operation}` } + ) + + await expect(coordinator.sync()).resolves.toMatchObject({ pulled: 1, pushed: 1 }) + + expect(await readFile(path.join(root, 'Plan.md'), 'utf8')).toBe('v7') + expect(await readFile(path.join(root, 'Draft.md'), 'utf8')).toBe('draft, edited here') + expect(requests).toEqual([ + 'GET /api/v1/vaults/vault/changes?after=3&limit=250', + 'GET /api/v1/vaults/vault/manifest?include_content=false&page=1&per_page=250', + 'GET /api/v1/vaults/vault/items/plan/revisions/7', + 'GET /api/v1/vaults/vault/changes?after=40&limit=250', + 'POST /api/v1/vaults/vault/mutations', + 'GET /api/v1/vaults/vault/changes?after=40&limit=250' + ]) + }) +}) diff --git a/apps/desktop/src/main/cloud-sync-filesystem.ts b/apps/desktop/src/main/cloud-sync-filesystem.ts index c7d70063..521b400d 100644 --- a/apps/desktop/src/main/cloud-sync-filesystem.ts +++ b/apps/desktop/src/main/cloud-sync-filesystem.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto' import { constants as fsConstants, createReadStream, promises as fs, type BigIntStats, type Stats } from 'node:fs' import path from 'node:path' import { atomicWriteTarget, renameWithRetry } from './atomic-write' +import { cloudProtocolError } from '@zennotes/shared-domain/cloud-error-copy' import { stageDesktopCloudContent, copyVerifiedCloudFile, type DesktopCloudDownloadOptions, type DesktopCloudStagedHandle } from './cloud-sync-download' import { cloudSyncStagedHandle, throwIfCloudSyncCancelled, type CloudSyncDownloadSource, type CloudSyncStagedFile, type CloudSyncStagedConflict } from '@zennotes/shared-domain/cloud-sync-content' import type { @@ -104,7 +105,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { if ([change.path, previous?.path].some((candidate) => candidate && !shouldSyncVaultPath(candidate))) return const handle = this.stagedHandle(file) if (change.type !== 'upsert' || !change.content_ref || change.item_id !== file.reference.item_id || - change.revision < file.reference.revision) throw new Error('Invalid staged Cloud change.') + change.revision < file.reference.revision) throw cloudProtocolError('Invalid staged Cloud change.') cloudSyncStagedHandle(file, change.content_ref) const atTarget = await this.localItemOrNull(change.path) if (atTarget?.content.sha256 === file.reference.sha256 && atTarget.content.byte_length === file.reference.byte_length) { @@ -135,13 +136,13 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { const local = expected ? await this.localItemOrNull(expected) : null if ((local?.content.sha256 ?? null) !== input.expected_sha256) throw new Error('This file changed on this device.') const replacing = expected !== null && cloudSyncPathKey(expected) === cloudSyncPathKey(cloud) - if (!replacing && await exists(this.resolve(cloud))) throw new Error(`${cloud} already exists.`) + if (!replacing && await exists(this.resolve(cloud))) throw new Error(`A file named “${cloud}” already exists. Choose another name.`) let keptCopy: Stats | undefined let cloudPublished = false try { if (keep) { if (!expected || !local) throw new Error('Both versions are no longer available.') - if (await exists(this.resolve(keep))) throw new Error(`${keep} already exists.`) + if (await exists(this.resolve(keep))) throw new Error(`A file named “${keep}” already exists. Choose another name.`) keptCopy = await this.publishFile(keep, this.resolve(expected), local.content, null, handle.signal) } // Recheck after a potentially long local copy, immediately before publish. @@ -183,7 +184,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { private stagedHandle(file: CloudSyncStagedFile): DesktopCloudStagedHandle { const handle = cloudSyncStagedHandle(file) - if (handle.owner !== this) throw new Error('Cloud staging belongs to another repository.') + if (handle.owner !== this) throw cloudProtocolError('Cloud staging belongs to another repository.') return handle } @@ -242,7 +243,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { if (affectedPaths.some((path) => !shouldSyncVaultPath(path))) return if (change.type === 'upsert') { - if (!change.content) throw new Error(`Upsert change ${change.sequence} did not include content`) + if (!change.content) throw cloudProtocolError(`Upsert change ${change.sequence} did not include content`) const atTarget = await this.readIfExists(change.path) // Already byte-for-byte what the change carries. There is nothing to // write and nothing to conflict over at the target, so adopt the file @@ -337,7 +338,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { const source = this.resolve(originalPath) const destination = this.resolve(localCopyPath) - if (await exists(destination)) throw new Error(`${localCopyPath} already exists.`) + if (await exists(destination)) throw new Error(`A file named “${localCopyPath}” already exists. Choose another name.`) await fs.mkdir(path.dirname(destination), { recursive: true }) await fs.rename(source, destination) try { @@ -384,7 +385,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { 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.`) + throw new Error(`A file named “${file.path}” already exists. Choose another name.`) } } @@ -446,7 +447,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { const absolutePath = path.resolve(root, ...normalized.split('/')) if (!absolutePath.startsWith(`${root}${path.sep}`)) { - throw new Error(`Cloud sync path escapes vault: ${relPath}`) + throw cloudProtocolError(`Cloud sync path escapes vault: ${relPath}`) } return absolutePath @@ -735,7 +736,7 @@ function directUploadContent(relPath: string, absolutePath: string, bytes: Buffe 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') + throw cloudProtocolError('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 diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts index 0f21e465..57f7f0d9 100644 --- a/apps/desktop/src/main/cloud-sync-service.test.ts +++ b/apps/desktop/src/main/cloud-sync-service.test.ts @@ -824,7 +824,7 @@ describe('DesktopCloudSyncService', () => { local_conflicts: [] }) - await expect(service.createBackup(localRoot)).rejects.toThrow('Resolve sync conflicts') + await expect(service.createBackup(localRoot)).rejects.toThrow('Some files are waiting for review') expect(client.createBackup).not.toHaveBeenCalled() }) diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts index f446ec61..d7b0df3e 100644 --- a/apps/desktop/src/main/cloud-sync-service.ts +++ b/apps/desktop/src/main/cloud-sync-service.ts @@ -30,6 +30,12 @@ import { cloudBackupItemsPage, restoreCloudBackup } from '@zennotes/shared-domain/cloud-backup' +import { + CLOUD_NOT_LINKED_MESSAGE, + CLOUD_NOT_SIGNED_IN_MESSAGE, + CLOUD_OTHER_ACCOUNT_MESSAGE, + CLOUD_VAULT_NAME_REQUIRED_MESSAGE +} from '@zennotes/shared-domain/cloud-error-copy' import { CLOUD_SYNC_SETTINGS_CONFLICT_PATH, CLOUD_SYNC_VAULT_SETTINGS_PATH @@ -237,7 +243,7 @@ export class DesktopCloudSyncService { private async encryptionConnection() { const connection = await this.connection() - if (!connection.keyStore) throw new Error('End-to-end encryption is not available in this build.') + if (!connection.keyStore) throw new Error("This version of ZenNotes can't use end-to-end encryption. Update ZenNotes to the latest version.") return { ...connection, keyStore: connection.keyStore } } @@ -289,7 +295,7 @@ export class DesktopCloudSyncService { const vault = (await client.listVaults()).data.find((candidate) => candidate.id === vaultId) if (!vault) throw new Error('That ZenNotes Cloud vault is not available to this account.') if (vault.encryption && !keyring) { - throw new Error('This vault is end-to-end encrypted, which this version of ZenNotes cannot open.') + throw new Error('This vault is end-to-end encrypted, which this version of ZenNotes cannot open. Update ZenNotes to the latest version.') } // Opening an encrypted vault's name needs the account key, so linking a // locked device stops here instead of at the first sync. @@ -311,7 +317,7 @@ export class DesktopCloudSyncService { options: { encrypted?: boolean } = {} ): Promise { const normalizedName = name.trim() - if (!normalizedName) throw new Error('Cloud vault name is required.') + if (!normalizedName) throw new Error(CLOUD_VAULT_NAME_REQUIRED_MESSAGE) const { account, client, keyStore, keyring } = await this.connection() let vault: CloudSyncVault if (options.encrypted) { @@ -423,10 +429,10 @@ export class DesktopCloudSyncService { } ) if (!response.ok) { - throw new Error(await backupDownloadError(response)) + throw await backupDownloadError(response) } if (!response.body) { - throw new Error('ZenNotes Cloud returned an empty backup archive.') + throw new CloudServiceRequestError('ZenNotes Cloud returned an empty backup archive.', response.status, 'BACKUP_NOT_AVAILABLE') } const target = path.resolve(destinationPath) @@ -556,10 +562,10 @@ export class DesktopCloudSyncService { async encryptLinkedVault(localRoot: string): Promise { const before = await this.sync(localRoot) if (hasOpenConflicts(before)) { - throw new Error('Resolve sync conflicts before encrypting this vault.') + throw new Error('Some files are waiting for review. Choose Review, settle them, then encrypt this vault.') } const { client, link, keyStore, keyring } = await this.linkedConnection(localRoot) - if (!keyStore || !keyring) throw new Error('End-to-end encryption is not available in this build.') + if (!keyStore || !keyring) throw new Error("This version of ZenNotes can't use end-to-end encryption. Update ZenNotes to the latest version.") if (link.replaces_vault_id) return this.syncAndRetireReplacedVault(localRoot) if (await keyring.forVault(link.vault_id)) throw new Error('This vault is already end-to-end encrypted.') const stored = await keyStore.load() @@ -578,10 +584,10 @@ export class DesktopCloudSyncService { private async syncAndRetireReplacedVault(localRoot: string): Promise { const summary = await this.sync(localRoot) if (hasOpenConflicts(summary)) { - throw new Error('Resolve sync conflicts, then sync again to finish encrypting this vault.') + throw new Error('Some files are waiting for review. Choose Review, settle them, then choose Sync now to finish encrypting this vault.') } const link = await this.readLink(localRoot) - if (!link) throw new Error('Link this local vault to a ZenNotes Cloud vault first.') + if (!link) throw new Error(CLOUD_NOT_LINKED_MESSAGE) return (await this.retireReplacedVault(localRoot, link, summary)) ?? link } @@ -629,10 +635,10 @@ export class DesktopCloudSyncService { private async run(localRoot: string): Promise { const link = await this.readLink(localRoot) - if (!link) throw new Error('Link this local vault to a ZenNotes Cloud vault before syncing.') + if (!link) throw new Error(CLOUD_NOT_LINKED_MESSAGE) const { account, client } = await this.connection() if (link.base_url !== account.base_url) { - throw new Error('This vault is linked to a different ZenNotes Cloud account.') + throw new Error(CLOUD_OTHER_ACCOUNT_MESSAGE) } const coordinator = createDesktopCloudSyncCoordinator({ @@ -836,7 +842,7 @@ export class DesktopCloudSyncService { keyring?: CloudE2eeKeyring }> { const connection = await this.optionalConnection() - if (!connection) throw new Error('Connect ZenNotes Cloud before using sync.') + if (!connection) throw new Error(CLOUD_NOT_SIGNED_IN_MESSAGE) return connection } @@ -854,7 +860,7 @@ export class DesktopCloudSyncService { const account = status.account const token = await this.dependencies.getSecret(account.base_url) signal.throwIfAborted() - if (!token) throw new Error('The ZenNotes Cloud credential is unavailable. Sign in again.') + if (!token) throw new Error("ZenNotes can't find your saved Cloud sign-in on this device. Choose Disconnect, then Connect ZenNotes Cloud to sign in again.") const keyStore = this.dependencies.accountKeyStore?.(account) const keyring = keyStore ? this.keyringFor(account, token, keyStore) : undefined return { @@ -908,10 +914,10 @@ export class DesktopCloudSyncService { keyring?: CloudE2eeKeyring }> { const link = await this.readLink(localRoot) - if (!link) throw new Error('Link this local vault to a ZenNotes Cloud vault first.') + if (!link) throw new Error(CLOUD_NOT_LINKED_MESSAGE) const connection = await this.connection() if (link.base_url !== connection.account.base_url) { - throw new Error('This vault is linked to a different ZenNotes Cloud account.') + throw new Error(CLOUD_OTHER_ACCOUNT_MESSAGE) } return { ...connection, link } } @@ -944,14 +950,24 @@ export class DesktopCloudSyncService { } } -async function backupDownloadError(response: Response): Promise { +/** The refusal as the API client would throw it, so it is worded by its + * status and code like every other Cloud answer. A refusal without a code + * reads as an archive that cannot be downloaded right now. */ +async function backupDownloadError(response: Response): Promise { + let message: string | null = null + let code: string | null = null try { - const payload = (await response.json()) as { error?: { message?: unknown } } - if (typeof payload.error?.message === 'string') return payload.error.message + const payload = (await response.json()) as { error?: { message?: unknown; code?: unknown } } + if (typeof payload.error?.message === 'string') message = payload.error.message + if (typeof payload.error?.code === 'string') code = payload.error.code } catch { - // The response was not JSON; use the stable fallback below. + // The response was not JSON; the status says enough. } - return `ZenNotes Cloud could not download this backup (${response.status}).` + return new CloudServiceRequestError( + message ?? `ZenNotes Cloud could not download this backup (${response.status}).`, + response.status, + code ?? (response.status === 401 ? null : 'BACKUP_NOT_AVAILABLE') + ) } function fingerprint(value: string): string { @@ -1005,6 +1021,6 @@ function assertBackupReady(summary: CloudSyncRunSummary): void { summary.bootstrap_conflicts.length > 0 || (summary.pending_conflicts?.length ?? 0) > 0 ) { - throw new Error('Resolve sync conflicts before creating a cloud backup.') + throw new Error('Some files are waiting for review. Settle them first (Review), then create the backup.') } } diff --git a/apps/desktop/src/main/cloud-sync-window-barrier.ts b/apps/desktop/src/main/cloud-sync-window-barrier.ts index a6e53634..a9407b4a 100644 --- a/apps/desktop/src/main/cloud-sync-window-barrier.ts +++ b/apps/desktop/src/main/cloud-sync-window-barrier.ts @@ -3,6 +3,7 @@ import type { CloudSyncRunSummary, CloudSyncWindowEvent } from '@zennotes/bridge-contract/cloud-sync' +import { describeCloudError, encodeCloudErrorMessage } from '@zennotes/shared-domain/cloud-error-copy' interface Participant { id: number @@ -76,7 +77,7 @@ export class CloudSyncWindowBarrier { () => reject( new Error( - 'Another ZenNotes window could not save its review draft. Sync paused; try again.' + "Another ZenNotes window couldn't save the review you were working on, so sync waited. Try again." ) ), this.options.timeoutMs ?? 10_000 @@ -92,11 +93,13 @@ export class CloudSyncWindowBarrier { (candidate) => !participants.some((participant) => participant.id === candidate.id) ) ) - throw new Error('Vault windows changed while preparing sync. Please try again.') + throw new Error('A ZenNotes window opened or closed while sync was starting. Choose Sync now to try again.') summary = await work() return summary } catch (cause) { - error = cause instanceof Error ? cause.message : String(cause) + // The other windows show this text: word it here, where the error + // still carries its status and code. + error = encodeCloudErrorMessage(describeCloudError(cause)) throw cause } finally { clearTimeout(timer) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 90501a5c..aed439ea 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -26,6 +26,7 @@ import { createRequire } from "node:module"; import { IPC } from "@shared/ipc"; import { openExternalUrl } from "./external-urls"; import type { + CloudAccountStatus, CloudBackupItemsQuery, CloudPublishNoteInput, CloudSyncBootstrapConflict, @@ -215,6 +216,10 @@ import type { CloudEncryptionSecret, StoredCloudAccountKey, } from "@zennotes/shared-domain/cloud-e2ee-account"; +import { + describeCloudError, + encodeCloudErrorMessage, +} from "@zennotes/shared-domain/cloud-error-copy"; import { CloudSyncWindowBarrier } from "./cloud-sync-window-barrier"; import { scanAllTasks, scanTasksForPath } from "./tasks"; import { @@ -639,10 +644,36 @@ function queueCloudAuthCallback( async function completeCloudAuthCallback( request: NonNullable>, ): Promise { - const status = await getCloudAuthManager().complete(request); + let status: CloudAccountStatus; + try { + status = await getCloudAuthManager().complete(request); + } catch (error) { + // The browser only says sign-in failed; the reason is told here, on + // every platform, and the app stops waiting for the browser. Not + // awaited: the loopback page answers the browser only after this. + broadcastCloudAccountChange(await getCloudAuthManager().status()); + void dialog.showMessageBox({ + type: "error", + title: "ZenNotes Cloud sign-in failed", + message: "ZenNotes Cloud sign-in failed", + detail: encodeCloudErrorMessage(describeCloudError(error)), + }); + throw error; + } broadcastCloudAccountChange(status); } +/** + * A Cloud failure as the window receives it. IPC carries only the message, + * and the status and code that say what went wrong are on the error here, so + * it is worded now; the technical line follows a marker app-core splits off. + * A cancelled request stays one, so the window can keep ignoring it. + */ +function cloudIpcError(error: unknown): unknown { + if (error instanceof Error && error.name === "AbortError") return error; + return new Error(encodeCloudErrorMessage(describeCloudError(error))); +} + async function flushPendingCloudAuthCallbacks(): Promise { if (flushingCloudAuthCallbacks || pendingCloudAuthCallbacks.length === 0) return; @@ -653,12 +684,8 @@ async function flushPendingCloudAuthCallbacks(): Promise { if (!request) continue; try { await completeCloudAuthCallback(request); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "ZenNotes Cloud sign-in failed."; - dialog.showErrorBox("ZenNotes Cloud sign-in failed", message); + } catch { + // completeCloudAuthCallback already told the person why. } } } finally { @@ -3264,13 +3291,18 @@ function registerIpc(): void { ...args: Args ) => Result | Promise, ): void => { + const cloud = channel.startsWith("cloud"); ipcMain.handle(channel, async (event, ...args) => { assertTrustedIpcEvent(event); const win = requireEventWindow(event); - return await ipcWindowContext.run( - win, - async () => await listener(event, ...(args as Args)), - ); + try { + return await ipcWindowContext.run( + win, + async () => await listener(event, ...(args as Args)), + ); + } catch (error) { + throw cloud ? cloudIpcError(error) : error; + } }); }; diff --git a/apps/desktop/src/main/tasks.ts b/apps/desktop/src/main/tasks.ts index 240f17cc..02d3ef55 100644 --- a/apps/desktop/src/main/tasks.ts +++ b/apps/desktop/src/main/tasks.ts @@ -43,11 +43,15 @@ async function readOne( /** Walk the whole vault and parse every task out of every live (non-trash) * note. Parallelized with `Promise.all` so a 500-note vault is IO-bound, * not sequentially latent. Folders on the vault's `tasks.excludedFolders` - * list (#458) are skipped before any file is read. */ + * list (#458), and every folder outside its Task folders once + * `tasks.includedFolders` names any (#962), are skipped before any file is + * read. */ export async function scanAllTasks(root: string): Promise { - const excluded = (await getVaultSettings(root)).tasks?.excludedFolders ?? [] + const tasksSettings = (await getVaultSettings(root)).tasks + const excluded = tasksSettings?.excludedFolders ?? [] + const included = tasksSettings?.includedFolders ?? [] const metas = (await listNotes(root)).filter( - (m) => includesFolder(m.folder) && !isPathExcludedFromTasks(m.path, excluded) + (m) => includesFolder(m.folder) && !isPathExcludedFromTasks(m.path, excluded, included) ) const batches = await Promise.all(metas.map((m) => readOne(root, m))) const out: VaultTask[] = [] @@ -72,9 +76,17 @@ export async function scanTasksForPath( const settings = await getVaultSettings(root) const folder = folderForRelativePath(posix, settings) if (!folder || !LIVE_FOLDERS.has(folder)) return [] - // Same exclusion the full scan applies (#458), or a single-note rescan - // would resurrect an excluded folder's tasks on every edit. - if (isPathExcludedFromTasks(posix, settings.tasks?.excludedFolders ?? [])) return [] + // Same folder lists the full scan applies (#458, #962), or a single-note + // rescan would resurrect a left-out folder's tasks on every edit. + if ( + isPathExcludedFromTasks( + posix, + settings.tasks?.excludedFolders ?? [], + settings.tasks?.includedFolders ?? [] + ) + ) { + return [] + } const abs = path.join(root, posix.split('/').join(path.sep)) let body: string diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 37f086ed..a1b403d4 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -93,7 +93,11 @@ import { systemFolderForDirName, type SystemFolderPaths } from '@shared/system-folder-paths' -import { normalizeTasksExcludedFolders } from '@shared/tasks-excluded-folders' +import { + normalizeTasksExcludedFolders, + normalizeTasksIncludedFolders, + rewriteTasksFoldersForRename +} from '@shared/tasks-excluded-folders' import { normalizeVaultDisplayName, resolveVaultName } from '@shared/vault-display-name' import { isTypstPreamblePath, @@ -895,6 +899,9 @@ function cloneVaultSettings(settings: VaultSettings): VaultSettings { ...settings.tasks, ...(settings.tasks.excludedFolders ? { excludedFolders: [...settings.tasks.excludedFolders] } + : {}), + ...(settings.tasks.includedFolders + ? { includedFolders: [...settings.tasks.includedFolders] } : {}) } } @@ -1216,15 +1223,20 @@ function normalizeVaultSettings( } } -/** Carry the Tasks-system settings (#458) through the round-trip: a validated - * excludedFolders list, or undefined when nothing survives so vault.json - * stays free of empty stubs. */ +/** Carry the Tasks-system settings through the round-trip: validated + * excludedFolders (#458) and includedFolders (#962) lists, each left out when + * empty, or undefined when nothing survives so vault.json stays free of empty + * stubs. */ function normalizeTasksSettings(raw: unknown): VaultSettings['tasks'] | undefined { if (!raw || typeof raw !== 'object') return undefined - const excluded = normalizeTasksExcludedFolders( - (raw as { excludedFolders?: unknown }).excludedFolders - ) - return excluded.length > 0 ? { excludedFolders: excluded } : undefined + const lists = raw as { excludedFolders?: unknown; includedFolders?: unknown } + const excluded = normalizeTasksExcludedFolders(lists.excludedFolders) + const included = normalizeTasksIncludedFolders(lists.includedFolders) + if (excluded.length === 0 && included.length === 0) return undefined + return { + ...(excluded.length > 0 ? { excludedFolders: excluded } : {}), + ...(included.length > 0 ? { includedFolders: included } : {}) + } } /** Carry the per-vault view overrides (#292) through the round-trip, keeping @@ -4124,12 +4136,16 @@ export async function renameFolder( } const settings = await getVaultSettings(root) + const oldRel = toPosix(path.relative(root, oldAbs)) + const newRel = toPosix(path.relative(root, newAbs)) const nextSettings: VaultSettings = { ...settings, folderIcons: rewriteFolderIconsForRename(settings.folderIcons, topFolder, oldClean, newClean), - folderColors: rewriteFolderColorsForRename(settings.folderColors, topFolder, oldClean, newClean) + folderColors: rewriteFolderColorsForRename(settings.folderColors, topFolder, oldClean, newClean), + // The Tasks folder lists name on-disk paths, so they follow the folder too. + tasks: rewriteTasksFoldersForRename(settings.tasks, oldRel, newRel) } - await renameFolderTrees(root, toPosix(path.relative(root, oldAbs)), toPosix(path.relative(root, newAbs)), () => setVaultSettings(root, nextSettings)) + await renameFolderTrees(root, oldRel, newRel, () => setVaultSettings(root, nextSettings)) return newClean } diff --git a/apps/desktop/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts index 9043d80d..695bea03 100644 --- a/apps/desktop/src/mcp/instructions.ts +++ b/apps/desktop/src/mcp/instructions.ts @@ -218,7 +218,8 @@ starts it expanded; use \`-\` for long blocks a reader opens on demand. - Task ids from list_tasks (\`path#index\`) are stable, so pass them to toggle_task. - list_tasks omits notes opted out of Tasks (frontmatter - \`tasks: false\`/\`note\`, excluded folders). Pass + \`tasks: false\`/\`note\`, excluded folders, and notes outside + the vault's Task folders when it has any). Pass includeExcluded: true only when the user asks for everything. ## Comments: reviewing a note together diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 3a81e651..a3cd6617 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -701,7 +701,7 @@ const TOOLS: ToolDef[] = [ includeExcluded: { type: 'boolean', description: - 'Also scan notes opted out of Tasks (frontmatter `tasks: false` / `tasks: note`) and folders on the vault\'s excluded list. Default false: excluded tasks are invisible.' + 'Also scan notes opted out of Tasks (frontmatter `tasks: false` / `tasks: note`), folders on the vault\'s excluded list, and notes outside its Task folders. Default false: excluded tasks are invisible.' } } } diff --git a/apps/desktop/src/mcp/vault-ops.test.ts b/apps/desktop/src/mcp/vault-ops.test.ts index a6e4f18f..f08efdb2 100644 --- a/apps/desktop/src/mcp/vault-ops.test.ts +++ b/apps/desktop/src/mcp/vault-ops.test.ts @@ -340,3 +340,40 @@ describe('primary notes location follows vault.json (#745)', () => { expect(await readPrimaryNotesLocation(root)).toBe('root') }) }) + +// #962: the Task folders narrow every Tasks surface, MCP's list_tasks and the +// Node zn included, to the notes inside them. +describe('mcp tasks honor the Task folders (#962)', () => { + async function seedTaskFolders(tasks: Record): Promise { + await mkdir(path.join(root, 'Tasks', 'Work'), { recursive: true }) + await mkdir(path.join(root, 'Tasks', 'Someday'), { recursive: true }) + await writeFile(path.join(root, 'Tasks', 'Today.md'), '# Today\n\n- [ ] ship it\n') + await writeFile(path.join(root, 'Tasks', 'Work', 'Q4.md'), '# Q4\n\n- [ ] plan\n') + await writeFile(path.join(root, 'Tasks', 'Someday', 'Maybe.md'), '# Maybe\n\n- [ ] later\n') + await mkdir(path.join(root, '.zennotes'), { recursive: true }) + await writeFile(path.join(root, '.zennotes', 'vault.json'), JSON.stringify({ tasks })) + } + const sources = (tasks: { sourcePath: string }[]): string[] => + [...new Set(tasks.map((t) => t.sourcePath))].sort() + + it('lists only the tasks inside the Task folders', async () => { + await seedTaskFolders({ includedFolders: ['Tasks'] }) + expect(sources(await scanAllTasks(root))).toEqual([ + 'Tasks/Someday/Maybe.md', + 'Tasks/Today.md', + 'Tasks/Work/Q4.md' + ]) + }) + + it('still leaves out an excluded folder inside a Task folder', async () => { + await seedTaskFolders({ includedFolders: ['Tasks'], excludedFolders: ['Tasks/Someday'] }) + expect(sources(await scanAllTasks(root))).toEqual(['Tasks/Today.md', 'Tasks/Work/Q4.md']) + }) + + it('scans every note again with includeExcluded', async () => { + await seedTaskFolders({ includedFolders: ['Tasks'], excludedFolders: ['Tasks/Someday'] }) + const all = sources(await scanAllTasks(root, { includeExcluded: true })) + expect(all).toContain('Tasks/Someday/Maybe.md') + expect(all.some((p) => p.includes('Rename'))).toBe(true) + }) +}) diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index e7e3092f..59a66085 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -24,7 +24,8 @@ export type { NoteComment, NoteCommentInput } import { inlineTaskField, noteTasksMode, type NoteTasksMode } from '@shared/tasks' import { isPathExcludedFromTasks, - normalizeTasksExcludedFolders + normalizeTasksExcludedFolders, + normalizeTasksIncludedFolders } from '@shared/tasks-excluded-folders' import { DEFAULT_TYPST_PREAMBLE_FOLDER, @@ -1398,8 +1399,9 @@ function parseNoteDefaults(body: string): { } interface ParseTasksOptions { - /** Scan past the note-level `tasks:` opt-out (#458): the `list_tasks` - * includeExcluded / `zn task list --include-excluded` escape hatch. */ + /** Scan past the note-level `tasks:` opt-out (#458) and the vault's folder + * lists (#962): the `list_tasks` includeExcluded / + * `zn task list --include-excluded` escape hatch. */ includeExcluded?: boolean } @@ -1664,10 +1666,7 @@ function todayIsoLocal(): string { return `${y}-${mo}-${day}` } -/** The vault's `tasks.excludedFolders` list (#458), read straight off - * vault.json like readSystemFolderPaths above; validation comes from the - * shared normalizer, so the rules cannot drift from the other runtimes. */ -/** The vault's Typst preamble folder (#562), read the same way. Preamble notes +/** The vault's Typst preamble folder (#562), read straight off vault.json. Preamble notes * are Typst source, so an agent asking for a note's tags must not be handed * `let` and a pile of variable names. */ async function readTypstPreambleFolder(root: string): Promise { @@ -1683,28 +1682,40 @@ async function readTypstPreambleFolder(root: string): Promise { return resolveTypstPreambleFolder((preambles as { folder?: unknown }).folder) } -async function readTasksExcludedFolders(root: string): Promise { +/** The vault's Tasks folder lists, `tasks.excludedFolders` (#458) and the + * Task folders in `tasks.includedFolders` (#962), read straight off + * vault.json like readSystemFolderPaths above; validation comes from the + * shared normalizers, so the rules cannot drift from the other runtimes. */ +async function readTasksFolderLists( + root: string +): Promise<{ excluded: string[]; included: string[] }> { + const none = { excluded: [], included: [] } const settingsPath = path.join(root, INTERNAL_VAULT_DIR, VAULT_SETTINGS_FILE) let raw: Record try { raw = JSON.parse(await fs.readFile(settingsPath, 'utf8')) as Record } catch { - return [] + return none } const tasks = raw['tasks'] - if (!tasks || typeof tasks !== 'object') return [] - return normalizeTasksExcludedFolders( - (tasks as { excludedFolders?: unknown }).excludedFolders - ) + if (!tasks || typeof tasks !== 'object') return none + const lists = tasks as { excludedFolders?: unknown; includedFolders?: unknown } + return { + excluded: normalizeTasksExcludedFolders(lists.excludedFolders), + included: normalizeTasksIncludedFolders(lists.includedFolders) + } } export async function scanAllTasks( root: string, opts?: ParseTasksOptions ): Promise { - const excluded = opts?.includeExcluded ? [] : await readTasksExcludedFolders(root) + // The escape hatch scans past both lists: "everything" means every note. + const { excluded, included } = opts?.includeExcluded + ? { excluded: [], included: [] } + : await readTasksFolderLists(root) const metas = (await listNotes(root)).filter( - (m) => m.folder !== 'trash' && !isPathExcludedFromTasks(m.path, excluded) + (m) => m.folder !== 'trash' && !isPathExcludedFromTasks(m.path, excluded, included) ) const out: VaultTask[] = [] await Promise.all( diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 5f98f61c..edc47f36 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -336,7 +336,7 @@ const api: ZenBridge = { ipcRenderer.on(IPC.CLOUD_VAULT_SYNC_WINDOW, listener) return () => { for (const requestId of active) { - ipcRenderer.send(IPC.CLOUD_VAULT_SYNC_WINDOW_ACK, requestId, 'The vault window changed during sync preparation.') + ipcRenderer.send(IPC.CLOUD_VAULT_SYNC_WINDOW_ACK, requestId, 'A ZenNotes window opened or closed while sync was starting. Choose Sync now to try again.') } active.clear() ipcRenderer.removeListener(IPC.CLOUD_VAULT_SYNC_WINDOW, listener) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index a106126a..ccdd7c80 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.8.0", - "commit": "58b0e5683bdc35049e7e592998a3f236093e7557", + "version": "0.9.0", + "commit": "a0d335c459c04fdccb8686d81d481e10634a3948", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_darwin_arm64.tar.gz", - "sha256": "29ebd83447c45a8fd5f8196f2c6680521bc1a2bbbec6afa609fadec480006042" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.9.0/zn_0.9.0_darwin_arm64.tar.gz", + "sha256": "1f97fcbf307ccfba03496311bdc04e9708c10e822ac2abdf03ffa7131197f92e" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_darwin_amd64.tar.gz", - "sha256": "7d6e91766664d72bb938e20644ab2501d8691aba4ae82168f7d18741850ef22e" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.9.0/zn_0.9.0_darwin_amd64.tar.gz", + "sha256": "2f95711520fda368cff318d744b87b3afa98cccefcb34b35d86cce967c4c0bb8" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_linux_arm64.tar.gz", - "sha256": "b73d9356b65a597364ba09bcb086be1b37b24c7c4cd2e806e990b00e8a997f5f" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.9.0/zn_0.9.0_linux_arm64.tar.gz", + "sha256": "b0a08f2575fec89e87f76fcb5dc9a7410c9b8e3327922e587605ccdd3588f2dc" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_linux_amd64.tar.gz", - "sha256": "5f5484434dda2c4745529f4c54c955c678219ce05880e6ff3c25a5af8845b2b1" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.9.0/zn_0.9.0_linux_amd64.tar.gz", + "sha256": "6ca45da7e8adcbed0037888961239fe4dbb6b960ec87844a8b6fb2439484f523" } } } diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 9408b8e0..32c08ce7 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.71.0", + "version": "2.72.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index a0954f5b..27829a49 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.71.0", + "version": "2.72.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 0670482a..dd1b0788 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.71.0", + "version": "2.72.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index 85e5b356..d07cdae8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.71.0", + "version": "2.72.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.71.0", + "version": "2.72.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.71.0", + "version": "2.72.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -915,7 +915,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.71.0", + "version": "2.72.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -989,7 +989,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.71.0", + "version": "2.72.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1061,7 +1061,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.71.0", + "version": "2.72.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16409,7 +16409,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.71.1", + "version": "2.72.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16498,14 +16498,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.71.1", + "version": "2.72.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.71.1", + "version": "2.72.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16519,7 +16519,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.71.0" + "version": "2.72.0" } } } diff --git a/package.json b/package.json index 7e84fa54..5355cfbd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.71.0", + "version": "2.72.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 21e23d94..c918d778 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.71.1", + "version": "2.72.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index cb32c963..54e8df69 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -51,7 +51,7 @@ import { import { requestPaneMode } from './lib/pane-mode' import { recordRendererPerf } from './lib/perf' import { focusEditorNormalMode } from './lib/editor-focus' -import { isAppOverlayOpen } from './lib/overlay-open' +import { isAppOverlayOpen, isOverlayOrDialogOpen } from './lib/overlay-open' import { installMarkdownFileDropHandler } from './lib/markdown-file-drop' import { setMarkdownLooseMathDelimiters, @@ -321,6 +321,10 @@ function AppUpdateNotice({ */ function handlePanelKeyWithoutVim(e: KeyboardEvent, focusedPanel: string | null): void { if (e.metaKey || e.ctrlKey || e.altKey) return + // A dialog over the panel owns its keys: the focused panel stays set while + // one is open, so arrows would move the row behind it and Enter would open + // that row instead of pressing the dialog's focused button (#960). + if (isOverlayOrDialogOpen()) return // Text entry inside a panel (the outline filter, a comment draft) keeps its keys. const target = e.target instanceof HTMLElement ? e.target : null if (target) { @@ -758,7 +762,11 @@ function App(): JSX.Element { // open. Per the house rule for global key handlers, a shortcut that // switches, reopens or creates the note must not act under it: it // strands the user on a different note than they left, and a new note - // pulls the keyboard into a title field nobody can see. + // pulls the keyboard into a title field nobody can see. The DOM check + // also covers every dialog the shared Modal shell draws without a + // data-* hook of its own (the sidebar icon and color pickers, Publish, + // the workflow pickers), where Shift+Mod+N used to make a quick note + // behind the picker. const overlayOpen = state.settingsOpen || state.searchOpen || @@ -768,10 +776,7 @@ function App(): JSX.Element { state.templatePaletteOpen || state.embedDrawingPaletteOpen || state.outlinePaletteOpen || - !!document.querySelector('[data-ctx-menu]') || - !!document.querySelector('[data-prompt-modal]') || - !!document.querySelector('[data-confirm-modal]') || - !!document.querySelector('[data-cloud-conflict-dialog]') + isOverlayOrDialogOpen() if (isUndoShortcut(e)) { const assetUndoEntry = state.assetUndoStack.at(-1) @@ -1036,13 +1041,11 @@ function App(): JSX.Element { // Don't move focus (or swallow the key) while a modal, palette, or the // Settings keybinding recorder is open — the recorder also captures keys // in this phase, so a focusPane shortcut bound to e.g. Ctrl+H must not - // intercept it. (#124) + // intercept it. (#124) Any dialog the shared Modal shell draws counts + // too: under the sidebar color picker Alt+L moved focus into the editor + // behind it, and Mod+N made a note there. const overrides = state.keymapOverrides - const modalOrMenuOpen = - !!document.querySelector('[data-ctx-menu]') || - !!document.querySelector('[data-prompt-modal]') || - !!document.querySelector('[data-confirm-modal]') || - !!document.querySelector('[data-cloud-conflict-dialog]') + const modalOrMenuOpen = isOverlayOrDialogOpen() // `global.searchNotes` is deliberately not a toggle. `Mod+P` resolves to // `Ctrl+P` on Linux and Windows, which is also the Emacs previous-item // chord every palette handles via `isPalettePreviousKey()`. A toggle here diff --git a/packages/app-core/src/components/CloudEncryptionPanel.tsx b/packages/app-core/src/components/CloudEncryptionPanel.tsx index 8a25bdfb..9ad9e559 100644 --- a/packages/app-core/src/components/CloudEncryptionPanel.tsx +++ b/packages/app-core/src/components/CloudEncryptionPanel.tsx @@ -7,7 +7,8 @@ import type { import type { ZenBridge } from "@zennotes/bridge-contract/bridge"; import { cloudPassphraseProblem } from "@zennotes/shared-domain/cloud-e2ee-passphrase"; import { confirmApp } from "../lib/confirm-requests"; -import { humanIpcError } from "../lib/ipc-error"; +import type { CloudErrorCopy } from "@zennotes/shared-domain/cloud-error-copy"; +import { CloudErrorMessage, cloudErrorCopy } from "./CloudErrorMessage"; import { isImeComposing } from "../lib/ime"; import { Button } from "./ui/Button"; import { Modal } from "./ui/Modal"; @@ -32,7 +33,7 @@ export function CloudEncryptionPanel({ }): JSX.Element | null { const [status, setStatus] = useState(null); const [dialog, setDialog] = useState(null); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const supported = typeof bridge.getCloudEncryptionStatus === "function"; const refresh = useCallback(async () => { @@ -43,7 +44,7 @@ export function CloudEncryptionPanel({ onStatusChange?.(next); setError(null); } catch (cause) { - setError(humanIpcError(cause, "ZenNotes could not check end-to-end encryption.")); + setError(cloudErrorCopy(cause, "ZenNotes couldn't check end-to-end encryption. Try again in a moment.", bridge)); } }, [bridge, onStatusChange]); @@ -66,7 +67,7 @@ export function CloudEncryptionPanel({ await bridge.lockCloudEncryption?.(); await refresh(); } catch (cause) { - setError(humanIpcError(cause, "ZenNotes could not lock encryption on this device.")); + setError(cloudErrorCopy(cause, "ZenNotes couldn't lock encryption on this device. Try again.", bridge)); } }; @@ -100,9 +101,9 @@ export function CloudEncryptionPanel({

{error && ( -

- {error} -

+
+ +
)}
{!status.keyset && ( @@ -164,7 +165,7 @@ function EncryptionSetupDialog({ busyLabel="Preparing…" onCancel={onCancel} onSubmit={async (passphrase) => { - if (!bridge.prepareCloudEncryptionSetup) throw new Error("This app cannot set up encryption."); + if (!bridge.prepareCloudEncryptionSetup) throw new Error("This version of ZenNotes can't use end-to-end encryption. Update ZenNotes to the latest version."); setPrompt(await bridge.prepareCloudEncryptionSetup(passphrase)); setStep("words"); }} @@ -287,7 +288,7 @@ function EncryptionNewRecoveryKeyDialog({ busyLabel="Preparing…" onCancel={onCancel} onSubmit={async (secret) => { - if (!bridge.prepareNewCloudRecoveryKey) throw new Error("This app cannot replace the recovery key."); + if (!bridge.prepareNewCloudRecoveryKey) throw new Error("This version of ZenNotes can't replace the recovery key. Update ZenNotes to the latest version."); setPrompt(await bridge.prepareNewCloudRecoveryKey(secret)); setStep("words"); }} @@ -352,19 +353,19 @@ function EncryptionDialog({ /** Runs one async step, showing its error in place and keeping the dialog open. */ function useStep(): { busy: boolean; - error: string | null; + error: CloudErrorCopy | null; run: (task: () => Promise) => Promise; - setError: (message: string | null) => void; + setError: (error: CloudErrorCopy | null) => void; } { const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const run = useCallback(async (task: () => Promise) => { setBusy(true); setError(null); try { await task(); } catch (cause) { - setError(humanIpcError(cause, "That did not work. Try again.")); + setError(cloudErrorCopy(cause, "That didn't work. Try again.")); } finally { setBusy(false); } @@ -451,9 +452,9 @@ function NewPassphraseStep({ recovered by anyone, including ZenNotes. The notes on your devices are not affected.

{error && ( -

- {error} -

+
+ +
)}
@@ -559,9 +560,9 @@ function SecretStep({ {mode === "passphrase" ? "Use recovery key instead" : "Use passphrase instead"} {error && ( -

- {error} -

+
+ +
)} @@ -706,9 +707,9 @@ function RecoveryConfirmStep({ ))} {error && ( -

- {error} -

+
+ +
)} diff --git a/packages/app-core/src/components/CloudErrorMessage.tsx b/packages/app-core/src/components/CloudErrorMessage.tsx new file mode 100644 index 00000000..dc5da8d8 --- /dev/null +++ b/packages/app-core/src/components/CloudErrorMessage.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react"; +import { + describeCloudError, + type CloudErrorCopy, +} from "@zennotes/shared-domain/cloud-error-copy"; +import { writeClipboardText } from "../lib/clipboard-text"; +import { signOutAfterRefusal } from "../lib/cloud-auto-sync"; +import { Button } from "./ui/Button"; + +const APP_ISSUES_URL = "https://github.com/ZenNotes/zennotes/issues"; + +/** + * A failed Cloud action in words a person can act on. The desktop main + * process words its errors before IPC and appends the technical line; the + * phones' in-process hosts hand over the error itself. Either way the result + * is the sentence to show and, apart from it, the line support needs. + */ +export function cloudErrorCopy( + error: unknown, + fallback: string, + /** Given, a refused sign-in also signs this device out, as sync does. */ + bridge?: Parameters[0], +): CloudErrorCopy { + const copy = describeCloudError(error, fallback); + if (bridge && copy.kind === "signed-out") signOutAfterRefusal(bridge, copy); + return copy; +} + +/** + * The sentence, and under it a closed Details disclosure with the technical + * line, Copy details, and Report an Issue (the same words as the Help menu + * and the command palette). The surrounding alert box belongs to the caller. + */ +export function CloudErrorMessage({ + message, + detail, +}: { + message: string; + detail: string | null; +}): JSX.Element { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), 1500); + return () => window.clearTimeout(timer); + }, [copied]); + + return ( + <> + {message} + {detail && ( +
+ + Details + +
+            {detail}
+          
+
+ + +
+
+ )} + + ); +} diff --git a/packages/app-core/src/components/CloudPendingConflictResolver.test.ts b/packages/app-core/src/components/CloudPendingConflictResolver.test.ts index a78c14b2..087e35fb 100644 --- a/packages/app-core/src/components/CloudPendingConflictResolver.test.ts +++ b/packages/app-core/src/components/CloudPendingConflictResolver.test.ts @@ -119,7 +119,9 @@ describe("CloudPendingConflictResolver", () => { expect(bridge.getCloudConflict).toHaveBeenCalledTimes(1); expect(useCloudSyncStatusStore.getState()).toMatchObject({ phase: "error", lastSyncedAt: 123, lastSummary: synced, - error: "Note saved. Remaining vault sync failed: Request timed out", + error: expect.stringMatching( + /^Your note was saved, but the rest of the vault didn't sync\. ZenNotes Cloud took too long to answer/, + ), }); view.unmount(); }); diff --git a/packages/app-core/src/components/CloudPendingConflictResolver.tsx b/packages/app-core/src/components/CloudPendingConflictResolver.tsx index 7625aa79..48330482 100644 --- a/packages/app-core/src/components/CloudPendingConflictResolver.tsx +++ b/packages/app-core/src/components/CloudPendingConflictResolver.tsx @@ -14,6 +14,8 @@ import { useCloudSyncStatusStore, } from "../lib/cloud-auto-sync"; import { Button } from "./ui/Button"; +import type { CloudErrorCopy } from "@zennotes/shared-domain/cloud-error-copy"; +import { CloudErrorMessage, cloudErrorCopy } from "./CloudErrorMessage"; import { randomUUID } from "@zennotes/shared-domain/random-uuid"; type ChangeChoice = "local" | "cloud" | "both"; @@ -44,7 +46,7 @@ export function CloudPendingConflictResolver({ const [resolving, setBusy] = useState(false); const syncing = useCloudSyncStatusStore((state) => state.phase === "syncing" || state.syncWindowLocked); const busy = resolving || syncing; - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [keepBothOpen, setKeepBothOpen] = useState(false); const [finishLaterOpen, setFinishLaterOpen] = useState(false); const [combineOpen, setCombineOpen] = useState(false); @@ -325,7 +327,7 @@ export function CloudPendingConflictResolver({ role="alert" className="mt-4 rounded-lg border border-danger/35 bg-danger/10 px-3 py-2 text-sm leading-5 text-danger" > - {error} + )} @@ -988,6 +990,8 @@ function safeId(value: string): string { return value.replace(/[^a-zA-Z0-9_-]/g, "-"); } -function message(error: unknown): string { - return error instanceof Error ? error.message : String(error); +/** Without this the resolver showed "Error invoking remote method + * 'cloud-vault-conflict:resolve': Error: ..." whole. */ +function message(error: unknown): CloudErrorCopy { + return cloudErrorCopy(error, "That didn't work. Try again."); } diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index 33248d5a..0b4218e2 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -17,6 +17,11 @@ import { getPublishNoteRequest, dismissPublishNoteRequest } from "../lib/publish import { CloudSettings } from "./CloudSettings"; import { subscribePublishedNoteChanges } from "../lib/published-note-events"; import { clearCloudSyncStatus, useCloudSyncStatusStore } from "../lib/cloud-auto-sync"; +import { + CLOUD_SIGNED_OUT_MESSAGE, + describeCloudError, + encodeCloudErrorMessage, +} from "@zennotes/shared-domain/cloud-error-copy"; const mocks = vi.hoisted(() => ({ getCloudAccountStatus: vi.fn(), @@ -924,9 +929,9 @@ describe("CloudSettings", () => { ); await act(async () => sync!.click()); - expect(host.textContent).toContain("Cloud item limit reached"); + expect(host.textContent).toContain("Cloud file limit reached"); expect(host.textContent).toContain( - "Cloud active-item limit reached (100 of 100)", + "Your ZenNotes Cloud plan holds up to 100 files across your account", ); expect(host.textContent).toContain("107 changes are waiting to upload"); expect(host.textContent).not.toContain("Everything is up to date"); @@ -1802,7 +1807,7 @@ describe("CloudSettings", () => { ); expect(host.textContent).toContain( - "Sync is not included in this subscription", + "Sync isn't part of your current plan. Choose a plan under Billing on zennotes.org", ); expect(mocks.listCloudVaults).not.toHaveBeenCalled(); expect(mocks.getCloudVaultLink).not.toHaveBeenCalled(); @@ -1921,14 +1926,65 @@ describe("CloudSettings", () => { ), ); - expect(host.textContent).toContain("fetch failed"); + // The runtime's words stay in Details, never as the message. + const message = host.querySelector("[data-cloud-error-message]"); + expect(message?.textContent).toMatch(/^ZenNotes can't reach ZenNotes Cloud\./); + expect(message?.textContent).not.toContain("fetch failed"); + expect(host.querySelector("[data-cloud-error-details] pre")?.textContent).toBe( + "TypeError · fetch failed", + ); await act(async () => window.dispatchEvent(new Event("online"))); - expect(host.textContent).not.toContain("fetch failed"); + expect(host.textContent).not.toContain("can't reach ZenNotes Cloud"); expect(host.textContent).toContain("SyncIncluded"); }); + it("signs out when Cloud refuses this device's sign-in, and says why above Connect", async () => { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.getCloudVaultLink.mockResolvedValue(null); + mocks.logoutCloudAccount.mockResolvedValue(disconnected); + // What the desktop main process sends for a revoked device. + const worded = encodeCloudErrorMessage( + describeCloudError( + Object.assign(new Error("Authentication is required."), { + name: "CloudServiceRequestError", + status: 401, + code: "UNAUTHENTICATED", + }), + ), + ); + mocks.listCloudVaults.mockRejectedValue( + new Error(`Error invoking remote method 'cloud-vaults:list': Error: ${worded}`), + ); + + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + expect(mocks.logoutCloudAccount).toHaveBeenCalledTimes(1); + expect(host.querySelector('[role="alert"]')).toBeNull(); + + const [accountChanged] = mocks.onCloudAccountChange.mock.calls[0] as unknown as [ + (status: CloudAccountStatus) => void, + ]; + await act(async () => accountChanged(disconnected)); + + const notice = host.querySelector("[data-cloud-signed-out]"); + expect(notice?.querySelector("[data-cloud-error-message]")?.textContent).toBe( + CLOUD_SIGNED_OUT_MESSAGE, + ); + expect(notice?.querySelector("[data-cloud-error-details] pre")?.textContent).toBe( + "HTTP 401 · UNAUTHENTICATED · Authentication is required.", + ); + expect(host.textContent).toContain("Connect ZenNotes Cloud"); + }); + it("loads again after a sign-in cancels the first load instead of reporting the cancellation", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount @@ -2965,7 +3021,9 @@ describe("CloudSettings", () => { await act(async () => buttonNamed("Load more")!.click()); const inline = (): string | null | undefined => host.querySelector('p[role="alert"]')?.textContent; - expect(inline()).toBe("Could not load more notes."); + expect(inline()).toBe( + "ZenNotes couldn't load more notes from this backup. Try again in a moment.", + ); expect(shownNotes()).toBe(50); await act(async () => typeInto(searchBox()!, "launch")); diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index 7f9e3288..6340c019 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -20,8 +20,13 @@ import type { } from "@zennotes/bridge-contract/cloud-sync"; import { getZenBridge, type ZenBridge } from "@zennotes/bridge-contract/bridge"; import { cloudBackupLimitMessage } from "@zennotes/shared-domain/cloud-backup"; +import { + CLOUD_VAULT_NAME_REQUIRED_MESSAGE, + type CloudErrorCopy, +} from "@zennotes/shared-domain/cloud-error-copy"; import { confirmApp } from "../lib/confirm-requests"; import { CloudEncryptionPanel } from "./CloudEncryptionPanel"; +import { CloudErrorMessage, cloudErrorCopy } from "./CloudErrorMessage"; import { clearRemovedCloudVault, cloudSyncAttentionItems, @@ -38,6 +43,7 @@ import { useCloudSyncStatusStore, requestCloudAutoSync, syncCloudVaultWithStatus, + CLOUD_SYNC_FAILED_MESSAGE, type CloudVaultRemoval, } from "../lib/cloud-auto-sync"; import { @@ -152,11 +158,27 @@ export function CloudSettings({ const [loadingPublishedNotes, setLoadingPublishedNotes] = useState(false); const [loadingDetails, setLoadingDetails] = useState(false); const [action, setAction] = useState(null); - const [error, setError] = useState(null); - const [vaultError, setVaultError] = useState(null); + const [error, setError] = useState(null); + const signedOutReason = useCloudSyncStatusStore((s) => + s.errorKind === "signed-out" ? s.error : null, + ); + const signedOutDetail = useCloudSyncStatusStore((s) => + s.errorKind === "signed-out" ? s.errorDetail : null, + ); + const [vaultError, setVaultError] = useState(null); const [deleteRequest, setDeleteRequest] = useState( null, ); + // A refused sign-in signs this device out, and the page's Connect card + // says why (from the status store); anything else is said where it failed. + const showFailure = ( + cause: unknown, + fallback: string, + show: (copy: CloudErrorCopy) => void = setError, + ): void => { + const copy = cloudErrorCopy(cause, fallback, bridge); + if (copy.kind !== "signed-out") show(copy); + }; // The list is the truth about what can be opened: a choice that is no // longer in it falls back to the first vault that is. const effectiveSelectedVaultId = cloudVaults.some( @@ -310,7 +332,7 @@ export function CloudSettings({ if (superseded()) return; continue; } - setError(errorMessage(cause, "Could not load ZenNotes Cloud.")); + showFailure(cause, LOAD_ACCOUNT_FAILED); return; } finally { if (!superseded()) setLoadingDetails(false); @@ -323,7 +345,7 @@ export function CloudSettings({ useEffect(() => { const refresh = (): void => { void loadStatus().catch((cause) => { - setError(errorMessage(cause, "Could not load ZenNotes Cloud.")); + showFailure(cause, LOAD_ACCOUNT_FAILED); }); }; @@ -424,7 +446,7 @@ export function CloudSettings({ await refreshServiceAccount(); } catch (cause) { if (isCancelledRequest(cause)) return; - setError(errorMessage(cause, "Could not load published notes.")); + showFailure(cause, "ZenNotes couldn't load your published notes. Try again in a moment."); } finally { setLoadingPublishedNotes(false); } @@ -453,7 +475,7 @@ export function CloudSettings({ await refreshServiceAccount(); } catch (cause) { if (isCancelledRequest(cause)) return; - setError(errorMessage(cause, "Could not load cloud backups.")); + showFailure(cause, "ZenNotes couldn't load your backups. Try again in a moment."); } finally { setLoadingBackups(false); } @@ -494,12 +516,11 @@ export function CloudSettings({ // Sync errors already live in the shared status store. Duplicating one // here leaves it visible after a successful editor/background retry. if (nextAction !== "sync") { - const message = errorMessage( + showFailure( cause, - "ZenNotes Cloud could not complete that action.", + "That didn't work. Try again in a moment.", + VAULT_SECTION_ACTIONS.has(nextAction) ? setVaultError : setError, ); - if (VAULT_SECTION_ACTIONS.has(nextAction)) setVaultError(message); - else setError(message); } } finally { setAction(null); @@ -543,7 +564,7 @@ export function CloudSettings({ const createAndLinkVault = (): Promise => runAction("link", async () => { const name = newVaultName.trim(); - if (!name) throw new Error("Enter a name for the cloud vault."); + if (!name) throw new Error(CLOUD_VAULT_NAME_REQUIRED_MESSAGE); const createdLink = encryptNewVault && encryptionReady ? await bridge.createAndLinkCloudVault(name, { encrypted: true }) @@ -820,7 +841,17 @@ export function CloudSettings({ role="alert" className="rounded-xl border border-danger/35 bg-danger/10 px-4 py-3 text-sm leading-6 text-danger" > - {error} + + + )} + + {status.state === "disconnected" && signedOutReason && ( +
+
)} @@ -996,7 +1027,9 @@ function CloudPublishedNotesPanel({ if (!publishIncluded) { return ( - Publishing is not included in this subscription. + Publishing isn't part of your current plan. Choose a plan under + Billing on zennotes.org to publish notes. Your notes stay on this + device either way. ); } @@ -1360,7 +1393,7 @@ function CloudVaultPanel({ settingsConflict: CloudSyncSettingsConflict | null; summary: CloudSyncRunSummary | null; syncIncluded: boolean; - vaultError: string | null; + vaultError: CloudErrorCopy | null; onCreateAndLink: () => void; onLink: () => void; onNewVaultNameChange: (value: string) => void; @@ -1378,6 +1411,7 @@ function CloudVaultPanel({ const lastSummary = useCloudSyncStatusStore((s) => s.lastSummary); const syncPhase = useCloudSyncStatusStore((s) => s.phase); const syncError = useCloudSyncStatusStore((s) => s.error); + const syncErrorDetail = useCloudSyncStatusStore((s) => s.errorDetail); const removedVault = useCloudSyncStatusStore((s) => s.removedVault); const syncing = syncPhase === "syncing" || action === "sync"; const syncFailed = syncPhase === "error"; @@ -1388,7 +1422,11 @@ function CloudVaultPanel({ ); if (!syncIncluded) { return ( - Sync is not included in this subscription. + + Sync isn't part of your current plan. Choose a plan under Billing + on zennotes.org to sync this vault. Your notes stay on this device + either way. + ); } if (!localVaultAvailable) { @@ -1419,7 +1457,7 @@ function CloudVaultPanel({

- {vaultError && } + {vaultError && } {!link && removedVault && ( - {syncError ?? "Sync failed. Please try again."} + )} {displayedSummary && @@ -1826,7 +1867,7 @@ function useBackupNotes(bridge: ZenBridge): BackupNotes { } catch (cause) { if (request !== latestRequest.current) return; requestedSearch.current = null; - setError(errorMessage(cause, "Could not search this backup.")); + setError(cloudErrorCopy(cause, "ZenNotes couldn't search this backup. Try again in a moment.").message); } }, [bridge], @@ -1876,7 +1917,7 @@ function useBackupNotes(bridge: ZenBridge): BackupNotes { }); } catch (cause) { if (request !== latestRequest.current) return; - setError(errorMessage(cause, "Could not load more notes.")); + setError(cloudErrorCopy(cause, "ZenNotes couldn't load more notes from this backup. Try again in a moment.").message); } finally { if (request === latestRequest.current) setLoadingMore(false); } @@ -1966,7 +2007,11 @@ function CloudBackupPanel({ if (!backupIncluded) { return ( - Backups are not included in this subscription. + + Backups aren't part of your current plan. Choose a plan under + Billing on zennotes.org to back up this vault. Your notes stay on this + device either way. + ); } if (!link) { @@ -2434,20 +2479,53 @@ function CloudRestoreResult({ ); } - const message = + const failure = result.restore.status === "conflict" - ? "The cloud vault changed before restore began, so nothing was replaced. Refresh and try again." - : result.restore.error?.message || "The backup could not be restored."; + ? { + message: + "The cloud vault changed before restore began, so nothing was replaced. Refresh and try again.", + detail: null, + } + : restoreFailureCopy(result.restore.error); return (
- {message} +
); } +const RESTORE_FAILED_MESSAGE = + "ZenNotes couldn't restore this backup. Try again in a few minutes. If it keeps happening, copy the details and choose Report an Issue."; + +/** + * A failed restore job, from what the service recorded. RESTORE_FAILED can + * carry the job's raw exception text, so it and anything unrecognised are + * worded here and the service's words go to Details; the rest are worded + * like the same codes anywhere else. + */ +function restoreFailureCopy( + error: CloudBackupRestoreResult["restore"]["error"], +): { message: string; detail: string | null } { + if (!error) return { message: RESTORE_FAILED_MESSAGE, detail: null }; + if (!error.code || error.code === "RESTORE_FAILED") { + return { + message: RESTORE_FAILED_MESSAGE, + detail: [error.code, error.message].filter(Boolean).join(" · ") || null, + }; + } + return cloudErrorCopy( + Object.assign(new Error(error.message ?? ""), { + name: "CloudServiceRequestError", + status: 409, + code: error.code, + }), + RESTORE_FAILED_MESSAGE, + ); +} + function restoredItemsSummary( restore: CloudBackupRestoreResult["restore"], ): string { @@ -2739,7 +2817,10 @@ function CloudSyncSummary({ {item.path}
- {item.detail} +
@@ -2915,11 +2996,11 @@ function CloudVaultRemovedNotice({ /** A failed action from This vault, said in This vault and brought into view: * the button that failed can sit a screen below the section's top. */ -function CloudSectionError({ message }: { message: string }): JSX.Element { +function CloudSectionError({ error }: { error: CloudErrorCopy }): JSX.Element { const ref = useRef(null); useEffect(() => { ref.current?.scrollIntoView?.({ block: "nearest" }); - }, [message]); + }, [error]); return (
- {message} +
); } @@ -2975,15 +3056,4 @@ function isCancelledRequest(error: unknown): boolean { ); } -function errorMessage(error: unknown, fallback: string): string { - if (!(error instanceof Error) || !error.message.trim()) return fallback; - - // Electron hands the renderer ": " for a main-process - // rejection; the class name ("CloudServiceRequestError") is not for people. - const message = error.message - .replace(/^Error invoking remote method '[^']+':\s*/i, "") - .replace(/^(?:[A-Z][A-Za-z0-9]*)?Error:\s*/, "") - .trim(); - - return message || fallback; -} +const LOAD_ACCOUNT_FAILED = "ZenNotes couldn't load your Cloud account. Try again in a moment."; diff --git a/packages/app-core/src/components/CloudSettingsConflictDialog.tsx b/packages/app-core/src/components/CloudSettingsConflictDialog.tsx index 03dad4b8..7dd294cc 100644 --- a/packages/app-core/src/components/CloudSettingsConflictDialog.tsx +++ b/packages/app-core/src/components/CloudSettingsConflictDialog.tsx @@ -10,7 +10,8 @@ import { type VaultSettingsSectionDifference, type VaultSettingsSide, } from "@zennotes/shared-domain/vault-settings-conflict"; -import { humanIpcError } from "../lib/ipc-error"; +import type { CloudErrorCopy } from "@zennotes/shared-domain/cloud-error-copy"; +import { CloudErrorMessage, cloudErrorCopy } from "./CloudErrorMessage"; import { describeVaultSettingsConflict, formatVaultSettingsValue, @@ -67,7 +68,7 @@ export function CloudSettingsConflictDialog({ const differences = described?.differences ?? []; const [choices, setChoices] = useState({}); const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const primary = useRef(null); const sideOf = (section: VaultSettingsSection): VaultSettingsSide => @@ -86,7 +87,7 @@ export function CloudSettingsConflictDialog({ try { await work(); } catch (cause) { - setError(humanIpcError(cause, "The settings could not be applied. Nothing was changed.")); + setError(cloudErrorCopy(cause, "The settings could not be applied. Nothing was changed.")); setBusy(false); } }; @@ -189,7 +190,7 @@ export function CloudSettingsConflictDialog({ )} {error && (
- {error} +
)} diff --git a/packages/app-core/src/components/ExternalFileApp.tsx b/packages/app-core/src/components/ExternalFileApp.tsx index 1163bc8d..e1278f24 100644 --- a/packages/app-core/src/components/ExternalFileApp.tsx +++ b/packages/app-core/src/components/ExternalFileApp.tsx @@ -24,6 +24,7 @@ import { vimHalfPageKeymap } from '../lib/vim-half-page-keymap' import { applyVimInsertEscape } from '../lib/vim-insert-escape' import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' +import { isTouchPrimaryDevice, vimImeGuard } from '../lib/cm-vim-ime-guard' import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language' import type { ExternalFileContent } from '@shared/ipc' import { livePreviewPlugin } from '../lib/cm-live-preview' @@ -165,6 +166,10 @@ export function ExternalFileApp(): JSX.Element { extensions: [ appMarkdownSnippetExtension(), new Compartment().of(prefs.vimMode ? vim() : []), + // No text input outside Vim insert mode, as in every other editor: + // without it a CJK input method composed over hjkl and typed `ia` + // into a file opened from outside the vault (#921). + vimImeGuard(() => prefs.vimBlockImeInNormalMode && !isTouchPrimaryDevice()), vimVisualHighlightExtension, history(), floatingCursorDrawSelection(), diff --git a/packages/app-core/src/components/FolderColorPickerModal.tsx b/packages/app-core/src/components/FolderColorPickerModal.tsx index d0502cf2..83c2e1f0 100644 --- a/packages/app-core/src/components/FolderColorPickerModal.tsx +++ b/packages/app-core/src/components/FolderColorPickerModal.tsx @@ -1,4 +1,7 @@ +import { useRef } from 'react' import type { FolderColorId } from '@shared/ipc' +import { useStore } from '../store' +import { handlePickerGridKey } from '../lib/picker-grid' import { FOLDER_COLOR_OPTIONS } from './FolderColors' import { Modal } from './ui/Modal' import { Button } from './ui/Button' @@ -16,8 +19,11 @@ export function FolderColorPickerModal({ onReset: () => void onCancel: () => void }): JSX.Element { + const vimMode = useStore((s) => s.vimMode) + // Opens on the color already set, so Enter right away keeps it. + const currentRef = useRef(null) return ( - + } /> - - {FOLDER_COLOR_OPTIONS.map((option) => { - const active = option.id === currentColorId - return ( - - ) - })} + +
handlePickerGridKey(e, vimMode)} + > + {FOLDER_COLOR_OPTIONS.map((option) => { + const active = option.id === currentColorId + return ( + + ) + })} +
- ) - })} + +
handlePickerGridKey(e, vimMode)} + > + {FOLDER_ICON_OPTIONS.map((option) => { + const active = option.id === currentIconId + return ( + + ) + })} +
diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 9e5a963e..fdb380c0 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -496,6 +496,7 @@ export function Sidebar(): JSX.Element { const newDatabase = useStore((s) => s.newDatabase); const toggleFavorite = useStore((s) => s.toggleFavorite); const toggleTasksExcludedFolder = useStore((s) => s.toggleTasksExcludedFolder); + const toggleTasksIncludedFolder = useStore((s) => s.toggleTasksIncludedFolder); const createDatabase = useStore((s) => s.createDatabase); const createNoteInChosenFolder = useStore((s) => s.createNoteInChosenFolder); const openTemplatePaletteForFolder = useStore((s) => s.openTemplatePaletteForFolder); @@ -1998,19 +1999,28 @@ export function Sidebar(): JSX.Element { }, }); - // Vault-level Tasks exclusion (#458). The list stores on-disk relative - // paths, so a root-primary inbox's top level resolves to "": no valid - // entry to toggle, hide the item there. + // Vault-level Task folders (#962) and Tasks exclusion (#458). Both lists + // store on-disk relative paths, so a root-primary inbox's top level + // resolves to "": no valid entry to toggle, hide the items there. const tasksExcludeRelDir = vaultRelativeFolderPath( folder, subpath, vaultSettings, ); if (tasksExcludeRelDir) { + const taskFolder = ( + vaultSettings.tasks?.includedFolders ?? [] + ).includes(tasksExcludeRelDir); const tasksExcluded = ( vaultSettings.tasks?.excludedFolders ?? [] ).includes(tasksExcludeRelDir); items.push({ kind: "separator" }); + items.push({ + label: taskFolder ? "Stop Using as Task Folder" : "Use as Task Folder", + onSelect: async () => { + await toggleTasksIncludedFolder(tasksExcludeRelDir); + }, + }); items.push({ label: tasksExcluded ? "Include in Tasks" : "Exclude from Tasks", onSelect: async () => { @@ -2104,6 +2114,7 @@ export function Sidebar(): JSX.Element { openColorPicker, toggleFavorite, toggleTasksExcludedFolder, + toggleTasksIncludedFolder, bulkSelectionMenuItems, selectedSidebarKeys, ]); diff --git a/packages/app-core/src/components/StatusBar.tsx b/packages/app-core/src/components/StatusBar.tsx index d2089528..f39e4770 100644 --- a/packages/app-core/src/components/StatusBar.tsx +++ b/packages/app-core/src/components/StatusBar.tsx @@ -115,6 +115,7 @@ function CloudSyncStatus({ const vaultName = useCloudSyncStatusStore((state) => state.vaultName); const lastSyncedAt = useCloudSyncStatusStore((state) => state.lastSyncedAt); const error = useCloudSyncStatusStore((state) => state.error); + const errorKind = useCloudSyncStatusStore((state) => state.errorKind); const lastSummary = useCloudSyncStatusStore((state) => state.lastSummary); const settingsOnly = useCloudSyncStatusStore(cloudSyncAttentionIsSettingsOnly); const settingsQuestionWaiting = useCloudSyncStatusStore( @@ -140,13 +141,18 @@ function CloudSyncStatus({ // person, and in the plain unlinked phase a phone said nothing at all // while its sync had stopped. const removal = phase === "unlinked" ? removedVault : null; - const shownPhase = removal ? "attention" : phase; + // Signed out because Cloud refused this device: the same, it stopped + // syncing without the person choosing to. + const signedOut = phase === "disconnected" && errorKind === "signed-out"; + const shownPhase = removal || signedOut ? "attention" : phase; const label = removal ? cloudVaultRemovalLabel(removal) : phase === "disconnected" ? error - ? "Cloud unavailable" + ? errorKind === "signed-out" + ? "Signed out of Cloud" + : "Sign-in didn't start" : "ZenNotes Cloud" : phase === "connecting" ? "Connecting to Cloud…" @@ -161,7 +167,9 @@ function CloudSyncStatus({ ? "Settings need review" : (cloudSyncAttentionLabel(lastSummary) ?? "Sync incomplete") : phase === "error" - ? "Sync failed" + ? errorKind === "offline" + ? "Can't reach Cloud" + : "Sync failed" : lastSyncedAt === null ? "Cloud ready" : `Synced ${formatRelativeSyncTime(lastSyncedAt, now)}`; @@ -174,9 +182,9 @@ function CloudSyncStatus({ : phase === "unlinked" ? "Choose the cloud vault this device should use." : phase === "attention" - ? (error ?? "Some Cloud changes could not be synchronized.") + ? (error ?? "Some changes didn't sync. Open Settings → Cloud to see which files.") : phase === "error" - ? `${error ?? "Sync could not finish."} Click to retry.` + ? `${error ?? "Sync didn't finish."} Click to retry.` : phase === "syncing" ? `Syncing ${vaultName ?? "this vault"}` : `${vaultName ?? "Cloud vault"} is connected. Click to sync now.`; @@ -195,7 +203,7 @@ function CloudSyncStatus({ // cannot finish the waiting file anyway. const actionLabel = phase === "disconnected" - ? error + ? error && errorKind !== "signed-out" ? "Retry" : "Connect" : phase === "unlinked" diff --git a/packages/app-core/src/components/TemplateEditorModal.tsx b/packages/app-core/src/components/TemplateEditorModal.tsx index 3aa94d8a..abcdf21d 100644 --- a/packages/app-core/src/components/TemplateEditorModal.tsx +++ b/packages/app-core/src/components/TemplateEditorModal.tsx @@ -12,6 +12,7 @@ import { vim } from '@replit/codemirror-vim' import { history, historyKeymap, indentWithTab } from '@codemirror/commands' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from '../lib/cm-vim-default-keymap' import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' +import { isTouchPrimaryDevice, vimImeGuard } from '../lib/cm-vim-ime-guard' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { yamlFrontmatter } from '@codemirror/lang-yaml' import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language' @@ -123,6 +124,10 @@ export function TemplateEditorModal({ extensions: [ appMarkdownSnippetExtension(), new Compartment().of(vimModeRef.current ? vim() : []), + // The same input-method guard as the main editor (#84, #464, #921). + vimImeGuard( + () => useStore.getState().vimBlockImeInNormalMode && !isTouchPrimaryDevice() + ), vimVisualHighlightExtension, history(), cursorDrawSelection(useStore.getState().cursorBlink), diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 86aafd9b..0012b8c7 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -454,7 +454,12 @@ export function VimNav(): JSX.Element | null { // the INPUT/TEXTAREA escape below does not cover it: without this // marker, Space armed the leader instead of pressing the focused // button and leader chords fired underneath the dialog. - document.querySelector('[data-workflow-import]') + document.querySelector('[data-workflow-import]') || + // Every other dialog the shared Modal shell draws. Routing below + // follows the store's focused panel, not DOM focus, so with the + // sidebar focused, j/k moved its cursor behind the icon and color + // pickers and l opened the row, pulling focus out of the dialog (#960). + document.querySelector('[aria-modal="true"]') ) return // Hint mode — handled entirely by HintOverlay's own listener diff --git a/packages/app-core/src/lib/cloud-auto-sync.test.ts b/packages/app-core/src/lib/cloud-auto-sync.test.ts index 90cc8848..ef2fa569 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.test.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.test.ts @@ -9,6 +9,7 @@ import { CLOUD_VAULT_REMOVED_MESSAGE, CLOUD_VAULT_SUPERSEDED_MESSAGE, } from "@zennotes/shared-domain/cloud-vault-availability"; +import { CLOUD_SIGNED_OUT_MESSAGE } from "@zennotes/shared-domain/cloud-error-copy"; import { acknowledgeCloudConflictResolution, clearCloudSyncStatus, @@ -212,7 +213,7 @@ describe("cloud auto sync host wiring", () => { capacity: { dimension, used: 10, reserved: 0, limit: 10, projected: 11, can_retry_after_reduction: true }, }]); expect(cloudSyncAttentionLabel(limit("sync_active_bytes"))).toBe("Cloud storage full"); - expect(cloudSyncAttentionLabel(limit("sync_active_items"))).toBe("Cloud item limit reached"); + expect(cloudSyncAttentionLabel(limit("sync_active_items"))).toBe("Cloud file limit reached"); expect(cloudSyncAttentionLabel(limit("vaults"))).toBe("Cloud capacity reached"); expect(cloudSyncAttentionLabel(run([]))).toBeNull(); expect(cloudSyncAttentionLabel(null)).toBeNull(); @@ -251,7 +252,7 @@ describe("cloud auto sync host wiring", () => { await expect(syncCloudVaultWithStatus(host.bridge)).rejects.toThrow("Connection timed out"); handlers.finished(null, "Connection timed out"); expect(useCloudSyncStatusStore.getState().error).toBe( - "Note saved. Remaining vault sync failed: Connection timed out", + "Your note was saved, but the rest of the vault didn't sync. Connection timed out", ); handlers.finished(summary, null); handlers.finished(null, "A later unrelated error"); @@ -480,7 +481,7 @@ describe("cloud auto sync host wiring", () => { vaultName: "Notes", lastSyncedAt: null, error: - "Cloud capacity reached. Remove files or increase your Cloud capacity.", + "Your ZenNotes Cloud plan is full, so some changes are waiting on this device. Delete files you no longer need, or change your plan under Billing on zennotes.org.", }); }); @@ -516,7 +517,7 @@ describe("cloud auto sync host wiring", () => { expect(useCloudSyncStatusStore.getState()).toMatchObject({ phase: "attention", error: - "Cloud active-item limit reached (100 of 100). Remove files or increase your Cloud capacity.", + "Your ZenNotes Cloud plan holds up to 100 files across your account, and you have reached that. New files are waiting on this device. Delete files you no longer need, or change your plan under Billing on zennotes.org.", }); }); @@ -664,8 +665,11 @@ describe("cloud auto sync host wiring", () => { it("disconnects when the service rejects the stored credential", async () => { const host = setup(); - const unauthorized = Object.assign(new Error("Unauthenticated."), { + // The shape both the desktop client and the phone shells throw. + const unauthorized = Object.assign(new Error("Authentication is required."), { + name: "CloudServiceRequestError", status: 401, + code: "UNAUTHENTICATED", }); host.syncCloudVault.mockRejectedValue(unauthorized); const onError = vi.fn(); @@ -680,6 +684,13 @@ describe("cloud auto sync host wiring", () => { expect(host.logoutCloudAccount).toHaveBeenCalledTimes(1); expect(onError).not.toHaveBeenCalled(); + // The row says why the device is signed out, in words, until it connects. + expect(useCloudSyncStatusStore.getState()).toMatchObject({ + phase: "disconnected", + error: CLOUD_SIGNED_OUT_MESSAGE, + errorKind: "signed-out", + errorDetail: "HTTP 401 · UNAUTHENTICATED · Authentication is required.", + }); await vi.advanceTimersByTimeAsync(60_000); await flushPromises(); @@ -799,7 +810,7 @@ describe("cloudSyncAttentionItems (Discord: name the file, not the count)", () = }, ], }); - expect(items.map((item) => item.path)).toEqual(["inbox/Old.md", "item i-10"]); + expect(items.map((item) => item.path)).toEqual(["inbox/Old.md", "A file"]); }); it("remembers the last run summary so Review can show it without another sync", async () => { @@ -972,7 +983,7 @@ describe("the vault settings question (#816)", () => { ); const state = useCloudSyncStatusStore.getState(); expect(state.phase).toBe("attention"); - expect(state.error).toContain("1 file differs"); + expect(state.error).toContain("1 file was changed both on this device and on another device"); expect(cloudSyncAttentionIsSettingsOnly()).toBe(false); expect(state.settingsConflict).toEqual(question); diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index c81866a8..8bd79284 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -16,6 +16,11 @@ import { type CloudAutoSyncReason, } from "@zennotes/shared-domain/cloud-auto-sync"; import { formatCloudBytes } from "@zennotes/shared-domain/cloud-bytes"; +import { + describeCloudError, + type CloudErrorCopy, + type CloudErrorKind, +} from "@zennotes/shared-domain/cloud-error-copy"; import { cloudSyncPathKey, shouldSyncVaultPath, @@ -89,7 +94,13 @@ interface CloudSyncStatusStore { phase: CloudSyncPhase; vaultName: string | null; lastSyncedAt: number | null; + /** What the status row and Settings say: a failure's plain sentence, or + * what a finished run needs from the person. */ error: string | null; + /** The technical line behind a failure, for Settings' Details. */ + errorDetail: string | null; + /** Null for anything that is not a failure. */ + errorKind: CloudErrorKind | null; /** What the last completed run reported, so the status bar's Review can * show the files that need attention without another sync first. */ lastSummary: CloudSyncRunSummary | null; @@ -127,6 +138,8 @@ const emptyCloudSyncStatus: CloudSyncStatusStore = { vaultName: null, lastSyncedAt: null, error: null, + errorDetail: null, + errorKind: null, lastSummary: null, conflictReviewOpen: false, conflictReviewStartId: null, @@ -137,6 +150,17 @@ const emptyCloudSyncStatus: CloudSyncStatusStore = { removedVault: null, }; +type CloudSyncStatusText = Pick; + +/** Status text that is not a failure: an attention message, or nothing. */ +function statusText(message: string | null): CloudSyncStatusText { + return { error: message, errorDetail: null, errorKind: null }; +} + +function failureText(copy: CloudErrorCopy): CloudSyncStatusText { + return { error: copy.message, errorDetail: copy.detail, errorKind: copy.kind }; +} + const SETTINGS_ATTENTION_MESSAGE = "Vault settings differ on this device and in Cloud. Choose which settings to use."; @@ -248,14 +272,12 @@ export function startCloudAutoSync( intervalMs: timings.intervalMs, retryDelaysMs: timings.retryDelaysMs, onError(error, retryInMs) { - if (!isUnauthorizedCloudError(error)) { + const copy = cloudSyncErrorCopy(error); + if (copy.kind !== "signed-out") { reportError(error, retryInMs); return; } - - void bridge.logoutCloudAccount().catch((logoutError) => { - reportError(logoutError, retryInMs); - }); + signOutAfterRefusal(bridge, copy); }, }); const unsubscribeVault = bridge.onVaultChange((event) => { @@ -271,7 +293,7 @@ export function startCloudAutoSync( else if (error) { useCloudSyncStatusStore.setState({ phase: "error", - error: syncFailureMessage(error), + ...syncFailure(error), }); void refreshRemovedCloudLink(bridge, error); } @@ -324,14 +346,16 @@ export async function connectCloudAccountFromStatusBar( try { await bridge.connectCloudAccount(); } catch (error) { - markCloudSyncDisconnected(cloudSyncErrorMessage(error)); + markCloudSyncDisconnected(failureText(cloudSyncErrorCopy(error))); throw error; } } export async function syncCloudVaultWithStatus( bridge: Pick & - Partial> = getZenBridge(), + Partial< + Pick + > = getZenBridge(), vaultName?: string | null, ): Promise { const current = useCloudSyncStatusStore.getState(); @@ -339,7 +363,7 @@ export async function syncCloudVaultWithStatus( useCloudSyncStatusStore.setState({ phase: "syncing", vaultName: nextVaultName, - error: null, + ...statusText(null), }); try { @@ -354,11 +378,14 @@ export async function syncCloudVaultWithStatus( applyCloudSyncSummary(summary, nextVaultName); return summary; } catch (error) { - if (!await refreshRemovedCloudLink(bridge, error)) { + const copy = cloudSyncErrorCopy(error); + if (copy.kind === "signed-out") { + signOutAfterRefusal(bridge, copy); + } else if (!await refreshRemovedCloudLink(bridge, error)) { useCloudSyncStatusStore.setState({ phase: "error", vaultName: nextVaultName, - error: syncFailureMessage(error), + ...syncFailure(error), }); } // The parked copy is local, so a failed run can still surface a question @@ -405,7 +432,7 @@ function applyCloudSettingsConflict(next: CloudSyncSettingsConflict | null): voi ? {} : { phase: settledAttention === null ? "ready" : "attention", - error: settledAttention, + ...statusText(settledAttention), }), }); return; @@ -421,7 +448,7 @@ function applyCloudSettingsConflict(next: CloudSyncSettingsConflict | null): voi settingsConflict: next, settingsConflictPromptOpen: current.settingsConflictPromptOpen || newQuestion, ...(current.phase === "ready" - ? { phase: "attention", error: SETTINGS_ATTENTION_MESSAGE } + ? { phase: "attention", ...statusText(SETTINGS_ATTENTION_MESSAGE) } : {}), }); } @@ -517,7 +544,7 @@ async function refreshRemovedCloudLink( return false; } const vaultName = useCloudSyncStatusStore.getState().vaultName; - markCloudSyncUnlinked(syncFailureMessage(error)); + markCloudSyncUnlinked(syncFailure(error)); // The hosts drop a link after a failed request only once Cloud has answered // that its vault is gone ("not found") or was replaced by an encrypted one, // so a link found gone after a failure means a deleted vault unless the @@ -717,7 +744,7 @@ function applyCloudSyncSummary(summary: CloudSyncRunSummary, vaultName?: string phase: attention === null ? "ready" : "attention", vaultName: vaultName ?? current.vaultName, lastSyncedAt: attention === null ? Date.now() : current.lastSyncedAt, - error: attention, + ...statusText(attention), lastSummary: summary, resolutionSaved: false, // Do not reopen a finished review on the next unrelated conflict. @@ -877,18 +904,25 @@ function markCloudSyncReady(vaultName: string): void { vaultName, lastSyncedAt: current.vaultName === vaultName ? current.lastSyncedAt : null, resolutionSaved: current.vaultName === vaultName && current.resolutionSaved, - error: null, + ...statusText(null), }); } -function markCloudSyncDisconnected(error: string | null = null): void { +function markCloudSyncDisconnected(failure: CloudSyncStatusText = statusText(null)): void { announcedCloudConflictIds.clear(); + const current = useCloudSyncStatusStore.getState(); + // Signing out after Cloud refused this device's sign-in announces the + // account change too; why it happened stays until the person connects. + const reason = + failure.error === null && current.errorKind === "signed-out" + ? { error: current.error, errorDetail: current.errorDetail, errorKind: current.errorKind } + : failure; useCloudSyncStatusStore.setState({ phase: "disconnected", vaultName: null, lastSyncedAt: null, resolutionSaved: false, - error, + ...reason, settingsConflict: null, settingsConflictPromptOpen: false, }); @@ -900,18 +934,18 @@ function markCloudSyncConnecting(): void { vaultName: null, lastSyncedAt: null, resolutionSaved: false, - error: null, + ...statusText(null), }); } -function markCloudSyncUnlinked(error: string | null = null): void { +function markCloudSyncUnlinked(failure: CloudSyncStatusText = statusText(null)): void { announcedCloudConflictIds.clear(); useCloudSyncStatusStore.setState({ phase: "unlinked", vaultName: null, lastSyncedAt: null, resolutionSaved: false, - error, + ...failure, lastSummary: null, conflictReviewOpen: false, // Unlinking leaves the parked copy on disk, but there is no cloud to @@ -926,13 +960,32 @@ function isSyncableVaultChange(event: VaultChangeEvent): boolean { return shouldSyncVaultPath(event.path); } -function isUnauthorizedCloudError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "status" in error && - error.status === 401 - ); +let signingOutAfterRefusal: Promise | null = null; + +/** + * Cloud no longer accepts this device's sign-in (it was removed from the + * account on the website, or the sign-in expired). Every later request would + * be refused the same way, so the device signs out, as if the person had + * chosen Disconnect, and the status row says why until they connect again. + * Concurrent refusals (another window, the change check) sign out once. + */ +export function signOutAfterRefusal( + bridge: Partial>, + copy: CloudErrorCopy, +): void { + const current = useCloudSyncStatusStore.getState(); + // A failed run reports here and to the controller's onError: one sign-out. + if (current.phase === "disconnected" && current.errorKind === "signed-out") return; + markCloudSyncDisconnected(failureText(copy)); + if (!bridge.logoutCloudAccount || signingOutAfterRefusal) return; + signingOutAfterRefusal = bridge + .logoutCloudAccount() + .catch((error: unknown) => { + console.warn("[cloud-sync] Signing out after a refused sign-in failed", error); + }) + .finally(() => { + signingOutAfterRefusal = null; + }); } function browserCloudAutoSyncEnvironment(): CloudAutoSyncEnvironment { @@ -964,17 +1017,21 @@ function logAutomaticSyncError(error: unknown, retryInMs: number): void { ); } -function syncFailureMessage(error: unknown): string { +function syncFailure(error: unknown): CloudSyncStatusText { + const copy = cloudSyncErrorCopy(error); const prefix = useCloudSyncStatusStore.getState().resolutionSaved - ? "Note saved. Remaining vault sync failed: " + ? "Your note was saved, but the rest of the vault didn't sync. " : ""; - return prefix + cloudSyncErrorMessage(error); + return failureText({ ...copy, message: prefix + copy.message }); } -function cloudSyncErrorMessage(error: unknown): string { - return humanIpcError(error instanceof Error ? error : new Error(String(error)), "Cloud sync failed."); +/** A failed run or Cloud request in plain words, with its technical line. */ +export function cloudSyncErrorCopy(error: unknown): CloudErrorCopy { + return describeCloudError(error, CLOUD_SYNC_FAILED_MESSAGE); } +export const CLOUD_SYNC_FAILED_MESSAGE = "Sync didn't finish. Choose Sync now to try again."; + function firstCapacityConflict(summary: CloudSyncRunSummary): CloudSyncConflict | undefined { return summary.conflicts.find((conflict) => [ @@ -1016,7 +1073,7 @@ export function cloudSyncAttentionLabel( return `${count} ${count === 1 ? "file" : "files"} too large for Cloud`; } if (dimension === "sync_active_bytes") return "Cloud storage full"; - if (dimension === "sync_active_items") return "Cloud item limit reached"; + if (dimension === "sync_active_items") return "Cloud file limit reached"; return "Cloud capacity reached"; } @@ -1027,10 +1084,10 @@ export function cloudSyncAttentionMessage( if (capacityConflict) { const capacity = capacityConflict.capacity; if (capacity?.dimension === "sync_active_items") { - return `Cloud active-item limit reached (${capacity.used + capacity.reserved} of ${capacity.limit}). Remove files or increase your Cloud capacity.`; + return `Your ZenNotes Cloud plan holds up to ${formatCount(capacity.limit)} files across your account, and you have reached that. New files are waiting on this device. Delete files you no longer need, or change your plan under Billing on zennotes.org.`; } if (capacity?.dimension === "sync_active_bytes") { - return `Cloud storage limit reached (${formatCloudBytes(capacity.used + capacity.reserved)} of ${formatCloudBytes(capacity.limit)}). Remove files or increase your Cloud capacity.`; + return `Your ZenNotes Cloud storage is full (${formatCloudBytes(capacity.used + capacity.reserved)} of ${formatCloudBytes(capacity.limit)}), so new and changed files are waiting on this device. Delete large files you no longer need, or add storage under Billing on zennotes.org.`; } if (capacity?.dimension === "sync_max_file_bytes") { // Name the file: "a file" sent people hunting through the whole vault. @@ -1042,7 +1099,7 @@ export function cloudSyncAttentionMessage( } return `${onlyPath ? `“${fileName(onlyPath)}”` : "A file"} is larger than the ${limit} Cloud file-size limit, so it stays on this device. Remove it or make it smaller to finish syncing.`; } - return "Cloud capacity reached. Remove files or increase your Cloud capacity."; + return "Your ZenNotes Cloud plan is full, so some changes are waiting on this device. Delete files you no longer need, or change your plan under Billing on zennotes.org."; } const decisionCount = @@ -1050,7 +1107,7 @@ export function cloudSyncAttentionMessage( (summary.bootstrap_conflicts?.length ?? 0); if (decisionCount > 0) { const count = decisionCount; - return `Cloud sync needs attention: ${count} ${count === 1 ? "file differs" : "files differ"} on this device and in Cloud.`; + return `${count} ${count === 1 ? "file was" : "files were"} changed both on this device and on another device. Choose Review to decide what to keep.`; } if (summary.local_conflicts.length > 0) { const count = summary.local_conflicts.length; @@ -1061,7 +1118,7 @@ export function cloudSyncAttentionMessage( } if (summary.conflicts.length > 0) { const count = summary.conflicts.length; - return `Cloud sync needs attention: ${count} ${count === 1 ? "change could" : "changes could"} not be applied.`; + return `${count} ${count === 1 ? "change" : "changes"} didn't sync. The list below says what happened to each file.`; } return null; @@ -1088,12 +1145,19 @@ export interface CloudSyncAttentionItem { detail: string; /** Where the Cloud version was parked, when there is one to look at. */ conflictCopyPath: string | null; + /** What Cloud answered, for Details, when the row could not say more. */ + technical?: string | null; } function fileName(path: string): string { return path.slice(path.lastIndexOf("/") + 1); } +/** "50,000": English grouping, like the rest of these sentences. */ +function formatCount(value: number): string { + return new Intl.NumberFormat("en-US").format(value); +} + function pendingCloudConflictDetail(conflict: CloudSyncPendingConflict): string { return conflict.kind === "delete" ? "This file was edited here and deleted on another device. Choose whether to keep the note or delete it everywhere." @@ -1172,7 +1236,7 @@ export function cloudSyncAttentionItems( } for (const conflict of summary.conflicts) { if (CAPACITY_CODES.has(conflict.code)) continue; - const path = conflict.path ?? conflict.current_path ?? `item ${conflict.item_id}`; + const path = conflict.path ?? conflict.current_path ?? "A file"; const detail = conflict.code === "REVISION_CONFLICT" ? "Changed on another device after this device last synced. Sync again to load both versions into the resolver." @@ -1180,8 +1244,15 @@ export function cloudSyncAttentionItems( ? `Another Cloud file already uses this name${conflict.current_path && conflict.current_path !== path ? ` (${conflict.current_path})` : ""}. Rename one of them and sync again.` : conflict.code === "ITEM_DELETED" ? "Deleted on another device while this device still had a change. Sync again to choose whether to keep or delete it." - : `Cloud rejected this change (${conflict.code}).`; - items.push({ kind: "rejected", path, detail, conflictCopyPath: null }); + : "ZenNotes Cloud didn't accept this change. Choose Sync now to retry; if it stays, copy the details and choose Report an Issue."; + const known = ["REVISION_CONFLICT", "PATH_CONFLICT", "ITEM_DELETED"].includes(conflict.code); + items.push({ + kind: "rejected", + path, + detail, + conflictCopyPath: null, + technical: known ? null : `${conflict.code} · item ${conflict.item_id}`, + }); } return items; } diff --git a/packages/app-core/src/lib/cloud-publishing.ts b/packages/app-core/src/lib/cloud-publishing.ts index a089a08e..ca8b1222 100644 --- a/packages/app-core/src/lib/cloud-publishing.ts +++ b/packages/app-core/src/lib/cloud-publishing.ts @@ -50,7 +50,7 @@ export async function publishCloudNote( ): Promise { const account = await bridge.getCloudServiceAccount() if (!account.features.publish.active) { - throw new Error('Publishing requires a ZenNotes Cloud plan.') + throw new Error("Publishing isn't part of your current plan. Choose a plan under Billing on zennotes.org to publish notes.") } const refs = [...new Set(note.assetEmbeds)] if (refs.length !== assets.length) { diff --git a/packages/app-core/src/lib/cm-code-fence-indent.test.ts b/packages/app-core/src/lib/cm-code-fence-indent.test.ts index 690c5624..13d179e6 100644 --- a/packages/app-core/src/lib/cm-code-fence-indent.test.ts +++ b/packages/app-core/src/lib/cm-code-fence-indent.test.ts @@ -9,20 +9,36 @@ import { vim } from '@replit/codemirror-vim' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from './cm-vim-default-keymap' import { markdownSnippetExtension } from './cm-markdown-snippets' +import { noteMarkdown } from './cm-markdown-language' const views: EditorView[] = [] afterEach(() => views.splice(0).forEach((v) => v.destroy())) -function mount(doc: string, withSnippets = true): EditorView { +type MountOptions = { + withSnippets?: boolean + // EditorPane's note grammar, which parses a ```js body as JavaScript. + note?: boolean + vimMode?: boolean + cursor?: number +} + +function mount( + doc: string, + { withSnippets = true, note = false, vimMode = true, cursor }: MountOptions = {} +): EditorView { const view = new EditorView({ state: EditorState.create({ doc, + selection: cursor === undefined ? undefined : { anchor: cursor }, + // In EditorPane's order: the snippets ahead of the markdown keymap, both + // Prec.high, so `$$` + Enter auto-closes before the markdown command can + // continue a list or quote (#961). extensions: [ - vim(), - markdown({ base: markdownLanguage, addKeymap: false }), - vimAwareMarkdownKeymap, ...(withSnippets ? [markdownSnippetExtension()] : []), - keymap.of([...vimAwareDefaultKeymap(true)]) + ...(vimMode ? [vim()] : []), + note ? noteMarkdown() : markdown({ base: markdownLanguage, addKeymap: false }), + vimAwareMarkdownKeymap, + keymap.of([...vimAwareDefaultKeymap(vimMode)]) ] }), parent: document.body @@ -90,3 +106,128 @@ describe('fenced code Enter indentation in lists (#405)', () => { expect(view.state.doc.toString()).toBe('- ```bash') }) }) + +// #961: in a quote or callout the quote marker carries onto the new lines. +describe('block Enter inside quotes and callouts (#961)', () => { + it('auto-closes a math block typed in a callout, every line quoted', () => { + const view = mount('> [!note] Note heading\n> ') + press(view, 'G', 71) // last line + press(view, 'A', 65) + typeInput(view, '$$') + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> [!note] Note heading\n> $$\n> \n> $$') + }) + + it('auto-closes a fence typed in a quote, every line quoted', () => { + const view = mount('> ') + press(view, 'A', 65) + typeInput(view, '```js') + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> ```js\n> \n> ```') + }) + + it('keeps the quote on Enter at an existing quoted fence opener (fallback)', () => { + const view = mount('> ```js') + press(view, 'A', 65) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> ```js\n> ') + }) + + it('keeps the quote and turns the list marker to spaces for a fence in a quoted item', () => { + const view = mount('> - ```js') + press(view, 'A', 65) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> - ```js\n> ') + }) +}) + +// lang-markdown's Enter gives up inside FencedCode and the default newline +// copies only leading whitespace, so Enter on a code line in a quote or callout +// dropped the `>`: the new line left the callout and broke the block. These +// mount the note grammar, as EditorPane does, so a ```js body is JavaScript. +describe('Enter on a fenced code line inside a quote or callout', () => { + const CALLOUT = '> [!tip] Code\n> ```js\n> xy\n> ```' + const endOf = (doc: string, text: string): number => doc.indexOf(text) + text.length + + it('keeps the callout on a content line in insert mode', () => { + const view = mount(CALLOUT, { note: true, cursor: CALLOUT.indexOf('> xy') }) + press(view, 'A', 65) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> [!tip] Code\n> ```js\n> xy\n> \n> ```') + expect(view.state.selection.main.head).toBe(endOf(CALLOUT, '> xy') + 3) + }) + + it('keeps the callout with Vim off', () => { + const view = mount(CALLOUT, { note: true, vimMode: false, cursor: endOf(CALLOUT, '> xy') }) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> [!tip] Code\n> ```js\n> xy\n> \n> ```') + }) + + it("carries the line's own indentation after the quote", () => { + const doc = '> ```js\n> if (a) {\n> b()\n> ```' + const view = mount(doc, { note: true, vimMode: false, cursor: endOf(doc, 'b()') }) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> ```js\n> if (a) {\n> b()\n> \n> ```') + }) + + it('splits a line, the rest landing after the quote', () => { + const doc = '> ```js\n> foo bar\n> ```' + const view = mount(doc, { note: true, vimMode: false, cursor: endOf(doc, 'foo') }) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> ```js\n> foo\n> bar\n> ```') + }) + + it('reads a `>` that starts the code as code, not as a quote marker', () => { + const doc = '> ```py\n> >>> 1 + 1\n> ```' + const view = mount(doc, { note: true, vimMode: false, cursor: endOf(doc, '1 + 1') }) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> ```py\n> >>> 1 + 1\n> \n> ```') + }) + + it('stays in the callout after the closing fence', () => { + const view = mount(CALLOUT, { note: true, vimMode: false, cursor: CALLOUT.length }) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe(`${CALLOUT}\n> `) + }) + + it('keeps both markers in a nested quote', () => { + const doc = '> > ```js\n> > xy\n> > ```' + const view = mount(doc, { note: true, vimMode: false, cursor: endOf(doc, 'xy') }) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> > ```js\n> > xy\n> > \n> > ```') + }) + + it('keeps the quote and the item indent for a fence in a quoted list item', () => { + const doc = '> - ```js\n> xy\n> ```' + const view = mount(doc, { note: true, vimMode: false, cursor: endOf(doc, 'xy') }) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> - ```js\n> xy\n> \n> ```') + }) + + it('trims the blank quoted line it leaves behind', () => { + const doc = '> ```js\n> xy\n> ' + const view = mount(doc, { note: true, vimMode: false, cursor: doc.length }) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> ```js\n> xy\n>\n> ') + }) + + it('leaves a line with no `>` after a quoted fence alone (it ends the quote)', () => { + const doc = '> ```js\n> xy\nzz' + const view = mount(doc, { note: true, vimMode: false, cursor: doc.length }) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('> ```js\n> xy\nzz\n') + }) + + it('leaves an unquoted fence to the language indent', () => { + const doc = '```js\nf() {}\n```' + const view = mount(doc, { note: true, vimMode: false, cursor: endOf(doc, '{') }) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe('```js\nf() {\n \n}\n```') + }) + + it('does not edit on Enter in Vim normal mode (it is the motion)', () => { + const view = mount(CALLOUT, { note: true, cursor: CALLOUT.indexOf('> xy') }) + press(view, 'Enter', 13) + expect(view.state.doc.toString()).toBe(CALLOUT) + }) +}) diff --git a/packages/app-core/src/lib/cm-code-fence-indent.ts b/packages/app-core/src/lib/cm-code-fence-indent.ts index 143df6bb..d940b88e 100644 --- a/packages/app-core/src/lib/cm-code-fence-indent.ts +++ b/packages/app-core/src/lib/cm-code-fence-indent.ts @@ -1,5 +1,5 @@ import { syntaxTree } from '@codemirror/language' -import { EditorSelection } from '@codemirror/state' +import { EditorSelection, countColumn, type Line } from '@codemirror/state' import type { EditorView } from '@codemirror/view' import type { SyntaxNode } from '@lezer/common' import { hasPendingMarkdownBlockSnippet } from './cm-markdown-snippets' @@ -21,6 +21,9 @@ import { hasPendingMarkdownBlockSnippet } from './cm-markdown-snippets' * the content lines, and top-level fences) is left to * `insertNewlineContinueMarkup`, which indents them correctly. Returning * `false` falls through to it. + * + * The one exception is a fence inside a quote or callout: there the content + * and closing lines are taken over too (see `continueQuotedFenceLine`). */ export function insertNewlineContinueFencedCodeIndent(view: EditorView): boolean { const { state } = view @@ -39,18 +42,18 @@ export function insertNewlineContinueFencedCodeIndent(view: EditorView): boolean const fenceLine = state.doc.lineAt(node.from) const caretLine = state.doc.lineAt(range.head) - // Only the opener line needs help; content lines already copy their own - // leading whitespace correctly. - if (fenceLine.number !== caretLine.number) return false - const prefix = fenceLine.text.slice(0, node.from - fenceLine.from) + // Past the opener, content lines already copy their own leading whitespace + // correctly, unless the fence is in a quote. + if (fenceLine.number !== caretLine.number) { + return continueQuotedFenceLine(view, node, prefix, caretLine) + } + // Whitespace-only (or empty) indent is already handled by the default // command; only a list/quote marker before the fence trips it up. if (!/\S/.test(prefix)) return false - // Align continued lines to the fence column, turning the marker into spaces - // so the content sits inside the list item. - const insert = state.lineBreak + prefix.replace(/\S/g, ' ') + const insert = state.lineBreak + fenceContinuationPrefix(prefix) view.dispatch( state.changeByRange((r) => ({ changes: { from: r.from, to: r.to, insert }, @@ -60,3 +63,68 @@ export function insertNewlineContinueFencedCodeIndent(view: EditorView): boolean ) return true } + +/** Align continued lines to the fence column, turning a list marker into + * spaces so the content sits inside the item. A quote marker stays, or the + * new line would leave the quote or callout the fence is in (#961). The same + * rule as the block snippet's continuation prefix. */ +function fenceContinuationPrefix(openerPrefix: string): string { + return openerPrefix.replace(/[^\s>]/g, ' ') +} + +/** + * Enter on a content or closing line of a fence inside a quote or callout. + * + * `insertNewlineContinueMarkup` returns no markup context inside FencedCode, + * so the default newline ran, which copies only leading whitespace. On + * `> xy` that is nothing: the new line had no `>`, left the callout, and the + * block broke there. Here the new line gets the opener's continuation prefix + * plus whatever indentation this line carries past it, and otherwise behaves + * as the default newline does: whitespace after the caret is dropped, and a + * line left holding only its quote markup and whitespace is trimmed to the + * markup. The closing line is handled the same way, so the caret stays in the + * quote after the block. + * + * Unquoted fences and multiple carets are left to the default command. In a + * quoted fence the code language's own indentation (after a `{`, say) is not + * applied: it measures columns from a line start that holds the `>`. + */ +function continueQuotedFenceLine( + view: EditorView, + fence: SyntaxNode, + openerPrefix: string, + line: Line +): boolean { + const { state } = view + if (!openerPrefix.includes('>') || state.selection.ranges.length > 1) return false + const head = state.selection.main.head + + // Where this line's quote markup ends, read from the parse: code may itself + // start with `>` (a `>>>` prompt), and that is not a quote marker. + let markupEnd = -1 + for (let child = fence.firstChild; child && child.from <= line.to; child = child.nextSibling) { + if (child.name === 'QuoteMark' && child.from >= line.from) markupEnd = child.to + } + // A caret inside the markup (or a line with none) is not a code position. + if (markupEnd < 0 || head < markupEnd) return false + + let leadEnd = markupEnd + while (leadEnd < line.to && /[\t ]/.test(line.text[leadEnd - line.from])) leadEnd++ + const lead = line.text.slice(0, leadEnd - line.from) + const prefix = fenceContinuationPrefix(openerPrefix) + const extra = lead.startsWith(prefix) + ? lead.slice(prefix.length) + : ' '.repeat(Math.max(0, countColumn(lead, state.tabSize) - countColumn(prefix, state.tabSize))) + const insert = state.lineBreak + prefix + extra + + let to = head + while (to < line.to && /[\t ]/.test(line.text[to - line.from])) to++ + const from = /\S/.test(state.sliceDoc(markupEnd, head)) ? head : markupEnd + view.dispatch({ + changes: { from, to, insert }, + selection: EditorSelection.cursor(from + insert.length), + scrollIntoView: true, + userEvent: 'input' + }) + return true +} diff --git a/packages/app-core/src/lib/cm-markdown-snippets.test.ts b/packages/app-core/src/lib/cm-markdown-snippets.test.ts index 8a5a1ebb..fab3c39f 100644 --- a/packages/app-core/src/lib/cm-markdown-snippets.test.ts +++ b/packages/app-core/src/lib/cm-markdown-snippets.test.ts @@ -233,6 +233,61 @@ describe('block snippets inside list items (#405)', () => { }) }) +// #961: inside a quote or callout every line of the block needs its `>`, or +// the body and closing fence leave the quote and the block does not render. +describe('block snippets inside quotes and callouts (#961)', () => { + function caretLine(state: EditorState | null): string | undefined { + if (!state) return undefined + const head = state.selection.main.head + const line = state.doc.lineAt(head) + return head === line.to ? line.text : undefined + } + + it('keeps the quote marker on a math block in a callout', () => { + const state = typeThenTrigger('> [!note] Note heading\n> ', '$$', 'Enter') + expect(state?.doc.toString()).toBe('> [!note] Note heading\n> $$\n> \n> $$') + expect(caretLine(state)).toBe('> ') + }) + + it('keeps it on a fenced code block in a quote', () => { + const state = typeThenTrigger('> ', '```js', 'Enter') + expect(state?.doc.toString()).toBe('> ```js\n> \n> ```') + }) + + it('keeps every marker of a nested quote', () => { + const state = typeThenTrigger('> > ', '$$', 'Enter') + expect(state?.doc.toString()).toBe('> > $$\n> > \n> > $$') + }) + + it('keeps a quote marker written without its space', () => { + const state = typeThenTrigger('>', '$$', 'Enter') + expect(state?.doc.toString()).toBe('>$$\n>\n>$$') + expect(caretLine(state)).toBe('>') + }) + + it('indents under a list item inside a quote, keeping the quote', () => { + const state = typeThenTrigger('> - ', '$$', 'Enter') + expect(state?.doc.toString()).toBe('> - $$\n> \n> $$') + }) + + it('opens on a list item continuation inside a callout', () => { + const state = typeThenTrigger('> [!note] Outer\n> - item\n> ', '$$', 'Enter') + expect(state?.doc.toString()).toBe('> [!note] Outer\n> - item\n> $$\n> \n> $$') + expect(caretLine(state)).toBe('> ') + }) + + it('keeps a quote nested in a list item', () => { + const state = typeThenTrigger('- > ', '$$', 'Enter') + expect(state?.doc.toString()).toBe('- > $$\n > \n > $$') + }) + + it('reads a quoted closer as the end of the block, not a new opener', () => { + const doc = '> $$\n> x\n> ' + const typed = typeInput(createState(doc, doc.length), '$$') + expect(markdownSnippetTransaction(typed, 'Enter')).toBeNull() + }) +}) + // #718: no snippet may fire inside code. The plain harness above has no // markdown parser, so its states carry no FencedCode/InlineCode nodes; this // one parses the doc the way the real editor does. diff --git a/packages/app-core/src/lib/cm-markdown-snippets.ts b/packages/app-core/src/lib/cm-markdown-snippets.ts index b2f935a2..c8c24766 100644 --- a/packages/app-core/src/lib/cm-markdown-snippets.ts +++ b/packages/app-core/src/lib/cm-markdown-snippets.ts @@ -52,13 +52,18 @@ const markdownSnippetRulesFacet = Facet.define< // Leading whitespace plus any list/blockquote markers, i.e. everything before a // fence's own column. Lets a block open inside a list item or quote (`- ```bash`, // `> $$`) and lets its auto-inserted lines align to the fence column. (#405) -const BLOCK_INDENT_PREFIX_RE = /^[\t ]*(?:(?:[-+*]|\d{1,9}[.)])[\t ]+|>[\t ]?)*/ - -/** The whitespace to align a block's continuation lines to its fence column: - * the leading indent kept as-is, any list/quote marker turned into spaces. */ -function blockIndentFor(lineText: string): string { +// A quote marker may be followed by more than one space: a list item's +// continuation inside a callout (`> $$`) opens a block too (#961). +const BLOCK_INDENT_PREFIX_RE = /^[\t ]*(?:(?:[-+*]|\d{1,9}[.)])[\t ]+|>[\t ]*)*/ + +/** What a block's continuation lines start with, so they line up with its + * fence column: the leading indent kept as-is and any list marker turned into + * spaces, the item's continuation indent. A quote marker is kept: a line + * without its `>` leaves the quote (or callout), and the body and closing + * fence fell out of it with nothing to render (#961). */ +function blockContinuationPrefix(lineText: string): string { const prefix = lineText.match(BLOCK_INDENT_PREFIX_RE)?.[0] ?? '' - return prefix.replace(/[^\t ]/g, ' ') + return prefix.replace(/[^\t >]/g, ' ') } function isBlockOpenerLine(rule: MarkdownSnippetRule, text: string): boolean { @@ -163,12 +168,13 @@ function blockSnippetTransaction( // legitimate case (#718). if (isInsideCode(state, selection.head, line.number)) return null - // Align to the fence column so a block opened inside a list item keeps its - // content and closing fence inside the item instead of escaping to col 0 (#405). - const indent = blockIndentFor(line.text) + // Align to the fence column so a block opened inside a list item or a quote + // keeps its content and closing fence inside it instead of escaping to + // col 0 (#405, #961). + const prefix = blockContinuationPrefix(line.text) - const insert = `${line.text}\n${indent}\n${indent}${rule.close}` - const cursor = line.from + line.text.length + 1 + indent.length + const insert = `${line.text}\n${prefix}\n${prefix}${rule.close}` + const cursor = line.from + line.text.length + 1 + prefix.length return { changes: { from: line.from, to: line.to, insert }, selection: { anchor: cursor } diff --git a/packages/app-core/src/lib/cm-vim-ime-guard-coverage.test.ts b/packages/app-core/src/lib/cm-vim-ime-guard-coverage.test.ts new file mode 100644 index 00000000..074c8b36 --- /dev/null +++ b/packages/app-core/src/lib/cm-vim-ime-guard-coverage.test.ts @@ -0,0 +1,27 @@ +// Guards #921: the input-method guard (#84, #464) reached only the main +// editor, the floating note and Quick capture. The external-file window, the +// reference pane and the template editor built Vim without it, so a CJK input +// method composed over hjkl and typed `ia` into a file opened from outside the +// vault. Every source that turns Vim on must install the guard too. + +import { readdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +describe('vimImeGuard coverage (#921)', () => { + it('goes with every editor that turns Vim on', () => { + const src = join(dirname(fileURLToPath(import.meta.url)), '..') + const sources = readdirSync(src, { recursive: true, encoding: 'utf8' }).filter( + (file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file) + ) + // A scan that stopped finding the sources would pass on nothing. + expect(sources.length).toBeGreaterThan(100) + const withVim = sources.filter((file) => /\bvim\(\)/.test(readFileSync(join(src, file), 'utf8'))) + expect(withVim.length).toBeGreaterThan(3) + const unguarded = withVim.filter( + (file) => !/\bvimImeGuard\(/.test(readFileSync(join(src, file), 'utf8')) + ) + expect(unguarded).toEqual([]) + }) +}) diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index bc3c08ed..15b580f6 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -201,6 +201,11 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'End-to-end encryption keeps a Cloud vault\'s notes, attachments, file and folder names, and the vault\'s own name unreadable to ZenNotes Cloud: your devices encrypt everything before it is uploaded, with a key only you hold. Settings → Cloud shows **End-to-end encryption** (beta) once it is available for your account. Set it up there: choose a passphrase of at least 12 characters (four or more unrelated words work well), then save the 24-word recovery key it shows and type three of its words back. Either one unlocks encryption on your other devices, once per device. ZenNotes never receives them and cannot reset them; if you lose both, the encrypted Cloud copy cannot be read, but the notes on your devices are untouched.\n\nTo encrypt a new vault, tick **End-to-end encrypt this vault** when you create it. To move an existing one, choose **Encrypt this vault** in Settings → Cloud: ZenNotes makes a new encrypted vault with the same name, uploads this device\'s copy, then deletes the readable Cloud copy along with its version history and backups, so download a backup first if you want one. Other devices linked to the old vault stop syncing it; unlock encryption on each one, then open the encrypted vault from Settings → Cloud. Publishing a note from an encrypted vault still makes a readable public copy until you unpublish it. The website cannot export an encrypted vault as a ZIP. In the desktop app, **Save archive** under Backups saves a readable archive; the phone apps send you there. Change the passphrase, make a new recovery key, or lock this device from the same section; zennotes.org/security lists what ZenNotes can and cannot see.' }, + { + title: 'When Cloud reports a problem', + body: + 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report.' + }, { title: 'Customize the look: themes vs. overrides', body: @@ -287,7 +292,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Not every checkbox is a task', body: - 'A reading list is a checklist, not a to-do. Add `tasks: false` (or `off`) to a note’s frontmatter and its checkboxes stay checkboxes: they render and toggle exactly as before, but stop feeding the Tasks list, the boards, the calendars, `zn task list`, and the MCP tools. On a #task note, `tasks: note` keeps the note itself on the board while silencing its internal checklist, so a project note can be one card instead of twenty. To retire a whole folder (a media backlog, a reference library), right-click it in the sidebar and choose “Exclude from Tasks”, or manage the list under Settings → Tasks; the exclusion is stored in the vault’s own settings, so desktop, web, mobile, and the CLI all agree. When you do want to see past it, `zn task list --include-excluded` and the MCP `list_tasks` tool’s `includeExcluded` flag list everything.' + 'A reading list is a checklist, not a to-do. Add `tasks: false` (or `off`) to a note’s frontmatter and its checkboxes stay checkboxes: they render and toggle exactly as before, but stop feeding the Tasks list, the boards, the calendars, `zn task list`, and the MCP tools. On a #task note, `tasks: note` keeps the note itself on the board while silencing its internal checklist, so a project note can be one card instead of twenty. To collect tasks from a few folders only, such as a single Tasks folder, right-click each one in the sidebar and choose “Use as Task Folder”: once any Task folder is set, checkboxes everywhere else stay plain checkboxes. To retire a whole folder instead (a media backlog, a reference library), choose “Exclude from Tasks”, which also works inside a Task folder. Both lists live under Settings → Tasks and in the vault’s own settings, so the desktop app, the self-hosted web app, the CLI, and the MCP tools agree. When you do want to see past them, `zn task list --include-excluded` and the MCP `list_tasks` tool’s `includeExcluded` flag list everything.' }, { title: 'Check off a task with ⌘L', @@ -442,7 +447,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Files stay local', body: - 'Drop files into a note to insert local files. ZenNotes copies them into the vault’s `assets/` folder (the same place pasted images land), so they stay together instead of cluttering your notes area, whether you keep notes in `inbox/` or at the vault root. It can reveal them from the app, and opens images, SVGs, PDFs, audio, video, and generic files inside ZenNotes tabs or reference panes where possible. In the sidebar you can drag an image, PDF, or any attachment onto a folder to move it, just like a note, or use its Move… context-menu entry. Renaming or moving an asset (from the sidebar, the Assets view, a rendered image’s menu, or by dragging it onto a folder) rewrites every note that referenced it, `![[assets/old.png]]` embeds and `![](old.png)` links alike, in the style each link was written, the same way renaming a note updates its wikilinks.' + 'Drop files into a note to insert local files. ZenNotes copies them into the vault’s `assets/` folder (the same place pasted images land; a pasted screenshot is named for the moment you pasted it, such as `Pasted Image 2026-10-19 142312.png`, while a copied file keeps its own name), so they stay together instead of cluttering your notes area, whether you keep notes in `inbox/` or at the vault root. It can reveal them from the app, and opens images, SVGs, PDFs, audio, video, and generic files inside ZenNotes tabs or reference panes where possible. In the sidebar you can drag an image, PDF, or any attachment onto a folder to move it, just like a note, or use its Move… context-menu entry. Renaming or moving an asset (from the sidebar, the Assets view, a rendered image’s menu, or by dragging it onto a folder) rewrites every note that referenced it, `![[assets/old.png]]` embeds and `![](old.png)` links alike, in the style each link was written, the same way renaming a note updates its wikilinks.' }, { title: 'Any CSV is a database', @@ -1264,9 +1269,9 @@ export const HELP_CLI: HelpCard[] = [ 'Builds with the Go terminal tool include `zn tui`. An existing desktop-managed CLI keeps itself up to date (see Updates arrive without a ZenNotes update); keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' }, { - title: 'CLI 0.7.1 in desktop 2.69.0', + title: 'CLI 0.9.0 in desktop 2.72.0', body: - 'On macOS and Linux, desktop 2.70.0 bundles CLI 0.8.0. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. 0.7.2 reads tasks in numbered lists and quotes the way the app does, in `zn task` and in `zn mcp`, and 0.8.0 adds `zn task watch`, which prints each task status change as it happens, and gives each task’s status in `zn task list --json` and in `list_tasks`. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' + 'On macOS and Linux, desktop 2.72.0 bundles CLI 0.9.0. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. 0.7.2 reads tasks in numbered lists and quotes the way the app does, in `zn task` and in `zn mcp`, and 0.8.0 adds `zn task watch`, which prints each task status change as it happens, and gives each task’s status in `zn task list --json` and in `list_tasks`. 0.8.1 keeps a finished task note in its Custom status column and gives `zn mcp` this app’s assistant instructions again, and 0.9.0 follows the vault’s Task folders in `zn task list`, `zn task watch` and `zn mcp`, and the TUI’s Settings saves both Tasks folder lists where every app reads them. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' }, { title: 'Updates arrive without a ZenNotes update', diff --git a/packages/app-core/src/lib/picker-grid.test.ts b/packages/app-core/src/lib/picker-grid.test.ts new file mode 100644 index 00000000..7e336246 --- /dev/null +++ b/packages/app-core/src/lib/picker-grid.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { pickerGridMoveForKey, stepPickerGrid } from './picker-grid' + +describe('pickerGridMoveForKey', () => { + it('moves with the arrows whatever the Vim setting', () => { + for (const vimMode of [true, false]) { + expect(pickerGridMoveForKey('ArrowLeft', vimMode)).toBe('left') + expect(pickerGridMoveForKey('ArrowRight', vimMode)).toBe('right') + expect(pickerGridMoveForKey('ArrowUp', vimMode)).toBe('up') + expect(pickerGridMoveForKey('ArrowDown', vimMode)).toBe('down') + } + }) + + it('moves with h j k l only in Vim mode', () => { + expect(pickerGridMoveForKey('h', true)).toBe('left') + expect(pickerGridMoveForKey('j', true)).toBe('down') + expect(pickerGridMoveForKey('k', true)).toBe('up') + expect(pickerGridMoveForKey('l', true)).toBe('right') + for (const key of ['h', 'j', 'k', 'l']) { + expect(pickerGridMoveForKey(key, false)).toBeNull() + } + }) + + it('ignores every other key', () => { + expect(pickerGridMoveForKey('Enter', true)).toBeNull() + expect(pickerGridMoveForKey('J', true)).toBeNull() + expect(pickerGridMoveForKey('x', true)).toBeNull() + }) +}) + +describe('stepPickerGrid', () => { + // The color picker: ten cells in three columns, a lone cell on the last row. + const count = 10 + const columns = 3 + + it('walks left and right in reading order, across rows', () => { + expect(stepPickerGrid(2, count, columns, 'right')).toBe(3) + expect(stepPickerGrid(3, count, columns, 'left')).toBe(2) + }) + + it('keeps the column moving up and down', () => { + expect(stepPickerGrid(1, count, columns, 'down')).toBe(4) + expect(stepPickerGrid(9, count, columns, 'up')).toBe(6) + }) + + it('stays put at every edge', () => { + expect(stepPickerGrid(0, count, columns, 'left')).toBe(0) + expect(stepPickerGrid(0, count, columns, 'up')).toBe(0) + expect(stepPickerGrid(9, count, columns, 'right')).toBe(9) + expect(stepPickerGrid(9, count, columns, 'down')).toBe(9) + }) + + it('does not drop into the gap a short last row leaves', () => { + expect(stepPickerGrid(7, count, columns, 'down')).toBe(7) + expect(stepPickerGrid(6, count, columns, 'down')).toBe(9) + }) + + it('follows the two-column layout of a narrow window', () => { + expect(stepPickerGrid(1, count, 2, 'down')).toBe(3) + }) + + it('treats a missing column count as a single column', () => { + expect(stepPickerGrid(1, count, 0, 'down')).toBe(2) + }) +}) diff --git a/packages/app-core/src/lib/picker-grid.ts b/packages/app-core/src/lib/picker-grid.ts new file mode 100644 index 00000000..2fce68a1 --- /dev/null +++ b/packages/app-core/src/lib/picker-grid.ts @@ -0,0 +1,84 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' + +/** + * Keyboard movement for the sidebar's icon and color pickers (#960): a grid of + * buttons that opens with one of them focused, so a key moves focus to a + * neighbour and Enter presses it like any button. Arrows always move; h/j/k/l + * move only with Vim mode on, since single-key motions are Vim-only + * everywhere else. The grid reflows from three columns to two on a narrow + * window, so the column count is read from the rendered rows, never assumed. + */ + +export type PickerGridMove = 'left' | 'right' | 'up' | 'down' + +export function pickerGridMoveForKey(key: string, vimMode: boolean): PickerGridMove | null { + switch (key) { + case 'ArrowLeft': + return 'left' + case 'ArrowRight': + return 'right' + case 'ArrowUp': + return 'up' + case 'ArrowDown': + return 'down' + } + if (!vimMode) return null + switch (key) { + case 'h': + return 'left' + case 'l': + return 'right' + case 'k': + return 'up' + case 'j': + return 'down' + } + return null +} + +/** Left and right walk the cells in reading order, so they cross rows; up and + * down keep the column. A move past an edge, or down into the gap a short + * last row leaves, stays where it is. */ +export function stepPickerGrid( + index: number, + count: number, + columns: number, + move: PickerGridMove +): number { + if (count <= 0) return -1 + const cols = Math.max(1, columns) + const next = + move === 'left' + ? index - 1 + : move === 'right' + ? index + 1 + : move === 'up' + ? index - cols + : index + cols + return next >= 0 && next < count ? next : index +} + +/** Cells sharing the first cell's top edge form the first row. */ +function renderedColumns(cells: HTMLElement[]): number { + const top = cells[0]?.offsetTop + let columns = 0 + for (const cell of cells) { + if (cell.offsetTop !== top) break + columns += 1 + } + return columns +} + +/** onKeyDown for the grid container; its cells carry `data-picker-option`. */ +export function handlePickerGridKey(e: ReactKeyboardEvent, vimMode: boolean): void { + if (e.metaKey || e.ctrlKey || e.altKey) return + const move = pickerGridMoveForKey(e.key, vimMode) + if (!move) return + const cells = [...e.currentTarget.querySelectorAll('[data-picker-option]')] + const index = cells.indexOf(document.activeElement as HTMLElement) + if (index < 0) return + e.preventDefault() + e.stopPropagation() + const next = stepPickerGrid(index, cells.length, renderedColumns(cells), move) + cells[next]?.focus() +} diff --git a/packages/app-core/src/lib/tasks-folder-lists.test.ts b/packages/app-core/src/lib/tasks-folder-lists.test.ts new file mode 100644 index 00000000..48b697d1 --- /dev/null +++ b/packages/app-core/src/lib/tasks-folder-lists.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { toggleTasksFolder } from './tasks-folder-lists' + +describe('toggleTasksFolder (#458, #962)', () => { + it('adds a folder to an empty list', () => { + expect(toggleTasksFolder(undefined, 'includedFolders', 'Tasks')).toEqual({ + includedFolders: ['Tasks'] + }) + }) + + it('removes a folder that is already listed', () => { + expect( + toggleTasksFolder({ includedFolders: ['Tasks', 'Work'] }, 'includedFolders', 'Tasks') + ).toEqual({ includedFolders: ['Work'] }) + }) + + it('keeps the other list when toggling one', () => { + expect( + toggleTasksFolder({ includedFolders: ['Projects'] }, 'excludedFolders', 'Projects/Archive') + ).toEqual({ includedFolders: ['Projects'], excludedFolders: ['Projects/Archive'] }) + expect( + toggleTasksFolder( + { includedFolders: ['Projects'], excludedFolders: ['Projects/Archive'] }, + 'includedFolders', + 'Projects' + ) + ).toEqual({ excludedFolders: ['Projects/Archive'] }) + }) + + it('leaves no empty stub behind', () => { + expect(toggleTasksFolder({ excludedFolders: ['Books'] }, 'excludedFolders', 'Books')).toBeUndefined() + }) +}) diff --git a/packages/app-core/src/lib/tasks-folder-lists.ts b/packages/app-core/src/lib/tasks-folder-lists.ts new file mode 100644 index 00000000..e04660c6 --- /dev/null +++ b/packages/app-core/src/lib/tasks-folder-lists.ts @@ -0,0 +1,27 @@ +import type { VaultSettings } from '@shared/ipc' + +/** The two folder lists under a vault's `tasks` settings: the folders kept + * out of Tasks (#458), and the Task folders it is narrowed to (#962). */ +export type TasksFolderList = 'excludedFolders' | 'includedFolders' + +/** + * The vault's `tasks` settings with `relDir` added to or removed from one of + * its folder lists, the other list kept as it is. The exclusion toggle used to + * rebuild `tasks` from its own list alone, which would have dropped the Task + * folders the moment a folder was excluded. Undefined when both lists end up + * empty, so vault.json carries no empty stub. + */ +export function toggleTasksFolder( + tasks: VaultSettings['tasks'], + list: TasksFolderList, + relDir: string +): VaultSettings['tasks'] { + const current = tasks?.[list] ?? [] + const nextList = current.includes(relDir) + ? current.filter((folder) => folder !== relDir) + : [...current, relDir] + const next: NonNullable = { ...tasks, [list]: nextList } + if (!next.excludedFolders?.length) delete next.excludedFolders + if (!next.includedFolders?.length) delete next.includedFolders + return Object.keys(next).length > 0 ? next : undefined +} diff --git a/packages/app-core/src/lib/vault-layout.ts b/packages/app-core/src/lib/vault-layout.ts index 722b8b67..9d5d900b 100644 --- a/packages/app-core/src/lib/vault-layout.ts +++ b/packages/app-core/src/lib/vault-layout.ts @@ -25,7 +25,10 @@ import { resolveFolderPath, systemFolderForDirName } from '@shared/system-folder-paths' -import { normalizeTasksExcludedFolders } from '@shared/tasks-excluded-folders' +import { + normalizeTasksExcludedFolders, + normalizeTasksIncludedFolders +} from '@shared/tasks-excluded-folders' import { normalizeTypstPreambleSettings } from '@shared/typst-preamble-folder' import { normalizeHarperVaultState } from '@shared/harper-settings' import { normalizeVaultDisplayName } from '@shared/vault-display-name' @@ -617,6 +620,9 @@ export function normalizeVaultSettings( const normalizedTasksExcluded = normalizeTasksExcludedFolders( settings?.tasks?.excludedFolders ) + const normalizedTasksIncluded = normalizeTasksIncludedFolders( + settings?.tasks?.includedFolders + ) const normalizedTypstPreambles = normalizeTypstPreambleSettings(settings?.typstPreambles) const normalizedHarper = normalizeHarperVaultState(settings?.harper) const displayName = normalizeVaultDisplayName(settings?.displayName) @@ -686,8 +692,17 @@ export function normalizeVaultSettings( // Per-vault view overrides (#292): passed through as-is; the store validates // each value when it overlays them onto the live prefs. ...(settings?.view ? { view: settings.view } : {}), - ...(normalizedTasksExcluded.length > 0 - ? { tasks: { excludedFolders: normalizedTasksExcluded } } + ...(normalizedTasksExcluded.length > 0 || normalizedTasksIncluded.length > 0 + ? { + tasks: { + ...(normalizedTasksExcluded.length > 0 + ? { excludedFolders: normalizedTasksExcluded } + : {}), + ...(normalizedTasksIncluded.length > 0 + ? { includedFolders: normalizedTasksIncluded } + : {}) + } + } : {}), ...(normalizedTypstPreambles ? { typstPreambles: normalizedTypstPreambles } : {}), ...(normalizedHarper ? { harper: normalizedHarper } : {}), diff --git a/packages/app-core/src/lib/vault-settings-conflict.ts b/packages/app-core/src/lib/vault-settings-conflict.ts index e10d898c..12735768 100644 --- a/packages/app-core/src/lib/vault-settings-conflict.ts +++ b/packages/app-core/src/lib/vault-settings-conflict.ts @@ -91,6 +91,7 @@ const FIELD_LABELS: Record = { systemFolderLabels: "Label", unifiedSidebar: "Unified sidebar", excludedFolders: "Folders left out of Tasks", + includedFolders: "Task folders", words: "Words", ignoredLints: "Ignored suggestions", }; diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index ad27d225..fd0d82f0 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -39,6 +39,27 @@ export const CLOUD_ANNOUNCEMENT = { } as const export const WHATS_NEW: readonly WhatsNewEntry[] = [ + { + version: '2.72.0', + highlights: [ + { + title: 'Task folders', + body: 'Choose where tasks live: right-click a folder and pick Use as Task Folder, and only the notes in your Task folders feed Tasks, the boards and the calendars. Every other checkbox stays a plain checkbox. Both folder lists are under Settings → Tasks → Checklists.' + }, + { + title: 'Cloud errors you can act on', + body: 'When Cloud has a problem, it now says what happened, whether it was on Cloud’s side or yours, and what to do. The technical line is under Details in Settings → Cloud, with Copy details and Report an Issue.' + }, + { + title: 'Back after months away', + body: 'A device that last synced longer ago than Cloud keeps its change log now catches up by itself, keeping the edits you made while it was away and asking about anything changed on both sides.' + }, + { + title: 'Pickers and callouts', + body: 'The sidebar’s icon and color pickers move with the arrow keys, and h j k l with Vim on. A math or code block you open inside a callout keeps every line in the callout.' + } + ] + }, { version: '2.71.0', highlights: [ diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index fb5bde00..7b4b0ac8 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -20,7 +20,12 @@ import { type HarperLintConfig, type HarperVaultState } from '@shared/harper-settings' -import { normalizeTasksExcludedFolder } from '@shared/tasks-excluded-folders' +import { + normalizeTasksExcludedFolder, + normalizeTasksIncludedFolder, + rewriteTasksFoldersForRename +} from '@shared/tasks-excluded-folders' +import { toggleTasksFolder } from './lib/tasks-folder-lists' import { cloudSyncPathKey } from '@zennotes/shared-domain/cloud-sync' import { useCloudSyncStatusStore } from './lib/cloud-auto-sync' import type { @@ -3355,6 +3360,12 @@ interface Store { * (`vaultRelativeFolderPath` output, e.g. `inbox/Books`). */ toggleTasksExcludedFolder: (relDir: string) => Promise + /** + * Toggle a folder on the vault's Task folders list (`tasks.includedFolders`, + * #962) and rescan. Once the list names folders, only the notes inside them + * feed Tasks; the excluded list still applies within them. + */ + toggleTasksIncludedFolder: (relDir: string) => Promise /** * Point the vault at a different Typst preamble folder (#562). Empty or * invalid input restores the default (`typst`). Notes in the folder are @@ -5087,6 +5098,13 @@ export const useStore = create((set, get) => { folder, oldSubpath, newSubpath + ), + // Main rewrote the Tasks folder lists on disk; mirror that here, or + // the next toggle would write the old paths back. + tasks: rewriteTasksFoldersForRename( + s.vaultSettings.tasks, + oldPrefix.replace(/\/$/, ''), + newPrefix.replace(/\/$/, '') ) } })) @@ -6073,18 +6091,24 @@ export const useStore = create((set, get) => { const cleaned = normalizeTasksExcludedFolder(relDir) if (!cleaned) return const settings = get().vaultSettings - const current = settings.tasks?.excludedFolders ?? [] - const next = current.includes(cleaned) - ? current.filter((f) => f !== cleaned) - : [...current, cleaned] await get().setVaultSettings({ ...settings, - tasks: next.length > 0 ? { excludedFolders: next } : undefined + tasks: toggleTasksFolder(settings.tasks, 'excludedFolders', cleaned) }) // Rescan immediately: the Tasks view, boards, and calendars should reflect // the exclusion without waiting for the next natural refresh. await get().refreshTasks() }, + toggleTasksIncludedFolder: async (relDir) => { + const cleaned = normalizeTasksIncludedFolder(relDir) + if (!cleaned) return + const settings = get().vaultSettings + await get().setVaultSettings({ + ...settings, + tasks: toggleTasksFolder(settings.tasks, 'includedFolders', cleaned) + }) + await get().refreshTasks() + }, setTypstPreambleFolder: async (folder) => { const settings = get().vaultSettings const cleaned = normalizeTypstPreambleFolder(folder) diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 9d1311e9..3917ad87 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.71.1", + "version": "2.72.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index bb8c7cb3..b109fc9d 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -608,11 +608,14 @@ export interface VaultSettings { /** * Tasks-system settings (#458). `excludedFolders` lists vault-relative * directory paths (as they exist on disk) whose notes never feed the Tasks - * surfaces, on any runtime. Absent means nothing is excluded. An object - * rather than a bare list so the deferred per-vault `mode: all | tagged` - * can land beside it without another migration. + * surfaces, on any runtime. Absent means nothing is excluded. + * `includedFolders`, the Task folders (#962), narrows Tasks to the notes + * inside the folders it lists; absent or empty means the whole vault, and + * `excludedFolders` still applies inside them. An object rather than a bare + * list so the deferred per-vault `mode: all | tagged` can land beside it + * without another migration. */ - tasks?: { excludedFolders?: string[] } + tasks?: { excludedFolders?: string[]; includedFolders?: string[] } /** * Typst preamble settings (#486, #562). `folder` names the directory whose * notes are Typst preambles, matched at any depth; absent means `typst`. diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 2146969c..a4fb8833 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.71.1", + "version": "2.72.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/cloud-auth-flow.test.ts b/packages/shared-domain/src/cloud-auth-flow.test.ts index 8a83ab5f..085badd9 100644 --- a/packages/shared-domain/src/cloud-auth-flow.test.ts +++ b/packages/shared-domain/src/cloud-auth-flow.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { CloudAccount } from '@zennotes/bridge-contract/cloud-sync' +import { CLOUD_SIGN_IN_TROUBLE_MESSAGE, describeCloudError } from './cloud-error-copy' import { CloudAuthFlow, createCloudAuthChallenge, @@ -111,7 +112,7 @@ describe('CloudAuthFlow', () => { await flow.connect('https://zennotes.org') await expect(flow.complete({ code: 'OneTimeCode', state: 'other-state' })).rejects.toThrow( - 'invalid or has expired' + 'This sign-in has expired.' ) storage.pending = { base_url: 'https://zennotes.org', @@ -120,7 +121,7 @@ describe('CloudAuthFlow', () => { expires_at: '2026-08-10T11:59:59.000Z' } await expect(flow.complete({ code: 'OneTimeCode', state: 'fixed-state' })).rejects.toThrow( - 'invalid or has expired' + 'This sign-in has expired.' ) expect(exchange).not.toHaveBeenCalled() expect(storage.pending).toBeNull() @@ -140,11 +141,21 @@ describe('CloudAuthFlow', () => { device: { id: 'device-1', name: 'Ada’s iPhone', platform: 'ios' } }) await expect(flow.complete({ code: 'OneTimeCode', state: 'fixed-state' })).rejects.toThrow( - 'invalid sign-in response' + 'unexpected answer while signing you in' ) expect(storage.credential).toBeNull() }) + it('words a busy or broken service during the exchange as connect again', async () => { + const { exchange, flow } = setup() + await flow.connect('https://zennotes.org') + exchange.mockRejectedValueOnce( + Object.assign(new Error('Server Error'), { name: 'CloudServiceRequestError', status: 503, code: null }) + ) + const failure = await flow.complete({ code: 'OneTimeCode', state: 'fixed-state' }).catch((error: unknown) => error) + expect(describeCloudError(failure).message).toBe(CLOUD_SIGN_IN_TROUBLE_MESSAGE) + }) + it('clears pending state if the external browser cannot open and clears credentials on logout', async () => { const { flow, openExternal, storage } = setup() openExternal.mockRejectedValueOnce(new Error('unavailable')) diff --git a/packages/shared-domain/src/cloud-auth-flow.ts b/packages/shared-domain/src/cloud-auth-flow.ts index 4a1a2625..63299057 100644 --- a/packages/shared-domain/src/cloud-auth-flow.ts +++ b/packages/shared-domain/src/cloud-auth-flow.ts @@ -3,6 +3,11 @@ import type { CloudAccountConnectResult, CloudAccountStatus } from '@zennotes/bridge-contract/cloud-sync' +import { + CLOUD_SIGN_IN_EXPIRED_MESSAGE, + CLOUD_SIGN_IN_UNEXPECTED_MESSAGE, + cloudSignInExchangeError +} from './cloud-error-copy' const AUTH_LIFETIME_MS = 5 * 60 * 1000 const REVOKE_TIMEOUT_MS = 5_000 @@ -139,19 +144,24 @@ export class CloudAuthFlow { async complete(callback: CloudAuthCallback): Promise { const pending = await this.pending() if (!pending || !safeEquals(callback.state, pending.state)) { - throw new Error('This ZenNotes Cloud sign-in request is invalid or has expired.') + throw new Error(CLOUD_SIGN_IN_EXPIRED_MESSAGE) } - const response = await this.dependencies.exchange(pending.base_url, { - code: callback.code, - state: callback.state, - code_verifier: pending.code_verifier, - device_name: this.dependencies.deviceName, - platform: this.dependencies.platform, - app_version: this.dependencies.appVersion - }) + let response: unknown + try { + response = await this.dependencies.exchange(pending.base_url, { + code: callback.code, + state: callback.state, + code_verifier: pending.code_verifier, + device_name: this.dependencies.deviceName, + platform: this.dependencies.platform, + app_version: this.dependencies.appVersion + }) + } catch (error) { + throw cloudSignInExchangeError(error) + } if (!isExchangeResponse(response, this.dependencies.platform)) { - throw new Error('ZenNotes Cloud returned an invalid sign-in response.') + throw new Error(CLOUD_SIGN_IN_UNEXPECTED_MESSAGE) } const account: CloudAccount = { diff --git a/packages/shared-domain/src/cloud-e2ee-account.ts b/packages/shared-domain/src/cloud-e2ee-account.ts index a3d0987c..18dab781 100644 --- a/packages/shared-domain/src/cloud-e2ee-account.ts +++ b/packages/shared-domain/src/cloud-e2ee-account.ts @@ -81,8 +81,8 @@ async function openAccountKey(keyset: CloudEncryptionKeyset, secret: CloudEncryp throw new CloudE2eeError( 'wrong-key', 'passphrase' in secret - ? 'That passphrase does not unlock your encryption.' - : 'That recovery key does not unlock your encryption.' + ? "That passphrase didn't work. Check for typos and capital letters, or use your recovery key instead." + : "That recovery key didn't work. Check each word, or use your passphrase instead." ) } throw error @@ -107,7 +107,7 @@ export async function prepareCloudEncryptionSetup( const problem = cloudPassphraseProblem(passphrase) if (problem) throw new CloudE2eeError('invalid-key', problem) if (await api.getKeyset()) { - throw new CloudE2eeError('invalid-key', 'End-to-end encryption is already set up for this account.') + throw new CloudE2eeError('invalid-key', 'End-to-end encryption is already set up for this account. Unlock it with your passphrase instead.') } const accountKey = generateCloudE2eeKey() const recoveryKey = generateCloudE2eeKey() diff --git a/packages/shared-domain/src/cloud-e2ee-vault.test.ts b/packages/shared-domain/src/cloud-e2ee-vault.test.ts index dbd868eb..b952c6fe 100644 --- a/packages/shared-domain/src/cloud-e2ee-vault.test.ts +++ b/packages/shared-domain/src/cloud-e2ee-vault.test.ts @@ -209,7 +209,7 @@ describe('account setup and unlock', () => { expect(third.value?.key).toBe(first.value?.key) await expect(unlockCloudEncryption(api, new MemoryKeyStore(), { passphrase: 'not the passphrase at all' })) - .rejects.toThrow('That passphrase does not unlock your encryption.') + .rejects.toThrow("That passphrase didn't work.") }, 30_000) it('changes the passphrase and replaces the recovery key without touching the account key', async () => { @@ -220,7 +220,7 @@ describe('account setup and unlock', () => { const accountKey = store.value!.key await changeCloudEncryptionPassphrase(api, store, { passphrase: PASSPHRASE }, 'velvet canyon morning drum') - await expect(unlockCloudEncryption(api, new MemoryKeyStore(), { passphrase: PASSPHRASE })).rejects.toThrow(/does not unlock/) + await expect(unlockCloudEncryption(api, new MemoryKeyStore(), { passphrase: PASSPHRASE })).rejects.toThrow(/didn't work/) const afterChange = new MemoryKeyStore() await unlockCloudEncryption(api, afterChange, { passphrase: 'velvet canyon morning drum' }) expect(afterChange.value?.key).toBe(accountKey) @@ -228,7 +228,7 @@ describe('account setup and unlock', () => { const replacement = await prepareNewCloudRecoveryKey(api, store, { recoveryWords: setup.recoveryWords.join(' ') }) await replacement.finish() await expect(unlockCloudEncryption(api, new MemoryKeyStore(), { recoveryWords: setup.recoveryWords.join(' ') })) - .rejects.toThrow(/does not unlock/) + .rejects.toThrow(/didn't work/) const viaNewWords = new MemoryKeyStore() await unlockCloudEncryption(api, viaNewWords, { recoveryWords: replacement.recoveryWords.join(' ') }) expect(viaNewWords.value?.key).toBe(accountKey) diff --git a/packages/shared-domain/src/cloud-e2ee-vault.ts b/packages/shared-domain/src/cloud-e2ee-vault.ts index 90e6b826..9ac9eae2 100644 --- a/packages/shared-domain/src/cloud-e2ee-vault.ts +++ b/packages/shared-domain/src/cloud-e2ee-vault.ts @@ -60,7 +60,7 @@ const CIPHERTEXT_MEDIA_TYPE = 'application/octet-stream' export class CloudE2eeLockedError extends Error { readonly code = 'E2EE_LOCKED' - constructor(message = 'This vault is end-to-end encrypted. Unlock encryption on this device to sync it.') { + constructor(message = 'This vault is end-to-end encrypted, so this device needs your passphrase or recovery key before it can sync. Unlock encryption in Settings → Cloud.') { super(message) this.name = 'CloudE2eeLockedError' } diff --git a/packages/shared-domain/src/cloud-e2ee.test.ts b/packages/shared-domain/src/cloud-e2ee.test.ts index 55f468af..ff4eacab 100644 --- a/packages/shared-domain/src/cloud-e2ee.test.ts +++ b/packages/shared-domain/src/cloud-e2ee.test.ts @@ -193,7 +193,7 @@ describe('recovery words', () => { it('rejects the wrong count, unknown words, and a failed checksum', () => { const words = recoveryKeyToWords(new Uint8Array(32).fill(0x7f)) expect(() => recoveryKeyFromWords(words.slice(1).join(' '))).toThrow(/24 words/) - expect(() => recoveryKeyFromWords([...words.slice(1), 'qqqq'].join(' '))).toThrow(/not a recovery key word/) + expect(() => recoveryKeyFromWords([...words.slice(1), 'qqqq'].join(' '))).toThrow(/isn't a recovery key word/) const swapped = [...words] ;[swapped[0], swapped[1]] = [swapped[1]!, swapped[0]!] expect(() => recoveryKeyFromWords(swapped.join(' '))).toThrow(/mistyped word/) diff --git a/packages/shared-domain/src/cloud-e2ee.ts b/packages/shared-domain/src/cloud-e2ee.ts index e6b886c6..c54b22b9 100644 --- a/packages/shared-domain/src/cloud-e2ee.ts +++ b/packages/shared-domain/src/cloud-e2ee.ts @@ -666,13 +666,13 @@ export function recoveryKeyFromWords(input: string): Bytes { .split(/[^a-z0-9]+/) .filter((token) => token !== '' && !/^\d+$/.test(token)) if (tokens.length !== 24) { - throw new CloudE2eeError('invalid-recovery-key', `A recovery key has 24 words; this has ${tokens.length}.`) + throw new CloudE2eeError('invalid-recovery-key', `A recovery key has 24 words, and this has ${tokens.length}. Check for a missing or extra word.`) } const words = tokens.map((token, index) => { if (knownWords.has(token)) return token const word = token.length >= 4 ? wordByPrefix.get(token.slice(0, 4)) : undefined if (!word) { - throw new CloudE2eeError('invalid-recovery-key', `Word ${index + 1} ("${token}") is not a recovery key word.`) + throw new CloudE2eeError('invalid-recovery-key', `Word ${index + 1}, “${token}”, isn't a recovery key word. Check its spelling.`) } return word }) diff --git a/packages/shared-domain/src/cloud-error-copy.test.ts b/packages/shared-domain/src/cloud-error-copy.test.ts new file mode 100644 index 00000000..d6417e11 --- /dev/null +++ b/packages/shared-domain/src/cloud-error-copy.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest' +import { + CLOUD_OFFLINE_MESSAGE, + CLOUD_SIGN_IN_BUSY_MESSAGE, + CLOUD_SIGN_IN_OFFLINE_MESSAGE, + CLOUD_SIGN_IN_TROUBLE_MESSAGE, + CLOUD_SIGNED_OUT_MESSAGE, + CLOUD_TIMEOUT_MESSAGE, + CLOUD_UNEXPECTED_ANSWER_MESSAGE, + CLOUD_UNEXPECTED_MESSAGE, + CloudIssueError, + cloudProtocolError, + cloudSignInExchangeError, + describeCloudError, + encodeCloudErrorMessage +} from './cloud-error-copy' +import { CLOUD_VAULT_REMOVED_MESSAGE } from './cloud-vault-availability' +import { CloudE2eeError } from './cloud-e2ee' + +/** The shape both the desktop client and the phone shells throw. */ +function serviceError( + message: string, + status: number, + code: string | null, + details: Record | null = null +): Error { + return Object.assign(new Error(message), { name: 'CloudServiceRequestError', status, code, details }) +} + +/** What a renderer receives for a main-process rejection. */ +function overIpc(channel: string, error: Error): Error { + return new Error(`Error invoking remote method '${channel}': ${error.name}: ${error.message}`) +} + +describe('describeCloudError', () => { + it('words a network failure and keeps the runtime text as the detail', () => { + const error = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND zennotes.org'), { code: 'ENOTFOUND' }) }) + expect(describeCloudError(error)).toEqual({ + message: CLOUD_OFFLINE_MESSAGE, + detail: 'TypeError · ENOTFOUND · fetch failed', + kind: 'offline' + }) + }) + + it('recognises "fetch failed" that only survived IPC as text', () => { + const copy = describeCloudError(overIpc('cloud-vault:sync', new TypeError('fetch failed'))) + expect(copy.message).toBe(CLOUD_OFFLINE_MESSAGE) + expect(copy.kind).toBe('offline') + expect(copy.detail).toBe('TypeError · fetch failed') + }) + + it('words the phones\' fetch failures the same way', () => { + expect(describeCloudError(new TypeError('Load failed')).message).toBe(CLOUD_OFFLINE_MESSAGE) + expect(describeCloudError(new TypeError('Failed to fetch')).message).toBe(CLOUD_OFFLINE_MESSAGE) + }) + + it('words a timeout', () => { + const timeout = new DOMException('The operation was aborted due to timeout', 'TimeoutError') + expect(describeCloudError(timeout)).toMatchObject({ message: CLOUD_TIMEOUT_MESSAGE, kind: 'offline' }) + expect(describeCloudError(overIpc('cloud-vault:sync', timeout)).message).toBe(CLOUD_TIMEOUT_MESSAGE) + }) + + it('treats a refused sign-in as signed out, whatever the server says', () => { + for (const error of [ + serviceError('Authentication is required.', 401, 'UNAUTHENTICATED'), + serviceError('This access token is not bound to an active device.', 403, 'ACTIVE_DEVICE_REQUIRED'), + serviceError('Invalid ability provided.', 403, null) + ]) { + expect(describeCloudError(error)).toMatchObject({ message: CLOUD_SIGNED_OUT_MESSAGE, kind: 'signed-out' }) + } + }) + + it('signs out only when Cloud\'s API refuses the sign-in, never a storage host or a proxy', () => { + // A presigned upload the storage host answered 401: an upload that could not finish. + const storage = describeCloudError( + serviceError('ZenNotes Cloud object upload failed (401).', 401, 'DIRECT_UPLOAD_FAILED') + ) + expect(storage.kind).toBe('other') + expect(storage.message).toMatch(/^A large file couldn't be uploaded to ZenNotes Cloud\./) + // A proxy or portal page: not Cloud's JSON, so not Cloud's answer. + for (const text of ['ZenNotes Cloud returned an invalid JSON response.', 'ZenNotes Cloud request failed (401).', 'ZenNotes Cloud returned an unexpected response.']) { + const proxy = describeCloudError(serviceError(text, 401, null)) + expect(proxy.kind).toBe('other') + expect(proxy.message).toMatch(/public Wi-Fi/) + } + // Cloud's own refusals still sign out, with or without the code. + expect(describeCloudError(serviceError('Unauthenticated.', 401, null)).kind).toBe('signed-out') + expect(describeCloudError(serviceError('Authentication is required.', 401, 'UNAUTHENTICATED')).kind).toBe('signed-out') + expect(describeCloudError(serviceError('Removed.', 403, 'ACTIVE_DEVICE_REQUIRED')).kind).toBe('signed-out') + expect(describeCloudError(serviceError('Out of date.', 403, 'UNAUTHENTICATED')).kind).toBe('signed-out') + }) + + it('never signs out over a refusal from file storage', () => { + const storage = Object.assign(new Error('Cloud object download failed.'), { status: 401 }) + expect(describeCloudError(storage).kind).not.toBe('signed-out') + }) + + it('words the 411 upload refusal and keeps status and code for support', () => { + const copy = describeCloudError( + serviceError('ZenNotes Cloud object upload failed (411).', 411, 'DIRECT_UPLOAD_FAILED') + ) + expect(copy.message).toMatch(/^A large file couldn't be uploaded to ZenNotes Cloud\./) + expect(copy.message).not.toMatch(/411/) + expect(copy.detail).toBe('HTTP 411 · DIRECT_UPLOAD_FAILED · ZenNotes Cloud object upload failed (411).') + }) + + it('names the feature a plan lacks', () => { + const copy = describeCloudError( + serviceError('Your account does not include this feature.', 403, 'FEATURE_NOT_ENTITLED', { feature: 'sync' }) + ) + expect(copy.message).toBe( + "Sync isn't part of your current plan, or your plan has ended. Choose a plan under Billing on zennotes.org to keep syncing. Your notes stay on this device." + ) + }) + + it('tells the vault limit, the publishing limit and storage apart', () => { + expect(describeCloudError(serviceError('Your account has reached its synced vault limit.', 409, 'CAPACITY_EXCEEDED')).message) + .toMatch(/limit on Cloud vaults/) + expect(describeCloudError(serviceError('This publish operation would exceed account capacity.', 409, 'CAPACITY_EXCEEDED')).message) + .toMatch(/publishing limit/) + expect(describeCloudError(serviceError('Over capacity.', 409, 'CAPACITY_EXCEEDED')).message) + .toMatch(/^Your ZenNotes Cloud plan is full/) + }) + + it('reads which limit from its dimension, and the service\'s current sentences too', () => { + expect(describeCloudError(serviceError('Your plan\'s limit on Cloud vaults is reached.', 409, 'CAPACITY_EXCEEDED', { dimension: 'sync_active_vaults' })).message) + .toMatch(/limit on Cloud vaults/) + expect(describeCloudError(serviceError('An attachment in this note is larger than the publishing size limit.', 409, 'FILE_SIZE_LIMIT_EXCEEDED', { dimension: 'publish_max_asset_bytes' })).message) + .toMatch(/^An attachment in this note/) + const restoreLimit = 'Restoring this backup would go over your plan\'s Cloud storage.' + expect(describeCloudError(serviceError(restoreLimit, 409, 'CAPACITY_EXCEEDED')).message).toBe(restoreLimit) + const restoreFile = 'A file in this backup is larger than your plan\'s file-size limit, so the backup can\'t be restored to Cloud.' + expect(describeCloudError(serviceError(restoreFile, 409, 'FILE_SIZE_LIMIT_EXCEEDED')).message).toBe(restoreFile) + }) + + it('treats a 403 asking to sign in again as signed out', () => { + expect(describeCloudError(serviceError("This device's sign-in to ZenNotes Cloud is out of date.", 403, 'UNAUTHENTICATED')).kind) + .toBe('signed-out') + }) + + it('words server trouble, rate limits and maintenance without a code', () => { + expect(describeCloudError(serviceError('Server Error', 500, null)).message).toMatch(/having trouble right now/) + expect(describeCloudError(serviceError('Service Unavailable', 503, null)).message).toMatch(/having trouble right now/) + expect(describeCloudError(serviceError('Too many requests. Try again shortly.', 429, 'RATE_LIMITED')).message) + .toMatch(/slow down/) + expect(describeCloudError(serviceError('ZenNotes Cloud request failed (500).', 500, null)).message) + .not.toMatch(/500/) + expect(describeCloudError(serviceError('Server Error', 500, null)).detail).toBe('HTTP 500 · Server Error') + }) + + it('words a page that is not Cloud\'s answer', () => { + expect(describeCloudError(serviceError('ZenNotes Cloud returned an invalid JSON response.', 200, null)).message) + .toMatch(/public Wi-Fi/) + }) + + it('keeps developer wording from the server in the detail only', () => { + const copy = describeCloudError( + serviceError('This cursor is older than the retained change log. Rebuild this vault from its manifest.', 410, 'CURSOR_EXPIRED') + ) + // Sync catches up from the manifest by itself, so the words reassure + // instead of sending the person to Report an Issue. + expect(copy.message).toMatch(/catching up/) + expect(copy.message).not.toMatch(/Report an Issue|manifest/) + expect(copy.detail).toContain('CURSOR_EXPIRED') + expect(copy.detail).toContain('manifest') + }) + + it('lets the server\'s own plain restore sentences through', () => { + const plain = 'The vault changed before this note could be restored. Sync and try again.' + expect(describeCloudError(serviceError(plain, 409, 'VAULT_CHANGED'))).toEqual({ message: plain, detail: null, kind: 'other' }) + }) + + it('words a vault name that is too long', () => { + expect(describeCloudError( + serviceError('The name field must not be greater than 255 characters.', 422, 'VALIDATION_FAILED') + ).message).toBe('Use a Cloud vault name of 255 characters or fewer.') + }) + + it('words internal checks and keeps their text for support', () => { + const copy = describeCloudError(cloudProtocolError('Expected sync sequence 41, received 43')) + expect(copy).toEqual({ + message: CLOUD_UNEXPECTED_ANSWER_MESSAGE, + detail: 'protocol · Expected sync sequence 41, received 43', + kind: 'other' + }) + }) + + it('names the file a damaged download belongs to', () => { + const copy = describeCloudError( + new CloudIssueError('download-damaged', 'Cloud revision for Notes/Plan.md has an invalid hash. Retry sync.', 'Notes/Plan.md') + ) + expect(copy.message).toMatch(/^“Plan\.md” arrived damaged/) + }) + + it('words damaged encrypted data and a newer encryption format', () => { + expect(describeCloudError(new CloudE2eeError('tampered', 'The encrypted data was changed or is incomplete.')).message) + .toMatch(/couldn't open an encrypted file/) + expect(describeCloudError(new CloudE2eeError('unsupported', 'Vault key version 3 needs a newer ZenNotes.')).message) + .toMatch(/newer version of ZenNotes/) + }) + + it('keeps encryption sentences already written for people', () => { + const wrong = new CloudE2eeError('wrong-key', "That passphrase didn't work.") + expect(describeCloudError(wrong)).toEqual({ message: "That passphrase didn't work.", detail: null, kind: 'other' }) + }) + + it('words local disk errors', () => { + const full = Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC' }) + expect(describeCloudError(full).message).toMatch(/out of storage space/) + const denied = Object.assign(new Error("EACCES: permission denied, open '/v/a.md'"), { code: 'EACCES' }) + expect(describeCloudError(denied).message).toMatch(/isn't allowed to change a file/) + }) + + it('words a program error instead of showing it', () => { + const copy = describeCloudError(new TypeError("Cannot read properties of undefined (reading 'path')")) + expect(copy.message).toBe(CLOUD_UNEXPECTED_MESSAGE) + expect(copy.detail).toContain('Cannot read properties') + }) + + it('passes sentences written for people through untouched', () => { + expect(describeCloudError(new Error(CLOUD_VAULT_REMOVED_MESSAGE))).toEqual({ + message: CLOUD_VAULT_REMOVED_MESSAGE, + detail: null, + kind: 'other' + }) + expect(describeCloudError(overIpc('cloud-vault-link:set', new Error('That ZenNotes Cloud vault is not available to this account.'))).message) + .toBe('That ZenNotes Cloud vault is not available to this account.') + }) + + it('uses the fallback only for an error with no text', () => { + expect(describeCloudError(new Error(''), 'Sync didn\'t finish.').message).toBe('Sync didn\'t finish.') + expect(describeCloudError(undefined, 'Sync didn\'t finish.').message).toBe('Sync didn\'t finish.') + }) + + it('round-trips a worded error through the desktop IPC boundary', () => { + const inMain = describeCloudError(serviceError('Authentication is required.', 401, 'UNAUTHENTICATED')) + const rejected = new Error(`Error invoking remote method 'cloud-vault:sync': Error: ${encodeCloudErrorMessage(inMain)}`) + expect(describeCloudError(rejected)).toEqual(inMain) + // Wording twice changes nothing. + expect(describeCloudError(new Error(encodeCloudErrorMessage(inMain)))).toEqual(inMain) + }) + + it('words a failed sign-in exchange as connect again, not as a sync that retries', () => { + const words = (error: unknown) => describeCloudError(cloudSignInExchangeError(error)) + expect(words(serviceError('Too many requests.', 429, 'RATE_LIMITED')).message).toBe(CLOUD_SIGN_IN_BUSY_MESSAGE) + const trouble = words(serviceError('Server Error', 503, null)) + expect(trouble).toEqual({ message: CLOUD_SIGN_IN_TROUBLE_MESSAGE, detail: 'HTTP 503 · Server Error', kind: 'other' }) + expect(words(new TypeError('fetch failed')).message).toBe(CLOUD_SIGN_IN_OFFLINE_MESSAGE) + for (const message of [CLOUD_SIGN_IN_BUSY_MESSAGE, CLOUD_SIGN_IN_TROUBLE_MESSAGE, CLOUD_SIGN_IN_OFFLINE_MESSAGE]) { + expect(message).not.toMatch(/sync/i) + } + // A refused code is the host's to word. + const refused = serviceError('Invalid or expired code.', 422, null) + expect(cloudSignInExchangeError(refused)).toBe(refused) + }) + + it('reads an error sent as bare text', () => { + expect(describeCloudError('fetch failed').message).toBe(CLOUD_OFFLINE_MESSAGE) + }) +}) diff --git a/packages/shared-domain/src/cloud-error-copy.ts b/packages/shared-domain/src/cloud-error-copy.ts new file mode 100644 index 00000000..5c01ec28 --- /dev/null +++ b/packages/shared-domain/src/cloud-error-copy.ts @@ -0,0 +1,537 @@ +/** + * Plain words for a Cloud failure, for people without a technical background + * (Discord feedback: say what went wrong, why, and what to do). + * + * Every Cloud error a person can read goes through describeCloudError: the + * desktop main process runs it before an error crosses into a window, because + * IPC keeps only the text and the HTTP status and code are what tell most + * failures apart; app-core runs it again, which leaves an already worded + * message as it is and words the phones' errors, whose hosts run in-process + * and keep status and code on the error. The original text, status and code + * stay reachable as `detail` for a support conversation, never as the message. + */ +import { CLOUD_VAULT_SUPERSEDED_MESSAGE } from './cloud-vault-availability' + +export type CloudErrorKind = + /** Cloud could not be reached, or took too long: the status row says so. */ + | 'offline' + /** Cloud no longer accepts this device's sign-in: the device signs out. */ + | 'signed-out' + | 'other' + +export interface CloudErrorCopy { + /** What happened, why where it is known, and what to do. */ + message: string + /** The technical line for Details and Copy details; null when the message is all there is. */ + detail: string | null + kind: CloudErrorKind +} + +/** What a CloudIssueError stands for. Each is worded once, below. */ +export type CloudIssue = + /** Cloud and this device disagree about the sync protocol. */ + | 'protocol' + /** A download stopped part way or its link ran out. */ + | 'download-incomplete' + /** Downloaded bytes did not match what Cloud promised. */ + | 'download-damaged' + /** Cloud changed under a first sync that was still reading it. */ + | 'remote-changed' + /** Two local files differ only by letter case or Unicode form. */ + | 'path-collision' + +/** + * A failure whose own message is for developers and logs. The message stays + * as it was (tests and logs match it); people read the issue's sentence. + */ +export class CloudIssueError extends Error { + constructor( + readonly issue: CloudIssue, + message: string, + /** The vault file it concerns, when the sentence can name it. */ + readonly path: string | null = null + ) { + super(message) + this.name = 'CloudIssueError' + } +} + +/** Shorthand for the most common issue: an internal sync check failed. */ +export function cloudProtocolError(message: string): CloudIssueError { + return new CloudIssueError('protocol', message) +} + +/** + * Where the technical line starts in a message the desktop main process + * worded. Readable on its own, in case a surface shows the whole text. + */ +export const CLOUD_ERROR_DETAILS_MARKER = '\n\nDetails: ' + +const REPORT = 'If it keeps happening, copy the details and choose Report an Issue.' + +export const CLOUD_OFFLINE_MESSAGE = + "ZenNotes can't reach ZenNotes Cloud. Check that this device is online, then try again. Your notes are safe on this device, and sync picks up where it left off." +export const CLOUD_TIMEOUT_MESSAGE = + 'ZenNotes Cloud took too long to answer, which usually means a slow or unstable connection. Try again in a moment. Nothing was lost.' +export const CLOUD_SIGNED_OUT_MESSAGE = + 'This device was signed out of ZenNotes Cloud. That happens when it is removed from your account on the website, or when its sign-in is more than a year old. Choose Connect ZenNotes Cloud to sign in again. Your notes stay on this device.' +export const CLOUD_UNEXPECTED_ANSWER_MESSAGE = + `ZenNotes Cloud sent something ZenNotes didn't expect, so ZenNotes stopped. Your notes on this device are safe. Try again. ${REPORT}` +export const CLOUD_UNEXPECTED_MESSAGE = + `Something unexpected went wrong. Your notes on this device are safe. Try again. ${REPORT}` + +const NOT_CLOUD_ANSWER = + 'ZenNotes got an unexpected answer instead of ZenNotes Cloud. This happens on public Wi-Fi that wants you to sign in first, or while Cloud is being updated. Try again in a few minutes.' +const SERVER_TROUBLE = + 'ZenNotes Cloud is having trouble right now. This is on our side, not yours. Your notes are safe on this device, and sync will try again in a few minutes.' +const SLOW_DOWN = + 'ZenNotes Cloud asked this device to slow down after a burst of requests. Sync continues on its own in a few minutes.' +const STOPPED = 'ZenNotes stopped this before it finished, for example because you signed out. Try again.' +const NOT_ALLOWED =`ZenNotes Cloud didn't allow that on this account. ${REPORT}` +const NOT_FOUND = 'ZenNotes Cloud couldn\'t find that. It may have been deleted on another device.' + +const UPLOAD_REFUSED = + `A large file couldn't be uploaded to ZenNotes Cloud. It is safe on this device, and sync will try it again. If this keeps happening, update ZenNotes, then copy the details and choose Report an Issue.` +const UPLOAD_CHANGED = + 'A file changed while ZenNotes was uploading it. The newer version goes up on the next sync.' +const UPLOAD_UNEXPECTED = + 'ZenNotes Cloud gave an unexpected answer while a file was uploading, so ZenNotes stopped to keep your data safe. Sync will try again.' +const UPLOAD_BROKEN = + "A large file didn't arrive in ZenNotes Cloud in one piece. It is safe on this device, and sync will upload it again." +const DOWNLOAD_INCOMPLETE = + "A file from ZenNotes Cloud didn't finish downloading. Nothing on this device was changed. Sync will try again." +const REMOTE_CHANGED = + 'Files in Cloud changed while this device was syncing. Choose Sync now to try again.' +// A current sync never shows this: the coordinator rebuilds from the manifest +// on this answer, and its next pull starts at a cursor Cloud still keeps. The +// words are for anything else that meets the code. +const CATCHING_UP = + "This device was away longer than ZenNotes Cloud keeps its list of changes, so sync is catching up by comparing every file with Cloud. Edits made on this device are kept. Choose Sync now if it hasn't finished." +const CONTENT_UNAVAILABLE = + `A file stored in ZenNotes Cloud is missing or damaged, so this device can't download it. Your copy on this device is unchanged. Copy the details and choose Report an Issue.` + +const RESOURCE_BUSY = 'ZenNotes Cloud is busy with this vault, usually making a backup. Try again in a minute.' +const SUSPENDED = + "This ZenNotes Cloud account is suspended, so it can't sync. Your notes stay on this device. Choose Report an Issue if you need help." +// Only in invite-only mode, where buying a plan does not open Cloud. +const ACCESS_NOT_ENABLED = + "ZenNotes Cloud is invite-only right now, and this account hasn't been invited yet. Your notes stay on this device." +const VAULT_LIMIT = + "Your plan's limit on Cloud vaults is reached. Delete a Cloud vault you no longer need, or change your plan under Billing on zennotes.org." +const CAPACITY_FULL = + 'Your ZenNotes Cloud plan is full, so some changes are waiting on this device. Delete files you no longer need, or change your plan under Billing on zennotes.org.' +const FILE_TOO_LARGE = + "A file is larger than your plan's Cloud file-size limit, so it stays on this device. Remove it or make it smaller to finish syncing." +const PUBLISH_FULL = + "Publishing this note would go over your plan's publishing limit. Unpublish notes you no longer need, or change your plan under Billing on zennotes.org." +const PUBLISH_FILE_TOO_LARGE = + 'An attachment in this note is larger than the publishing size limit. Remove it or make it smaller, then publish again.' +const VAULT_NAME_TOO_LONG = 'Use a Cloud vault name of 255 characters or fewer.' +export const CLOUD_VAULT_NAME_REQUIRED_MESSAGE = 'Give the new Cloud vault a name.' +export const CLOUD_NOT_SIGNED_IN_MESSAGE = + "This device isn't signed in to ZenNotes Cloud. Choose Connect ZenNotes Cloud in Settings → Cloud." +export const CLOUD_NOT_LINKED_MESSAGE = + "This vault isn't linked to a Cloud vault yet. Choose one under This vault in Settings → Cloud." +export const CLOUD_OTHER_ACCOUNT_MESSAGE = + 'This vault syncs with a different ZenNotes Cloud account than the one signed in. Sign in with that account, or unlink this device and choose a vault from this one.' +export const CLOUD_SIGN_IN_EXPIRED_MESSAGE = + 'This sign-in has expired. Sign-in requests last five minutes. Choose Connect ZenNotes Cloud to start again.' +export const CLOUD_SIGN_IN_REFUSED_MESSAGE = + "This sign-in didn't go through, usually because it took longer than five minutes or the approval page was used twice. Choose Connect ZenNotes Cloud to start again." +export const CLOUD_SIGN_IN_UNEXPECTED_MESSAGE = + 'ZenNotes Cloud sent an unexpected answer while signing you in. Try again in a few minutes.' +export const CLOUD_SIGN_IN_OFFLINE_MESSAGE = + "ZenNotes couldn't reach ZenNotes Cloud to finish signing in. Check that this device is online, then choose Connect ZenNotes Cloud again." +export const CLOUD_SIGN_IN_BUSY_MESSAGE = + 'ZenNotes Cloud asked for a pause after several sign-in attempts. Wait a minute, then choose Connect ZenNotes Cloud again.' +export const CLOUD_SIGN_IN_TROUBLE_MESSAGE = + "ZenNotes Cloud is having trouble right now, so sign-in didn't finish. This is on our side, not yours. Choose Connect ZenNotes Cloud again in a few minutes." + +const KEYSET_CHANGED = 'Your encryption settings just changed on another device. Try again.' +const KEYSET_NOT_FOUND = + "End-to-end encryption isn't set up for this account. Set it up in Settings → Cloud first." +const KEYSET_EXISTS = + 'End-to-end encryption is already set up for this account. Unlock it with your passphrase instead.' +const E2EE_NOT_AVAILABLE = "End-to-end encryption isn't available for this account yet." +const E2EE_DAMAGED = + `ZenNotes couldn't open an encrypted file from Cloud. It may be damaged or incomplete, so your copy on this device was left as it is. Sync again. ${REPORT}` +const E2EE_NEWER = + 'This vault was encrypted by a newer version of ZenNotes. Update ZenNotes on this device to open it.' +const E2EE_CLIENT_REQUIRED = 'This vault is end-to-end encrypted. Update ZenNotes on this device to sync it.' + +const BACKUP_UNAVAILABLE = + "This backup can't be downloaded right now. Try Save archive again in a few minutes; if it keeps failing, choose another backup." +const BACKUP_NOT_RESTORABLE = "This backup isn't ready yet, or it has expired. Choose another backup." + +const DISK_FULL = + "This device is out of storage space, so sync couldn't save a file. Free up some space, then choose Sync now." +const NO_PERMISSION = + "ZenNotes isn't allowed to change a file in this vault on this device. Check the vault folder's permissions, then choose Sync now." +const BAD_FILE_NAME = + 'That file name can\'t be used on every device. Avoid the characters : * ? " < > | and try another name.' +const LOCAL_FILE_FAILED = + "ZenNotes couldn't read or write a file in this vault on this device. Choose Sync now to try again." + +const FEATURE_NAMES: Record = { sync: 'Sync', backup: 'Backups', publish: 'Publishing' } + +/** Errors whose name says the code itself broke, not Cloud or the network. */ +const PROGRAM_ERROR_NAMES = new Set([ + 'TypeError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'EvalError', + 'URIError', + 'AggregateError', + 'OperationError', + 'DataError', + 'InvalidAccessError', + 'NotSupportedError' +]) + +const NETWORK_SYSTEM_CODES = new Set([ + 'ENOTFOUND', + 'EAI_AGAIN', + 'ECONNREFUSED', + 'ECONNRESET', + 'ECONNABORTED', + 'ETIMEDOUT', + 'ENETUNREACH', + 'ENETDOWN', + 'EHOSTUNREACH', + 'EHOSTDOWN', + 'EPIPE', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_SOCKET', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_BODY_TIMEOUT' +]) + +/** Fetch failures as each runtime words them: undici, Chromium, WebKit, Firefox. */ +const NETWORK_TEXT = + /^fetch failed\b|^Failed to fetch\b|^Load failed$|NetworkError when attempting to fetch|network connection was lost|Internet connection appears to be offline|net::ERR_|^getaddrinfo\b|socket hang up|Could not connect to the server/i +const TIMEOUT_TEXT = /operation was aborted due to timeout|request timed out|signal timed out/i + +/** Server codes whose own sentence is already written for people. */ +const PLAIN_SERVER_CODES = new Set(['VAULT_CHANGED', 'BACKUP_QUOTA_EXCEEDED', 'RESTORE_ACCESS_REVOKED', 'GONE']) + +interface RawCloudError { + name: string + text: string + status: number | null + code: string | null + details: Record | null + causeCode: string | null + issue: CloudIssue | null + path: string | null +} + +/** + * The words for `error`. `fallback` is the sentence for an error that carries + * no text at all; anything else is worded from what it says about itself. + */ +export function describeCloudError(error: unknown, fallback = CLOUD_UNEXPECTED_MESSAGE): CloudErrorCopy { + const raw = rawCloudError(error) + + // Already worded, by the desktop main process before IPC. + const marker = raw.text.indexOf(CLOUD_ERROR_DETAILS_MARKER) + if (marker >= 0) { + const message = raw.text.slice(0, marker).trim() + const detail = raw.text.slice(marker + CLOUD_ERROR_DETAILS_MARKER.length).trim() + return { message, detail: detail || null, kind: cloudErrorKindOf(message) } + } + + const worded = (message: string, kind: CloudErrorKind = 'other'): CloudErrorCopy => ({ + message, + detail: technicalLine(raw), + kind + }) + + if (raw.issue) return worded(issueMessage(raw.issue, raw.path)) + if (raw.name === 'AbortError') return worded(STOPPED) + if (isTimeout(raw)) return worded(CLOUD_TIMEOUT_MESSAGE, 'offline') + if (isNetworkFailure(raw)) return worded(CLOUD_OFFLINE_MESSAGE, 'offline') + + // Status and code are what word a service error. One that crossed IPC + // unworded kept neither, and its own sentence is all there is to show. + if (raw.name === 'CloudServiceRequestError' && (raw.status !== null || raw.code !== null)) { + const service = serviceMessage(raw) + if (service === null) return { message: raw.text || fallback, detail: null, kind: 'other' } + return worded(service.message, service.kind) + } + + if (raw.name === 'CloudE2eeError') { + const code = raw.code ?? '' + if (['tampered', 'malformed', 'invalid-metadata', 'wrong-purpose'].includes(code)) return worded(E2EE_DAMAGED) + if (['unsupported', 'wrong-key-version', 'invalid-kdf'].includes(code)) return worded(E2EE_NEWER) + return { message: raw.text || fallback, detail: null, kind: 'other' } + } + + if (raw.name === 'CloudSyncLocalEditConflictError' && raw.path) { + return worded( + `Sync stopped because “${fileName(raw.path)}” is a folder on this device but a file in Cloud. Rename one of them, then choose Sync now.` + ) + } + + const system = systemErrorMessage(raw) + if (system) return worded(system) + // normalizeCloudSyncPath's refusal, met when a name is typed for a kept copy. + if (raw.text.startsWith('Invalid sync path:')) return worded(BAD_FILE_NAME) + + if (PROGRAM_ERROR_NAMES.has(raw.name)) return worded(CLOUD_UNEXPECTED_MESSAGE) + if (raw.text === '') return { message: fallback, detail: null, kind: 'other' } + + // Everything else was thrown with a sentence meant for people. + return { message: raw.text, detail: null, kind: cloudErrorKindOf(raw.text) } +} + +/** + * A failed sign-in exchange, in sign-in words. Sign-in never retries by itself + * and the request it answered is spent, so the sync sentences ("sync will try + * again in a few minutes") would promise something that does not happen. + * Unreachable, busy and broken service become "connect again"; anything else + * (a refused code, an unexpected answer) is returned unchanged for the host to + * word. + */ +export function cloudSignInExchangeError(error: unknown): unknown { + const raw = rawCloudError(error) + const copy = describeCloudError(error) + const message = + copy.kind === 'offline' + ? CLOUD_SIGN_IN_OFFLINE_MESSAGE + : raw.status === 429 || raw.code === 'RATE_LIMITED' + ? CLOUD_SIGN_IN_BUSY_MESSAGE + : raw.status !== null && raw.status >= 500 + ? CLOUD_SIGN_IN_TROUBLE_MESSAGE + : null + if (message === null) return error + return new Error(encodeCloudErrorMessage({ message, detail: copy.detail, kind: 'other' })) +} + +/** + * The message the desktop main process throws across IPC: the sentence, then + * the technical line after a marker app-core splits off again. + */ +export function encodeCloudErrorMessage(copy: CloudErrorCopy): string { + return copy.detail ? `${copy.message}${CLOUD_ERROR_DETAILS_MARKER}${copy.detail}` : copy.message +} + +/** The kind of an already worded message, which is all IPC carries across. */ +export function cloudErrorKindOf(message: string): CloudErrorKind { + if (message === CLOUD_OFFLINE_MESSAGE || message === CLOUD_TIMEOUT_MESSAGE) return 'offline' + if (message === CLOUD_SIGNED_OUT_MESSAGE) return 'signed-out' + return 'other' +} + +function issueMessage(issue: CloudIssue, path: string | null): string { + switch (issue) { + case 'download-incomplete': + return DOWNLOAD_INCOMPLETE + case 'download-damaged': + return `${path ? `“${fileName(path)}”` : 'A file from ZenNotes Cloud'} arrived damaged, so ZenNotes didn't use it. Your copy on this device is unchanged. Choose Sync now to download it again.` + case 'remote-changed': + return REMOTE_CHANGED + case 'path-collision': + return `Two files in this vault have names that differ only in capital letters${path ? `, such as “${path}”` : ''}. Some devices can't tell them apart. Rename one of them, then choose Sync now.` + case 'protocol': + return CLOUD_UNEXPECTED_ANSWER_MESSAGE + } +} + +function serviceMessage(raw: RawCloudError): { message: string; kind: CloudErrorKind } | null { + const status = raw.status ?? 0 + const code = raw.code + const dimension = typeof raw.details?.dimension === 'string' ? raw.details.dimension : '' + const other = (message: string) => ({ message, kind: 'other' as const }) + + if (isRefusedSignIn(raw)) return { message: CLOUD_SIGNED_OUT_MESSAGE, kind: 'signed-out' } + + switch (code) { + case 'RATE_LIMITED': + return other(SLOW_DOWN) + case 'FEATURE_NOT_ENTITLED': + return other(featureMessage(raw.details?.feature)) + case 'ACCOUNT_SUSPENDED': + return other(SUSPENDED) + case 'CLOUD_ACCESS_NOT_ENABLED': + return other(ACCESS_NOT_ENABLED) + // The limit's dimension says which one; the sentences are the fallback for a + // service that sent none, in its old words and its current ones. + case 'SYNC_QUOTA_EXCEEDED': + case 'QUOTA_EXCEEDED': + case 'CAPACITY_EXCEEDED': + if (dimension === 'sync_active_vaults' || raw.details?.limit === 'max_vaults' || /vault limit|limit on Cloud vaults/i.test(raw.text)) { + return other(VAULT_LIMIT) + } + if (dimension.startsWith('publish') || /publish/i.test(raw.text)) return other(PUBLISH_FULL) + if (/^Restoring this (note|backup)/i.test(raw.text)) return null + return other(CAPACITY_FULL) + case 'FILE_SIZE_LIMIT_EXCEEDED': + if (dimension === 'publish_max_asset_bytes' || /published asset|publishing size limit/i.test(raw.text)) { + return other(PUBLISH_FILE_TOO_LARGE) + } + if (/^(This backup note|A file in this backup)/i.test(raw.text)) return null + return other(FILE_TOO_LARGE) + case 'RESOURCE_BUSY': + return other(RESOURCE_BUSY) + case 'VALIDATION_FAILED': + if (/^The name field must not be greater than/i.test(raw.text)) return other(VAULT_NAME_TOO_LONG) + if (/^The name field is required/i.test(raw.text)) return other(CLOUD_VAULT_NAME_REQUIRED_MESSAGE) + return other(CLOUD_UNEXPECTED_ANSWER_MESSAGE) + case 'SYNC_CONTENT_UNAVAILABLE': + return other(CONTENT_UNAVAILABLE) + case 'CURSOR_EXPIRED': + return other(CATCHING_UP) + case 'UPLOAD_EXPIRED': + case 'UPLOAD_NOT_RESTARTABLE': + case 'UPLOAD_NOT_FOUND': + case 'UPLOAD_SIZE_MISMATCH': + case 'UPLOAD_HASH_MISMATCH': + case 'UPLOAD_NOT_COMPLETABLE': + case 'UPLOAD_FINALIZATION_FAILED': + return other(UPLOAD_BROKEN) + case 'DIRECT_UPLOAD_FAILED': + return other(UPLOAD_REFUSED) + case 'DIRECT_UPLOAD_SIZE_MISMATCH': + return other(UPLOAD_CHANGED) + case 'INVALID_DIRECT_UPLOAD_RESPONSE': + case 'INSECURE_DIRECT_UPLOAD_URL': + return other(UPLOAD_UNEXPECTED) + case 'KEYSET_REVISION_CONFLICT': + return other(KEYSET_CHANGED) + case 'KEYSET_NOT_FOUND': + return other(KEYSET_NOT_FOUND) + case 'KEYSET_EXISTS': + return other(KEYSET_EXISTS) + case 'E2EE_NOT_AVAILABLE': + return other(E2EE_NOT_AVAILABLE) + case 'E2EE_CLIENT_REQUIRED': + return other(E2EE_CLIENT_REQUIRED) + case 'VAULT_SUPERSEDED': + return other(CLOUD_VAULT_SUPERSEDED_MESSAGE) + case 'BACKUP_NOT_AVAILABLE': + case 'BACKUP_ARCHIVE_MISSING': + case 'BACKUP_ARCHIVE_UNAVAILABLE': + return other(BACKUP_UNAVAILABLE) + case 'BACKUP_NOT_RESTORABLE': + return other(BACKUP_NOT_RESTORABLE) + } + + // The rest of the restore-a-note answers are written for people. + if (code !== null && (PLAIN_SERVER_CODES.has(code) || code === 'PATH_CONFLICT') && raw.text) return null + + if (status === 429) return other(SLOW_DOWN) + if (status >= 500) return other(SERVER_TROUBLE) + if (!isCloudApiAnswer(raw)) return other(NOT_CLOUD_ANSWER) + if (status === 404) return other(NOT_FOUND) + if (status === 403) return other(NOT_ALLOWED) + return other(CLOUD_UNEXPECTED_ANSWER_MESSAGE) +} + +/** + * Cloud's API refusing this device's sign-in. Only that answer signs the + * device out, and signing out revokes the device on the service: a storage + * host's 401 on a presigned upload (DIRECT_UPLOAD_FAILED) or a proxy's 401 + * page once revoked a device whose sign-in was fine. + */ +function isRefusedSignIn(raw: RawCloudError): boolean { + if (raw.code === 'UNAUTHENTICATED' || raw.code === 'ACTIVE_DEVICE_REQUIRED') return true + if (raw.code !== null || !isCloudApiAnswer(raw)) return false + return raw.status === 401 || (raw.status === 403 && raw.text === 'Invalid ability provided.') +} + +/** The clients' own words for a body that was not Cloud's JSON: a proxy or + * captive-portal page, an empty body, or a cut-off one. */ +const NOT_CLOUD_BODY = + /^ZenNotes Cloud request failed \(\d+\)\.$|invalid JSON response|returned an unexpected response/i + +function isCloudApiAnswer(raw: RawCloudError): boolean { + return raw.text !== '' && !NOT_CLOUD_BODY.test(raw.text) +} + +function featureMessage(feature: unknown): string { + const name = typeof feature === 'string' ? FEATURE_NAMES[feature] : undefined + if (!name) { + return "That isn't part of your current plan, or your plan has ended. Choose a plan under Billing on zennotes.org. Your notes stay on this device." + } + const keep = feature === 'sync' ? 'to keep syncing' : feature === 'backup' ? 'to keep making backups' : 'to publish notes' + return `${name} isn't part of your current plan, or your plan has ended. Choose a plan under Billing on zennotes.org ${keep}. Your notes stay on this device.` +} + +function systemErrorMessage(raw: RawCloudError): string | null { + const code = raw.code + if (code === null || !/^E[A-Z]+$/.test(code)) return null + if (code === 'ENOSPC' || code === 'EDQUOT') return DISK_FULL + if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return NO_PERMISSION + return LOCAL_FILE_FAILED +} + +function isTimeout(raw: RawCloudError): boolean { + return raw.name === 'TimeoutError' || TIMEOUT_TEXT.test(raw.text) || + raw.causeCode === 'UND_ERR_CONNECT_TIMEOUT' || raw.causeCode === 'UND_ERR_HEADERS_TIMEOUT' +} + +function isNetworkFailure(raw: RawCloudError): boolean { + return NETWORK_TEXT.test(raw.text) || + (raw.code !== null && NETWORK_SYSTEM_CODES.has(raw.code)) || + (raw.causeCode !== null && NETWORK_SYSTEM_CODES.has(raw.causeCode)) +} + +/** "HTTP 411 · DIRECT_UPLOAD_FAILED · ZenNotes Cloud object upload failed (411)." */ +function technicalLine(raw: RawCloudError): string { + const parts: string[] = [] + if (raw.status !== null) parts.push(`HTTP ${raw.status}`) + if (raw.code !== null) parts.push(raw.code) + else if (raw.issue !== null) parts.push(raw.issue) + // The status already says it came from the service; the class name is noise. + else if (raw.name !== 'Error' && raw.name !== 'CloudServiceRequestError') parts.push(raw.name) + if (raw.causeCode !== null && raw.causeCode !== raw.code) parts.push(raw.causeCode) + parts.push(raw.text || '(no message)') + return parts.join(' · ') +} + +function rawCloudError(error: unknown): RawCloudError { + const value = (typeof error === 'object' && error !== null ? error : {}) as { + name?: unknown + message?: unknown + status?: unknown + code?: unknown + details?: unknown + cause?: unknown + issue?: unknown + path?: unknown + relPath?: unknown + } + const message = + typeof error === 'string' ? error : typeof value.message === 'string' ? value.message : error == null ? '' : String(error) + const { name: prefixName, text } = stripIpcPrefix(message) + const ownName = typeof value.name === 'string' && value.name ? value.name : 'Error' + const cause = value.cause as { code?: unknown } | null | undefined + return { + // A name that only survived IPC as a prefix ("TypeError: fetch failed"). + name: ownName === 'Error' && prefixName ? prefixName : ownName, + text, + status: typeof value.status === 'number' && Number.isInteger(value.status) && value.status > 0 ? value.status : null, + code: typeof value.code === 'string' && value.code ? value.code : null, + details: + value.details && typeof value.details === 'object' && !Array.isArray(value.details) + ? (value.details as Record) + : null, + causeCode: cause && typeof cause === 'object' && typeof cause.code === 'string' ? cause.code : null, + issue: ownName === 'CloudIssueError' && typeof value.issue === 'string' ? (value.issue as CloudIssue) : null, + path: typeof value.path === 'string' ? value.path : typeof value.relPath === 'string' ? value.relPath : null + } +} + +/** Electron wraps a main-process rejection as "Error invoking remote method + * 'x': : "; the name is kept, since it tells some apart. */ +function stripIpcPrefix(message: string): { name: string | null; text: string } { + const unwrapped = message.replace(/^Error invoking remote method '[^']*':\s*/, '') + const named = /^([A-Z][A-Za-z0-9]*)?Error:\s*/.exec(unwrapped) + if (!named) return { name: null, text: unwrapped.trim() } + return { name: `${named[1] ?? ''}Error`, text: unwrapped.slice(named[0].length).trim() } +} + +function fileName(path: string): string { + return path.slice(path.lastIndexOf('/') + 1) +} diff --git a/packages/shared-domain/src/cloud-sync-api.ts b/packages/shared-domain/src/cloud-sync-api.ts index 03765a3d..7292b83d 100644 --- a/packages/shared-domain/src/cloud-sync-api.ts +++ b/packages/shared-domain/src/cloud-sync-api.ts @@ -35,6 +35,7 @@ import type { } from '@zennotes/bridge-contract/cloud-sync' import type { CloudSyncRateLimitScope } from './cloud-sync-rate-limit' import type { CloudKeysetApi } from './cloud-e2ee-account' +import { cloudProtocolError } from './cloud-error-copy' import { CLOUD_E2EE_HEADER, CLOUD_E2EE_HEADER_VALUE, @@ -192,7 +193,7 @@ export class CloudSyncApiClient { } if (!await this.capabilityPromise) { this.referencesActive = false - throw new Error('This Cloud server needs content-reference support before this streaming host can sync.') + throw cloudProtocolError('This Cloud server needs content-reference support before this streaming host can sync.') } this.referencesActive = true return true @@ -205,14 +206,14 @@ export class CloudSyncApiClient { private contentQuery(options: CloudSyncContentRequestOptions = {}, metadataOnly = false): Record { if (!this.referencesActive) { - if (options.contentMode || (this.options.contentReferences && !metadataOnly)) throw new Error('Negotiate Cloud content references before using the reference protocol.') + if (options.contentMode || (this.options.contentReferences && !metadataOnly)) throw cloudProtocolError('Negotiate Cloud content references before using the reference protocol.') return {} } const inline = options.maxInlineBytes ?? this.options.maxInlineBytes ?? 262_144 const response = options.maxResponseBytes ?? Math.max(65_536, Math.min(this.bootstrapContentPageBytes ?? 16_777_216, 33_554_432)) if (!Number.isInteger(inline) || inline < 0 || inline > 1_048_576 || !Number.isInteger(response) || response < 65_536 || response > 33_554_432) { - throw new Error('Invalid Cloud content response budgets.') + throw cloudProtocolError('Invalid Cloud content response budgets.') } return { content_mode: 'references', max_inline_bytes: inline, max_response_bytes: response } } @@ -231,7 +232,7 @@ export class CloudSyncApiClient { accountKey: Uint8Array, accountKeyVersion: number ): Promise { - if (!this.options.endToEndEncryption) throw new Error('This app cannot create encrypted vaults.') + if (!this.options.endToEndEncryption) throw new Error("This version of ZenNotes can't use end-to-end encryption. Update ZenNotes to the latest version.") return this.send({ method: 'POST', path: '/api/v1/vaults', @@ -379,7 +380,7 @@ export class CloudSyncApiClient { } async download(vaultId: string, itemId: string, revision: number): Promise { - if (!this.referencesActive) throw new Error('Negotiate Cloud content references before downloading revisions.') + if (!this.referencesActive) throw cloudProtocolError('Negotiate Cloud content references before downloading revisions.') return this.send({ method: 'GET', path: `/api/v1/vaults/${encodeURIComponent(vaultId)}/items/${encodeURIComponent(itemId)}/revisions/${encodeURIComponent(String(revision))}/download` }) @@ -411,7 +412,7 @@ export class CloudSyncApiClient { if (cipher && (body.content.encoding !== 'aes-gcm' || !body.meta)) { // The host seals streamed uploads itself; refusing here keeps a host bug // from sending a readable file into an encrypted vault. - throw new Error('Encrypted vaults accept only encrypted uploads.') + throw cloudProtocolError('Encrypted vaults accept only encrypted uploads.') } return this.send({ method: 'POST', @@ -571,7 +572,7 @@ function publishedNoteBody(input: CloudPublishRequest): { payload: string } | Fo if ('upload_id' in input) { // The files are already in storage; the publish names them and their upload. const { upload_id, asset_refs, appearance, ...note } = input - if (appearance?.logo) throw new Error('A staged publish cannot replace the logo.') + if (appearance?.logo) throw cloudProtocolError('A staged publish cannot replace the logo.') return { payload: JSON.stringify({ ...note, diff --git a/packages/shared-domain/src/cloud-sync-bootstrap.test.ts b/packages/shared-domain/src/cloud-sync-bootstrap.test.ts index 7ad45e52..b8dddda5 100644 --- a/packages/shared-domain/src/cloud-sync-bootstrap.test.ts +++ b/packages/shared-domain/src/cloud-sync-bootstrap.test.ts @@ -976,7 +976,7 @@ describe('legacy bootstrap conflict decisions with metadata manifests', () => { it('rejects a changed conflict before downloading its revision', async () => { const f = conflictFixture() f.inventory[0].sha256 = content('new cloud').sha256 - await expect(f.coordinator.getBootstrapConflict(f.conflict)).rejects.toThrow('conflict changed') + await expect(f.coordinator.getBootstrapConflict(f.conflict)).rejects.toThrow('changed again on another device') expect(f.revision).not.toHaveBeenCalled() expect(f.repository.resolveBootstrapConflict).not.toHaveBeenCalled() expect(f.state()).toBeNull() @@ -991,7 +991,7 @@ describe('legacy bootstrap conflict decisions with metadata manifests', () => { conflict: f.conflict, choice: 'cloud' }) - ).rejects.toThrow('conflict changed') + ).rejects.toThrow('changed again on another device') expect(f.revision).not.toHaveBeenCalled() expect(f.locals.get(edited.path)).toEqual(edited) expect(f.state()).toBeNull() @@ -1111,3 +1111,43 @@ describe('legacy bootstrap conflict decisions with metadata manifests', () => { } ) }) + +describe('rebuilding from a metadata manifest after CURSOR_EXPIRED', () => { + it('downloads only what changed in Cloud and pushes what changed here', async () => { + const cloud = [file('a.md', 'a1'), file('b.md', 'b1'), file('c.md', 'c1')] + const f = fixture(cloud) + await f.coordinator.sync() + f.events.length = 0 + f.manifest.mockClear() + f.revision.mockClear() + + // Away longer than Cloud keeps its change log: b.md changed in Cloud and + // a.md changed here. c.md is the same on both sides. + cloud[1] = file('b.md', 'b2') + f.inventory[1] = { ...f.inventory[1], revision: 7, sha256: cloud[1].content.sha256, byte_length: cloud[1].content.byte_length } + f.locals.set('a.md', file('a.md', 'a, edited here')) + f.manifest.mockImplementation(async (_vaultId, options) => { + if (options.includeContent !== false) throw new Error('HTTP 413 SYNC_RESPONSE_TOO_LARGE') + return { data: structuredClone(f.inventory), cursor: 20, next_page: null } + }) + vi.mocked(f.remote.changes).mockImplementation(async (_vaultId, after) => { + if (after < 15) { + throw Object.assign(new Error('This cursor is older than the retained change log.'), { + name: 'CloudServiceRequestError', status: 410, code: 'CURSOR_EXPIRED', details: { pruned_through: 15, cursor: 20 } + }) + } + return { data: [], cursor: after, has_more: false } + }) + + const result = await f.coordinator.sync() + + expect(result).toMatchObject({ pulled: 1, pushed: 1, pendingConflicts: [] }) + expect(f.manifest).toHaveBeenCalledExactlyOnceWith('vault', { includeContent: false, page: 1, perPage: 250 }) + expect(f.events).toEqual(['revision:item-1', 'apply:item-1']) + expect(f.locals.get('b.md')).toEqual(cloud[1]) + expect(f.mutate.mock.calls.at(-1)?.[1].mutations).toEqual([ + expect.objectContaining({ type: 'upsert', item_id: 'item-0', base_revision: 1, content: content('a, edited here') }) + ]) + expect(f.state()?.cursor).toBe(20) + }) +}) diff --git a/packages/shared-domain/src/cloud-sync-content.ts b/packages/shared-domain/src/cloud-sync-content.ts index dff5a1ae..e55c326b 100644 --- a/packages/shared-domain/src/cloud-sync-content.ts +++ b/packages/shared-domain/src/cloud-sync-content.ts @@ -5,6 +5,7 @@ import type { CloudSyncDownloadInstruction, CloudSyncEncryptedContent } from '@zennotes/bridge-contract/cloud-sync' +import { cloudProtocolError } from './cloud-error-copy' /** Host-only token. Its native handle is never serialized or accepted from HTTP. */ export interface CloudSyncStagedFile { @@ -203,7 +204,7 @@ export function registerCloudSyncStagedFile( discard: () => Promise, preview?: CloudSyncContent ): CloudSyncStagedFile { - if (typeof discard !== 'function') throw new Error('Cloud staging requires a cleanup callback.') + if (typeof discard !== 'function') throw cloudProtocolError('Cloud staging requires a cleanup callback.') const file: CloudSyncStagedFile = Object.freeze({ source: 'file', reference: Object.freeze(validateCloudSyncContentReference(reference)), @@ -218,7 +219,7 @@ export function cloudSyncStagedHandle( expected?: CloudSyncContentReference ): Handle { const stored = stagedFiles.get(file) - if (!stored) throw new Error('Untrusted or released Cloud staging handle.') + if (!stored) throw cloudProtocolError('Untrusted or released Cloud staging handle.') if (expected) validateCloudSyncContentReference(expected) if ( expected && @@ -284,5 +285,5 @@ function onlyKeys(value: Record, keys: string[]): boolean { } function invalid(): never { - throw new Error('Invalid Cloud content reference or download instruction.') + throw cloudProtocolError('Invalid Cloud content reference or download instruction.') } diff --git a/packages/shared-domain/src/cloud-sync-coordinator.test.ts b/packages/shared-domain/src/cloud-sync-coordinator.test.ts index 48563c94..6a4a0976 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.test.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.test.ts @@ -1494,7 +1494,7 @@ describe('CloudSyncCoordinator: converged pending conflicts', () => { ) expect(fs.files.get(path)).toBe(agreedText) expect(server.mutations).toEqual([]) - await expect(resumed.getConflict('plan')).rejects.toThrow('no longer waiting') + await expect(resumed.getConflict('plan')).rejects.toThrow('already settled') fs.files.set(path, `${agreedText}- [ ] Next task.\n`) const next = await resumed.sync() @@ -1694,7 +1694,7 @@ describe('CloudSyncCoordinator: safely merging queued conflicts', () => { content: expect.objectContaining({ data: mergedText }) }) ]) - await expect(coordinator.getConflict('daily')).rejects.toThrow('no longer waiting') + await expect(coordinator.getConflict('daily')).rejects.toThrow('already settled') }) it('rechecks a queued conflict after a new Cloud revision makes its edits non-overlapping', async () => { @@ -2732,3 +2732,324 @@ describe('CloudSyncCoordinator: decisions on queued conflicts', () => { ]) }) }) + +/* ---------- A device away longer than Cloud keeps its change log ---------- */ + +/** Cloud's answer to a cursor older than what is left of its change log. */ +function cursorExpired(): Error { + return Object.assign( + new Error('This cursor is older than the retained change log. Rebuild this vault from its manifest.'), + { name: 'CloudServiceRequestError', status: 410, code: 'CURSOR_EXPIRED', details: { pruned_through: 30, cursor: 40 } } + ) +} + +/** Cloud at cursor 40, with everything through 30 pruned from its log. */ +function prunedServer( + files: Array<[itemId: string, path: string, revision: number, data: string]>, + changes: CloudSyncChange[] = [] +): ReturnType { + const server = remote({ + manifest: { + data: files.map(([item_id, path, revision, data]) => { + const body = realContent(data) + return { + item_id, path, kind: 'text' as const, revision, + sha256: body.sha256, byte_length: body.byte_length, media_type: body.media_type, + content: body + } + }), + cursor: 40, + next_page: null + }, + changes + }) + const follow = server.changes.bind(server) + server.changes = vi.fn(async (vaultId: string, after: number, limit?: number) => { + if (after < 30) throw cursorExpired() + return follow(vaultId, after, limit) + }) + return server +} + +/** What sync last agreed on before this device went away, at cursor 3. */ +function awayState(...items: CloudSyncTrackedItem[]): CloudSyncState { + return { version: 1, vault_id: 'vault-1', cursor: 3, items: Object.fromEntries(items.map((item) => [item.item_id, item])) } +} + +describe('CloudSyncCoordinator: a cursor older than the change log (CURSOR_EXPIRED)', () => { + it('catches up from the manifest when nothing changed on this device', async () => { + const fs = memoryFileSystem({ 'Plan.md': 'v1', 'Old.md': 'old', 'Trip.md': 'same' }) + const states = memoryState(awayState( + tracked('plan', 'Plan.md', 1, 'v1'), + tracked('old', 'Old.md', 1, 'old'), + tracked('trip', 'Trip.md', 1, 'same') + )) + // Edited, deleted, moved and created elsewhere while this device was away. + const server = prunedServer( + [['plan', 'Plan.md', 7, 'v7'], ['trip', 'archive/Trip.md', 2, 'same'], ['new', 'New.md', 1, 'new']], + [upsert(41, 'later', 'Later.md', 'after the trip')] + ) + const coordinator = new CloudSyncCoordinator('vault-1', server, new PortableCloudSyncRepository(fs), states, ids()) + + const result = await coordinator.sync() + + expect(Object.fromEntries(fs.files)).toEqual({ + 'Plan.md': 'v7', + 'archive/Trip.md': 'same', + 'New.md': 'new', + 'Later.md': 'after the trip' + }) + expect(server.mutations).toEqual([]) + expect(result).toMatchObject({ pulled: 5, pushed: 0, localConflicts: [], pendingConflicts: [] }) + // Then it follows the log again from the manifest's cursor. + expect(states.current?.cursor).toBe(41) + expect(states.current?.items).toEqual({ + plan: tracked('plan', 'Plan.md', 7, 'v7'), + trip: tracked('trip', 'archive/Trip.md', 2, 'same'), + new: tracked('new', 'New.md', 1, 'new'), + later: tracked('later', 'Later.md', 41, 'after the trip') + }) + await expect(coordinator.sync()).resolves.toMatchObject({ pulled: 0, pushed: 0 }) + }) + + it('pushes edits made while away and never overwrites one Cloud also changed', async () => { + // Plan.md was edited here, Gone.md deleted here, Fresh.md created here. + // Notes.md was edited on both sides. + const fs = memoryFileSystem({ 'Plan.md': 'mine', 'Notes.md': 'notes, edited here', 'Fresh.md': 'fresh' }) + const states = memoryState(awayState( + tracked('plan', 'Plan.md', 1, 'v1'), + tracked('notes', 'Notes.md', 1, 'notes'), + tracked('gone', 'Gone.md', 1, 'gone') + )) + const server = prunedServer([ + ['plan', 'Plan.md', 1, 'v1'], + ['notes', 'Notes.md', 5, 'notes, edited in Cloud'], + ['gone', 'Gone.md', 1, 'gone'] + ]) + + const result = await new CloudSyncCoordinator( + 'vault-1', server, new PortableCloudSyncRepository(fs), states, ids() + ).sync() + + expect(Object.fromEntries(fs.files)).toEqual({ + 'Plan.md': 'mine', + 'Notes.md': 'notes, edited here', + 'Fresh.md': 'fresh' + }) + expect(server.mutations.flatMap((request) => request.mutations)).toEqual([ + expect.objectContaining({ type: 'delete', item_id: 'gone', base_revision: 1 }), + expect.objectContaining({ type: 'upsert', item_id: 'plan', base_revision: 1, path: 'Plan.md', content: realContent('mine') }), + expect.objectContaining({ type: 'upsert', base_revision: null, path: 'Fresh.md', content: realContent('fresh') }) + ]) + expect(result.localConflicts).toEqual([]) + expect(result.pendingConflicts).toEqual([ + expect.objectContaining({ item_id: 'notes', kind: 'content', path: 'Notes.md' }) + ]) + expect(states.current?.pending_conflicts?.notes).toMatchObject({ + base: { path: 'Notes.md', revision: 1 }, + local: { path: 'Notes.md', content: realContent('notes, edited here') }, + cloud: { path: 'Notes.md', revision: 5, content: realContent('notes, edited in Cloud') } + }) + expect(states.current?.cursor).toBe(40) + }) + + it('keeps a file edited here that Cloud deleted, and asks', async () => { + const fs = memoryFileSystem({ 'Plan.md': 'mine' }) + const states = memoryState(awayState(tracked('plan', 'Plan.md', 2, 'v2'))) + const server = prunedServer([]) + const coordinator = new CloudSyncCoordinator('vault-1', server, new PortableCloudSyncRepository(fs), states, ids()) + + const result = await coordinator.sync() + + expect(fs.files.get('Plan.md')).toBe('mine') + expect(server.mutations).toEqual([]) + expect(result.pendingConflicts).toEqual([ + expect.objectContaining({ item_id: 'plan', kind: 'delete', path: 'Plan.md', cloud_path: null }) + ]) + // The manifest lists only live files, so the revision that deleted it is unknown. + expect(states.current?.pending_conflicts?.plan?.cloud).toMatchObject({ path: null, revision: null }) + expect(states.current?.items).toEqual({}) + + const details = await coordinator.getConflict('plan') + expect(details.cloud).toMatchObject({ deleted: true, revision: null }) + await coordinator.resolveConflict({ + conflict_id: 'plan', + choice: 'local', + expected_local_sha256: details.local.sha256, + expected_cloud_revision: details.cloud.revision + }) + + // With no revision of the deleted file to build on, this device's version + // goes back to Cloud as a new file. + expect(server.mutations.flatMap((request) => request.mutations)).toEqual([ + expect.objectContaining({ type: 'upsert', item_id: 'item-local-1', base_revision: null, path: 'Plan.md', content: realContent('mine') }) + ]) + expect(fs.files.get('Plan.md')).toBe('mine') + expect(states.current?.pending_conflicts).toEqual({}) + expect(states.current?.items['item-local-1']).toMatchObject({ path: 'Plan.md', sha256: realContent('mine').sha256 }) + }) + + it('removes the file when the person accepts a delete it found while away', async () => { + const fs = memoryFileSystem({ 'Plan.md': 'mine' }) + const states = memoryState(awayState(tracked('plan', 'Plan.md', 2, 'v2'))) + const server = prunedServer([]) + const coordinator = new CloudSyncCoordinator('vault-1', server, new PortableCloudSyncRepository(fs), states, ids()) + await coordinator.sync() + + await coordinator.resolveConflict({ + conflict_id: 'plan', + choice: 'cloud', + expected_local_sha256: realContent('mine').sha256, + expected_cloud_revision: null + }) + + expect(fs.files.has('Plan.md')).toBe(false) + expect(server.mutations).toEqual([]) + expect(states.current?.pending_conflicts).toEqual({}) + }) + + it('moves a decision queued before the trip to the Cloud version it now faces', async () => { + const fs = memoryFileSystem({ 'Plan.md': 'mine' }) + const states = memoryState({ + ...awayState(tracked('plan', 'Plan.md', 2, 'v2')), + pending_conflicts: { + plan: { + id: 'plan', + item_id: 'plan', + kind: 'content', + sequence: 3, + base: { path: 'Plan.md', revision: 1, kind: 'text', content: realContent('v1') }, + local: { path: 'Plan.md', revision: null, kind: 'text', content: realContent('mine') }, + cloud: { path: 'Plan.md', revision: 2, kind: 'text', content: realContent('v2') } + } + } + }) + const server = prunedServer([['plan', 'Plan.md', 9, 'v9']]) + + const result = await new CloudSyncCoordinator( + 'vault-1', server, new PortableCloudSyncRepository(fs), states, ids() + ).sync() + + expect(fs.files.get('Plan.md')).toBe('mine') + expect(server.mutations).toEqual([]) + expect(result.pendingConflicts).toHaveLength(1) + expect(states.current?.pending_conflicts?.plan?.cloud).toMatchObject({ revision: 9, content: realContent('v9') }) + expect(states.current?.items.plan).toEqual(tracked('plan', 'Plan.md', 9, 'v9')) + }) + + it('lands a file Cloud re-created at a path whose old file it deleted', async () => { + // Plan.md was deleted elsewhere and a new Plan.md with the same words + // made in its place. The delete must not take the new note with it. + const fs = memoryFileSystem({ 'Plan.md': 'same words' }) + const states = memoryState(awayState(tracked('old-plan', 'Plan.md', 1, 'same words'))) + const server = prunedServer([['new-plan', 'Plan.md', 1, 'same words']]) + + const result = await new CloudSyncCoordinator( + 'vault-1', server, new PortableCloudSyncRepository(fs), states, ids() + ).sync() + + expect(Object.fromEntries(fs.files)).toEqual({ 'Plan.md': 'same words' }) + expect(server.mutations).toEqual([]) + expect(result).toMatchObject({ pulled: 2, pendingConflicts: [] }) + expect(states.current?.items).toEqual({ 'new-plan': tracked('new-plan', 'Plan.md', 1, 'same words') }) + }) + + it('lands a chain of renames in the order that frees each path', async () => { + // Cloud renamed B to C, then A to B. Tracked order puts A first, which + // used to find B still taken and queue a false conflict whose local side + // later matched C by hash; choosing Cloud then removed C. + const fs = memoryFileSystem({ 'A.md': 'x', 'B.md': 'y' }) + const states = memoryState(awayState(tracked('x', 'A.md', 1, 'x'), tracked('y', 'B.md', 1, 'y'))) + const server = prunedServer([['x', 'B.md', 2, 'x'], ['y', 'C.md', 2, 'y']]) + const coordinator = new CloudSyncCoordinator('vault-1', server, new PortableCloudSyncRepository(fs), states, ids()) + + const result = await coordinator.sync() + + expect(Object.fromEntries(fs.files)).toEqual({ 'B.md': 'x', 'C.md': 'y' }) + expect(result).toMatchObject({ pendingConflicts: [], localConflicts: [] }) + expect(server.mutations).toEqual([]) + await expect(coordinator.sync()).resolves.toMatchObject({ pushed: 0, pendingConflicts: [] }) + expect(server.mutations).toEqual([]) + }) + + it('lands a longer chain, a rename with new bytes in the middle, without a conflict', async () => { + const fs = memoryFileSystem({ 'A.md': 'w', 'B.md': 'x', 'C.md': 'y' }) + const states = memoryState(awayState( + tracked('w', 'A.md', 1, 'w'), + tracked('x', 'B.md', 1, 'x'), + tracked('y', 'C.md', 1, 'y') + )) + // C to D first, then B to C with an edit, then A to B. + const server = prunedServer([['w', 'B.md', 2, 'w'], ['x', 'C.md', 3, 'x, edited in Cloud'], ['y', 'D.md', 2, 'y']]) + + const result = await new CloudSyncCoordinator( + 'vault-1', server, new PortableCloudSyncRepository(fs), states, ids() + ).sync() + + expect(Object.fromEntries(fs.files)).toEqual({ 'B.md': 'w', 'C.md': 'x, edited in Cloud', 'D.md': 'y' }) + expect(result).toMatchObject({ pulled: 3, pendingConflicts: [], localConflicts: [] }) + expect(server.mutations).toEqual([]) + }) + + it('queues a ring of renames instead of moving anything, and settles it on Cloud', async () => { + // A to B, B to C, C to A cannot be ordered without a temporary name. + const fs = memoryFileSystem({ 'A.md': 'x', 'B.md': 'y', 'C.md': 'z' }) + const states = memoryState(awayState( + tracked('x', 'A.md', 1, 'x'), + tracked('y', 'B.md', 1, 'y'), + tracked('z', 'C.md', 1, 'z') + )) + const server = prunedServer([['x', 'B.md', 2, 'x'], ['y', 'C.md', 2, 'y'], ['z', 'A.md', 2, 'z']]) + const coordinator = new CloudSyncCoordinator('vault-1', server, new PortableCloudSyncRepository(fs), states, ids()) + + const result = await coordinator.sync() + + expect(Object.fromEntries(fs.files)).toEqual({ 'A.md': 'x', 'B.md': 'y', 'C.md': 'z' }) + expect(result.pendingConflicts.map((conflict) => [conflict.item_id, conflict.kind]).sort()).toEqual([ + ['x', 'move'], ['y', 'move'], ['z', 'move'] + ]) + expect(server.mutations).toEqual([]) + + for (const id of ['x', 'y', 'z']) { + const details = await coordinator.getConflict(id) + await coordinator.resolveConflict({ + conflict_id: id, + choice: 'cloud', + expected_local_sha256: details.local.sha256, + expected_cloud_revision: details.cloud.revision + }) + } + expect(Object.fromEntries(fs.files)).toEqual({ 'A.md': 'z', 'B.md': 'x', 'C.md': 'y' }) + await expect(coordinator.sync()).resolves.toMatchObject({ pushed: 0, pendingConflicts: [] }) + expect(server.mutations).toEqual([]) + }) + + it('keeps what landed when a file fails midway, so a retry does not redo it (#813)', async () => { + const fs = memoryFileSystem({ 'A.md': 'a1', 'B.md': 'b1' }) + const writeText = fs.writeText + fs.writeText = async (path, value) => { + if (path === 'B.md') throw new Error('disk full') + await writeText(path, value) + } + const states = memoryState(awayState(tracked('a', 'A.md', 1, 'a1'), tracked('b', 'B.md', 1, 'b1'))) + const server = prunedServer([['a', 'A.md', 4, 'a4'], ['b', 'B.md', 4, 'b4']]) + const coordinator = new CloudSyncCoordinator('vault-1', server, new PortableCloudSyncRepository(fs), states, ids()) + + await expect(coordinator.sync()).rejects.toThrow('disk full') + expect(fs.files.get('A.md')).toBe('a4') + expect(states.current?.cursor).toBe(3) + expect(states.current?.items.a).toEqual(tracked('a', 'A.md', 4, 'a4')) + expect(states.current?.items.b).toEqual(tracked('b', 'B.md', 1, 'b1')) + + // The person deletes A.md before the retry: it stays deleted. + fs.files.delete('A.md') + fs.writeText = writeText + await coordinator.sync() + + expect(Object.fromEntries(fs.files)).toEqual({ 'B.md': 'b4' }) + expect(server.mutations.flatMap((request) => request.mutations)).toEqual([ + expect.objectContaining({ type: 'delete', item_id: 'a', base_revision: 4 }) + ]) + expect(states.current?.cursor).toBe(40) + }) +}) diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index 9dbfc6c3..44d735c0 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -38,12 +38,22 @@ import { type CloudSyncTrackedItem } from './cloud-sync-engine' import { mergeCloudSyncText, resolveCloudSyncMerge } from './cloud-sync-merge' +import { CloudIssueError, cloudProtocolError } from './cloud-error-copy' import { validateCloudSyncContentReference, validateCloudSyncDownloadInstruction, cloudSyncStagedHandle, releaseCloudSyncStagedFile, throwIfCloudSyncCancelled, type CloudContentDecryptor, type CloudSyncDownloadSource, type CloudSyncStagedFile, type CloudSyncStagedConflict } from './cloud-sync-content' +/** A decision another window or device already made. */ +const CONFLICT_SETTLED = + 'This file was already settled, in another window or on another device. Nothing else is needed.' +/** The other side moved on while the person was choosing. */ +const CONFLICT_CHANGED_AGAIN = + 'The file changed again on another device while you were reviewing it. Choose Sync now to load the newest version, then choose again.' +const CHOICE_UNCONFIRMED = "ZenNotes Cloud didn't confirm your choice, so nothing changed yet. Try again." +const NEEDS_UPDATE = "This version of ZenNotes can't do that yet. Update ZenNotes, then try again." + const MUTATION_BATCH_SIZE = 100 const MANIFEST_PAGE_SIZE = 250 const CHANGE_PAGE_SIZE = 250 @@ -186,7 +196,7 @@ export class CloudSyncCoordinator { await this.ensureReferences() const state = await this.requireState() const stored = state.pending_conflicts?.[conflictId] - if (!stored) throw new Error('This conflict is no longer waiting for a decision.') + if (!stored) throw new Error(CONFLICT_SETTLED) const current = await this.currentLocalSnapshot(stored) const conflict = { ...stored, local: current, cloud: await this.previewSnapshot(stored.cloud) } const merge = conflictTextMerge(conflict) @@ -210,7 +220,7 @@ export class CloudSyncCoordinator { async saveConflictDraft(conflictId: string, draftText: string | null): Promise { const state = await this.requireState() const conflict = state.pending_conflicts?.[conflictId] - if (!conflict) throw new Error('This conflict is no longer waiting for a decision.') + if (!conflict) throw new Error(CONFLICT_SETTLED) const pending = { ...state.pending_conflicts } pending[conflictId] = { ...conflict, @@ -226,7 +236,7 @@ export class CloudSyncCoordinator { } const state = await this.requireState() const stored = state.pending_conflicts?.[resolution.conflict_id] - if (!stored) throw new Error('This conflict is no longer waiting for a decision.') + if (!stored) throw new Error(CONFLICT_SETTLED) const local = await this.currentLocalSnapshot(stored) if ((local.content?.sha256 ?? null) !== resolution.expected_local_sha256) { throw new Error( @@ -234,7 +244,7 @@ export class CloudSyncCoordinator { ) } if (stored.cloud.revision !== resolution.expected_cloud_revision) { - throw new Error('The Cloud version changed. Sync again before choosing a version.') + throw new Error(CONFLICT_CHANGED_AGAIN) } if (resolution.choice === 'cloud' || resolution.choice === 'both') { // These choices consume Cloud bytes locally rather than writing a @@ -291,13 +301,21 @@ export class CloudSyncCoordinator { throw new Error('Choose a filename inside the synced vault.') } + // A delete found by rebuilding from the manifest has no revision to build + // on, so this device's version goes back to Cloud as a new file, and a + // file deleted on both sides leaves nothing to send. + const recreate = stored.cloud.path === null && stored.cloud.revision === null + if (recreate && !chosen) { + await this.saveWithoutConflict(state, stored.id) + return + } const operationId = this.ids.operationId() const mutation: CloudSyncMutation = chosen ? { type: 'upsert', operation_id: operationId, - item_id: stored.item_id, - base_revision: stored.cloud.revision, + item_id: recreate ? this.ids.itemId() : stored.item_id, + base_revision: recreate ? null : stored.cloud.revision, path: resolvedPath ?? '', kind: local.kind, content: chosen @@ -318,12 +336,10 @@ export class CloudSyncCoordinator { const response = await this.remote.mutate(this.vaultId, request) const conflict = response.conflicts.find((item) => item.operation_id === operationId) if (conflict) { - throw new Error( - 'The Cloud version changed while saving. Sync again to review the latest changes.' - ) + throw new Error(CONFLICT_CHANGED_AGAIN) } if (!response.acknowledged.some((item) => item.operation_id === operationId)) { - throw new Error('Cloud did not confirm the resolution. Try again.') + throw new Error(CHOICE_UNCONFIRMED) } // Save to Cloud first. If the request fails, the file the user is looking @@ -391,7 +407,7 @@ export class CloudSyncCoordinator { } if (resolution.choice !== 'local' && !this.repository.resolveBootstrapConflict && !(current.item.content_ref && this.repository.replaceConflictFile)) { - throw new Error('This device cannot resolve Cloud file conflicts yet.') + throw new Error(NEEDS_UPDATE) } if (resolution.choice === 'local' || resolution.choice === 'merged') { @@ -419,19 +435,17 @@ export class CloudSyncCoordinator { }) const conflict = response.conflicts.find((item) => item.operation_id === operationId) if (conflict) { - throw new Error( - `Cloud changed while resolving this file (${conflict.code}). Sync again to compare the latest versions.` - ) + throw new Error(CONFLICT_CHANGED_AGAIN) } if (!response.acknowledged.some((item) => item.operation_id === operationId)) { - throw new Error('Cloud did not confirm the conflict resolution. Try again.') + throw new Error(CHOICE_UNCONFIRMED) } } // A merged choice must reach Cloud before replacing the local version, // so a failed request leaves the original conflict safe to review again. if (resolution.choice !== 'local') { if (current.item.content_ref) { - if (!this.repository.replaceConflictFile) throw new Error('This host cannot save a merged conflict.') + if (!this.repository.replaceConflictFile) throw new Error(NEEDS_UPDATE) await this.repository.replaceConflictFile({ path: current.item.path, expectedSha256: current.local.content.sha256, content: await textContent(resolution.merged_text!, current.local.content.media_type) }) @@ -470,7 +484,7 @@ export class CloudSyncCoordinator { let state = bootstrap.state let pulled = bootstrap.pulled const localConflicts = [...bootstrap.localConflicts] - const initialPull = await this.pullChanges(state) + const initialPull = await this.pullOrRebuild(state) state = initialPull.state pulled += initialPull.pulled localConflicts.push(...initialPull.localConflicts) @@ -555,7 +569,7 @@ export class CloudSyncCoordinator { } if (mutationCursor > state.cursor) { - const finalPull = await this.pullChanges(state, acknowledgedSequences) + const finalPull = await this.pullOrRebuild(state, acknowledgedSequences) state = finalPull.state pulled += finalPull.pulled localConflicts.push(...finalPull.localConflicts) @@ -585,14 +599,185 @@ export class CloudSyncCoordinator { } } + /** + * Cloud keeps its change log for a limited time (90 days by default) and + * answers 410 CURSOR_EXPIRED to a cursor older than what is left. Every run + * used to fail there, and relinking reused the same state, so a device away + * that long could not sync again. It catches up from the manifest instead, + * then follows the log again from the manifest's cursor. + */ + private async pullOrRebuild( + state: CloudSyncState, + acknowledgedSequences?: ReadonlySet + ): Promise { + try { + return await this.pullChanges(state, acknowledgedSequences) + } catch (error) { + if (!isCloudCursorExpired(error)) throw error + } + // pullChanges saved the changes that landed before the log ran out. + const rebuilt = await this.rebuildFromManifest((await this.states.load(this.vaultId)) ?? state) + const pull = await this.pullChanges(rebuilt.state, acknowledgedSequences) + return { + state: pull.state, + pulled: rebuilt.pulled + pull.pulled, + localConflicts: [...rebuilt.localConflicts, ...pull.localConflicts] + } + } + + /** + * Replay what the pruned log would have said, item by item, from the + * difference between the tracked state and the manifest. A first sync + * knows nothing about this device's files; this one still knows what sync + * last agreed on for each, so an offline edit is pushed as an edit, an + * offline delete as a delete, and a Cloud change lands only on a file this + * device can vouch for. Anything else is queued as a conflict, exactly as + * the change feed would have queued it. + * + * The order is the only thing lost: deletes go first so the paths they free + * are free, then renames and edits, each rename after whatever is leaving + * its destination, then files new to this device. Only a ring of renames + * through each other's paths comes out as conflicts, never as an overwrite. + */ + private async rebuildFromManifest(previous: CloudSyncState): Promise { + const manifest = await this.stableManifest(!this.referencesEnabled && !this.remote.revision) + const localByPath = new Map((await this.repository.scan()).map((item) => [cloudSyncPathKey(item.path), item])) + const holds = (item: CloudSyncManifestItem) => + localByPath.get(cloudSyncPathKey(item.path))?.content.sha256 === item.sha256 + const listed = new Map(manifest.items.map((item) => [item.item_id, item])) + const pending = previous.pending_conflicts ?? {} + + const deletes: string[] = [] + const moves: string[] = [] + const edits = new Set() + const arrivals = new Set() + for (const id of new Set([...Object.keys(previous.items), ...Object.keys(pending), ...listed.keys()])) { + const tracked = previous.items[id] + const cloud = listed.get(id) + const conflict = pending[id] + if (conflict) { + // A queued decision moves to the Cloud version it now has to be + // made against, without touching the file, like the change feed. + if (!cloud) { + if (conflict.cloud.path !== null) deletes.push(id) + } else if (conflict.cloud.path === null || conflict.cloud.revision !== cloud.revision) { + edits.add(id) + } + } else if (!cloud) { + if (tracked) deletes.push(id) + } else if (!tracked) { + arrivals.add(id) + } else if (!sameTrackedItem(tracked, cloud)) { + if (tracked.sha256 === cloud.sha256 && tracked.kind === cloud.kind) { + if (tracked.path !== cloud.path) moves.push(id) + } else if (tracked.path !== cloud.path || !holds(cloud)) { + edits.add(id) + } + } + } + + const items = { ...previous.items } + let state: CloudSyncState = { ...previous, pending_conflicts: { ...pending } } + const localConflicts: CloudSyncLocalConflict[] = [] + let pulled = 0 + const land = async (change: CloudSyncChange, cloud: CloudSyncManifestItem | undefined) => { + throwIfCloudSyncCancelled(this.remote.downloadSignal) + const queued = state.pending_conflicts?.[change.item_id] + state = queued + ? withConflict(state, advancePendingConflict(queued, change)) + : await this.landRemoteChange(state, change, previous.items[change.item_id], localConflicts) + const conflict = state.pending_conflicts?.[change.item_id] + if (change.type === 'delete' && conflict?.cloud.path === null) { + // The manifest lists only live files, so the revision that deleted + // this one is unknown. Keeping this device's version sends it back + // as a new file (see resolveConflict). + state = withConflict(state, { ...conflict, cloud: { ...conflict.cloud, revision: null } }) + } + if (cloud) items[change.item_id] = trackedManifestItem(cloud) + else delete items[change.item_id] + pulled++ + } + + try { + for (const id of deletes) { + const path = previous.items[id]?.path ?? pending[id]?.cloud.path ?? pending[id]?.local.path ?? '' + await land({ + sequence: manifest.cursor, item_id: id, type: 'delete', path, previous_path: null, + revision: previous.items[id]?.revision ?? pending[id]?.cloud.revision ?? 0 + }, undefined) + } + // A rename, with or without new bytes, lands only once no other file + // still waiting to leave holds its destination. Cloud renamed B to C and + // then A to B: moving A first found B taken, queued a false conflict + // whose local side later matched the note at C by hash, and choosing + // Cloud there removed it. Renames that wait on each other in a ring + // cannot be ordered without a temporary name; they land as they are, + // and the repository queues each one without moving anything. + const leaving = new Map() + const destination = new Map() + for (const id of [...moves, ...edits]) { + const tracked = previous.items[id] + const target = cloudSyncPathKey(listed.get(id)!.path) + // A queued decision never moves its file, so it frees nothing. + if (pending[id] || !tracked || cloudSyncPathKey(tracked.path) === target) continue + leaving.set(cloudSyncPathKey(tracked.path), id) + destination.set(id, target) + } + const waiting = new Set([...moves, ...edits]) + const blocked = (id: string) => { + const occupant = leaving.get(destination.get(id) ?? '') + return occupant !== undefined && occupant !== id + } + while (waiting.size > 0) { + const ready = [...waiting].filter((id) => !blocked(id)) + const now = new Set(ready.length > 0 ? ready : waiting) + for (const id of moves) { + if (!now.has(id)) continue + const cloud = listed.get(id)! + await land({ + sequence: manifest.cursor, item_id: id, type: 'move', path: cloud.path, + previous_path: previous.items[id]!.path, revision: cloud.revision, + ...(cloud.content_ref ? { content_ref: cloud.content_ref } : {}) + }, cloud) + } + const editing = (candidate: CloudSyncManifestItem) => now.has(candidate.item_id) && edits.has(candidate.item_id) + for await (const item of this.bootstrapItems(manifest, editing)) { + const before = previous.items[item.item_id] + await land(manifestUpsert(item, manifest.cursor, before && before.path !== item.path ? before.path : null), item) + } + for (const id of now) { + waiting.delete(id) + const tracked = previous.items[id] + if (tracked && leaving.get(cloudSyncPathKey(tracked.path)) === id) leaving.delete(cloudSyncPathKey(tracked.path)) + } + } + // A file new to this device may already be here, but only the vault as + // it is now can say: a delete or rename above may have just taken away + // the file at its path. + const current = arrivals.size === 0 ? localByPath + : new Map((await this.repository.scan()).map((item) => [cloudSyncPathKey(item.path), item])) + const arriving = (item: CloudSyncManifestItem) => arrivals.has(item.item_id) && + current.get(cloudSyncPathKey(item.path))?.content.sha256 !== item.sha256 + for await (const item of this.bootstrapItems(manifest, arriving)) { + await land(manifestUpsert(item, manifest.cursor), item) + } + throwIfCloudSyncCancelled(this.remote.downloadSignal) + } catch (error) { + // What landed stays landed, still at the old cursor: a retry rebuilds + // from these items and never redoes a change undone since (#813). + await this.states.save({ ...state, items }) + throw error + } + + state = { ...state, cursor: manifest.cursor, items: manifestState(this.vaultId, manifest.cursor, manifest.items).items } + await this.states.save(state) + return { state, pulled, localConflicts } + } + private async pullChanges( initialState: CloudSyncState, acknowledgedSequences: ReadonlySet = new Set() - ): Promise<{ - state: CloudSyncState - pulled: number - localConflicts: CloudSyncLocalConflict[] - }> { + ): Promise { let state = initialState let pulled = 0 const localConflicts: CloudSyncLocalConflict[] = [] @@ -611,10 +796,10 @@ export class CloudSyncCoordinator { for (const change of response.data) { this.validateWireItem(change) if (this.referencesEnabled && change.sequence !== expectedSequence++) { - throw new Error(`Expected sync sequence ${expectedSequence - 1}, received ${change.sequence}`) + throw cloudProtocolError(`Expected sync sequence ${expectedSequence - 1}, received ${change.sequence}`) } if (this.referencesEnabled && change.type === 'upsert' && !change.content_ref && !change.content) { - throw new Error('Reference-mode change feed returned an upsert without content or a reference.') + throw cloudProtocolError('Reference-mode change feed returned an upsert without content or a reference.') } } pages.push({ after, start: changes.length, end: changes.length + response.data.length }) @@ -635,7 +820,7 @@ export class CloudSyncCoordinator { if (!response.has_more) break if (response.data.length === 0) { - throw new Error('Cloud sync change feed reported another page without returning a change') + throw cloudProtocolError('Cloud sync change feed reported another page without returning a change') } } @@ -693,7 +878,7 @@ export class CloudSyncCoordinator { const content = change.content ?? change.content_ref if (!expected || !content || expected.revision !== change.revision || expected.path !== change.path || expected.sha256 !== content.sha256 || expected.byte_length !== content.byte_length) { - throw new Error('Cloud echo did not match its acknowledged mutation.') + throw cloudProtocolError('Cloud echo did not match its acknowledged mutation.') } } // This device's own push: the file already holds these bytes. @@ -708,13 +893,7 @@ export class CloudSyncCoordinator { onDisk.delete(change.item_id) const existingConflict = state.pending_conflicts?.[change.item_id] if (existingConflict) { - state = { - ...state, - pending_conflicts: { - ...state.pending_conflicts, - [change.item_id]: advancePendingConflict(existingConflict, change) - } - } + state = withConflict(state, advancePendingConflict(existingConflict, change)) } else { if (this.referencesEnabled && change.type === 'upsert') { const pageIndex = pages.findIndex((page) => page.start <= index && index < page.end) @@ -723,7 +902,7 @@ export class CloudSyncCoordinator { const response = await this.remote.changes(this.vaultId, page.after, page.end - page.start, { contentMode: 'references' }) if (response.data.length !== page.end - page.start || response.data.some((body, offset) => !sameChangeMetadata(body, changes[page.start + offset]))) { - throw new Error('Cloud change page changed while its content was loading.') + throw new CloudIssueError('remote-changed', 'Cloud change page changed while its content was loading.') } for (const body of response.data) this.validateWireItem(body) pageBodies = new Map(response.data.map((body) => [body.sequence, body])) @@ -735,22 +914,7 @@ export class CloudSyncCoordinator { kind: change.content.encoding === 'utf8' ? 'text' : 'binary', ...change.content }, change.content) } - const applied = await this.applyRemoteChange(change, previous) - const conflict = applied.conflict - if (conflict?.code === 'LOCAL_EDIT_CONFLICT') { - const pending = await this.storedConflict(applied.change, previous, conflict.local ?? null) - if (!(await this.applyAutomaticMerge(pending))) { - state = { - ...state, - pending_conflicts: { - ...state.pending_conflicts, - [pending.id]: pending - } - } - } - } else if (conflict) { - localConflicts.push(publicLocalConflict(conflict)) - } + state = await this.landRemoteChange(state, change, previous, localConflicts) } pulled++ } @@ -767,6 +931,25 @@ export class CloudSyncCoordinator { return { state, pulled, localConflicts } } + /** Put one Cloud change on disk, or queue it for a decision when the local + * file is not what sync last agreed on with Cloud. */ + private async landRemoteChange( + state: CloudSyncState, + change: CloudSyncChange, + previous: CloudSyncTrackedItem | undefined, + localConflicts: CloudSyncLocalConflict[] + ): Promise { + const applied = await this.applyRemoteChange(change, previous) + const conflict = applied.conflict + if (conflict?.code === 'LOCAL_EDIT_CONFLICT') { + const pending = await this.storedConflict(applied.change, previous, conflict.local ?? null) + if (!(await this.applyAutomaticMerge(pending))) return withConflict(state, pending) + } else if (conflict) { + localConflicts.push(publicLocalConflict(conflict)) + } + return state + } + private async retryPendingMerges( initialState: CloudSyncState, localItems: CloudSyncLocalItem[], @@ -925,7 +1108,7 @@ export class CloudSyncCoordinator { const response = await this.remote.revision(this.vaultId, itemId, revision) if (this.referencesEnabled && (response.data.item_id !== itemId || response.data.revision !== revision || response.data.deleted)) return null if (response.data.content_ref) { - if (response.data.content) throw new Error('Ambiguous Cloud revision content.') + if (response.data.content) throw cloudProtocolError('Ambiguous Cloud revision content.') if (response.data.item_id !== itemId || response.data.revision !== revision || response.data.deleted) return null const ref = validateCloudSyncContentReference(response.data.content_ref, { item_id: itemId, revision, ...expected }) return await this.withStaged(ref, async (file) => file.preview ?? null) @@ -1010,7 +1193,7 @@ export class CloudSyncCoordinator { local: CloudSyncStoredConflict['local'] ): Promise { if (!this.repository.applyConflictResolutionFiles && !this.repository.replaceConflictFile) { - throw new Error('This device cannot resolve Cloud file conflicts yet.') + throw new Error(NEEDS_UPDATE) } // An empty file list below means "remove the local file", which is only // right when Cloud deleted it. A Cloud file whose bytes are missing must @@ -1034,7 +1217,7 @@ export class CloudSyncCoordinator { const localPath = local.path ?? conflict.base.path if (!localPath) throw new Error('The local file path is no longer available.') if (conflict.cloud.path !== null && conflict.cloud.path !== localPath) { - throw new Error('This device needs an update before it can resolve moved files.') + throw new Error(NEEDS_UPDATE) } await this.repository.replaceConflictFile!({ path: localPath, @@ -1064,7 +1247,7 @@ export class CloudSyncCoordinator { return } if (!this.repository.resolveBootstrapConflict || conflict.cloud.path !== local.path) { - throw new Error('This device needs an update before it can keep both moved files.') + throw new Error(NEEDS_UPDATE) } await this.repository.resolveBootstrapConflict({ path: local.path, @@ -1092,7 +1275,7 @@ export class CloudSyncCoordinator { if (!this.remote.negotiateContentReferences || !this.remote.download || !this.repository.stageCloudContent || !this.repository.applyStagedCloudContent || !this.repository.resolveStagedCloudConflict) { - if (this.remote.requiresContentReferenceHost) throw new Error('This host needs all Cloud staging and conflict hooks before streaming sync.') + if (this.remote.requiresContentReferenceHost) throw cloudProtocolError('This host needs all Cloud staging and conflict hooks before streaming sync.') return } this.referenceNegotiation ??= this.remote.negotiateContentReferences().then((enabled) => { @@ -1103,13 +1286,13 @@ export class CloudSyncCoordinator { private async withStaged(reference: CloudSyncContentReference, use: (file: CloudSyncStagedFile) => Promise): Promise { if (!this.referencesEnabled || !this.remote.download || !this.repository.stageCloudContent) { - throw new Error('This host cannot safely download Cloud content references.') + throw cloudProtocolError('This host cannot safely download Cloud content references.') } const ref = Object.freeze(validateCloudSyncContentReference(reference)) throwIfCloudSyncCancelled(this.remote.downloadSignal) const decryptor = ref.encrypted ? await this.remote.contentDecryptor?.(this.vaultId) : undefined if (ref.encrypted && !decryptor) { - throw new Error('This host cannot decrypt content from an end-to-end encrypted vault.') + throw new Error(NEEDS_UPDATE) } const file = await this.repository.stageCloudContent({ reference: ref, signal: this.remote.downloadSignal, previewLimitBytes: CONFLICT_PREVIEW_LIMIT_BYTES, @@ -1128,7 +1311,7 @@ export class CloudSyncCoordinator { throwIfCloudSyncCancelled(this.remote.downloadSignal) if (file.preview) { if (ref.byte_length > CONFLICT_PREVIEW_LIMIT_BYTES || typeof file.preview.data !== 'string' || file.preview.data.length > CONFLICT_PREVIEW_LIMIT_BYTES * 2) { - throw new Error('Cloud staging preview exceeded its byte budget.') + throw cloudProtocolError('Cloud staging preview exceeded its byte budget.') } const item: CloudSyncManifestItem = { ...ref, path: 'preview', kind: ref.encoding === 'utf8' ? 'text' : 'binary', content: file.preview } this.validateWireItem(item) @@ -1169,28 +1352,28 @@ export class CloudSyncCoordinator { private validateWireItem(item: CloudSyncManifestItem | CloudSyncChange): void { if (this.referencesEnabled) { if (typeof item.item_id !== 'string' || !item.item_id || !Number.isSafeInteger(item.revision) || item.revision < 1) { - throw new Error('Invalid Cloud item identity or revision.') + throw cloudProtocolError('Invalid Cloud item identity or revision.') } normalizeCloudSyncPath(item.path) if ('sequence' in item) { if (!Number.isSafeInteger(item.sequence) || item.sequence < 1 || !['upsert', 'move', 'delete'].includes(item.type)) { - throw new Error('Invalid Cloud change sequence or type.') + throw cloudProtocolError('Invalid Cloud change sequence or type.') } } else if (!['text', 'binary'].includes(item.kind) || typeof item.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(item.sha256) || !Number.isSafeInteger(item.byte_length) || item.byte_length < 0 || typeof item.media_type !== 'string' || !item.media_type) { - throw new Error('Invalid Cloud manifest metadata.') + throw cloudProtocolError('Invalid Cloud manifest metadata.') } } if (item.content_ref) { - if (!this.referencesEnabled || item.content) throw new Error('Unexpected or ambiguous Cloud content reference.') + if (!this.referencesEnabled || item.content) throw cloudProtocolError('Unexpected or ambiguous Cloud content reference.') validateCloudSyncContentReference(item.content_ref, item) } if (this.referencesEnabled && item.content) { if (typeof item.content.data !== 'string' || item.content.byte_length > 1_048_576 || item.content.data.length > 2_097_152 || Object.keys(item.content).some((key) => !['encoding', 'data', 'sha256', 'byte_length', 'media_type'].includes(key))) { - throw new Error('Invalid inline Cloud content or remote staging handle.') + throw cloudProtocolError('Invalid inline Cloud content or remote staging handle.') } const { data: _bytes, ...metadata } = item.content validateCloudSyncContentReference({ ...metadata, item_id: item.item_id, revision: item.revision }, item) @@ -1214,7 +1397,9 @@ export class CloudSyncCoordinator { let pulled = 0 const state = manifestState(this.vaultId, manifest.cursor, manifest.items) - for await (const item of this.bootstrapItems(manifest, localByPath)) { + const needsBytes = (item: CloudSyncManifestItem) => + localByPath.get(cloudSyncPathKey(item.path))?.content.sha256 !== item.sha256 + for await (const item of this.bootstrapItems(manifest, needsBytes)) { throwIfCloudSyncCancelled(this.remote.downloadSignal) const local = localByPath.get(cloudSyncPathKey(item.path)) @@ -1261,9 +1446,11 @@ export class CloudSyncCoordinator { return { state, pulled, conflicts, localConflicts } } + /** The manifest items `needsBytes` selects, with their content, one bounded + * page at a time in manifest order. */ private async *bootstrapItems( manifest: { cursor: number; items: CloudSyncManifestItem[] }, - localByPath: ReadonlyMap + needsBytes: (item: CloudSyncManifestItem) => boolean ): AsyncGenerator { const budget = this.remote.bootstrapContentPageBytes ?? BOOTSTRAP_BULK_RESPONSE_LIMIT_BYTES if (!Number.isSafeInteger(budget) || budget <= 0) { @@ -1274,21 +1461,19 @@ export class CloudSyncCoordinator { contentBytes: Math.min(budget, BOOTSTRAP_BULK_RESPONSE_LIMIT_BYTES) } for (let offset = 0; offset < manifest.items.length; offset += MANIFEST_PAGE_SIZE) { - yield* this.bootstrapPage(manifest, localByPath, offset, MANIFEST_PAGE_SIZE, limits) + yield* this.bootstrapPage(manifest, needsBytes, offset, MANIFEST_PAGE_SIZE, limits) } } private async *bootstrapPage( manifest: { cursor: number; items: CloudSyncManifestItem[] }, - localByPath: ReadonlyMap, + needsBytes: (item: CloudSyncManifestItem) => boolean, offset: number, pageSize: number, limits: { pageSize: number; contentBytes: number } ): AsyncGenerator { const items = manifest.items.slice(offset, offset + pageSize) - const needed = items.filter( - (item) => localByPath.get(cloudSyncPathKey(item.path))?.content.sha256 !== item.sha256 - ) + const needed = items.filter(needsBytes) if (needed.length === 0) return if ( (!this.referencesEnabled && !this.remote.revision) || @@ -1336,7 +1521,7 @@ export class CloudSyncCoordinator { response.data.length !== items.length || response.data.some((item, index) => !sameManifestItem(item, items[index])) ) { - throw new Error('Vault changed while bootstrap content was loading. Retry sync.') + throw new CloudIssueError('remote-changed', 'Vault changed while bootstrap content was loading. Retry sync.') } const neededIds = new Set(needed.map((item) => item.item_id)) for (const item of response.data) { @@ -1356,7 +1541,7 @@ export class CloudSyncCoordinator { const smallerSize = BOOTSTRAP_PAGE_SIZES.find((size) => size < pageSize)! for (let start = offset; start < offset + items.length; start += smallerSize) { - yield* this.bootstrapPage(manifest, localByPath, start, smallerSize, limits) + yield* this.bootstrapPage(manifest, needsBytes, start, smallerSize, limits) } } @@ -1365,9 +1550,7 @@ export class CloudSyncCoordinator { local: CloudSyncLocalItem }> { if (await this.states.load(this.vaultId)) { - throw new Error( - 'This Cloud conflict is no longer pending. Sync again to see the latest state.' - ) + throw new Error(CONFLICT_SETTLED) } const manifest = await this.stableManifest(!this.referencesEnabled && !this.remote.revision) @@ -1385,7 +1568,7 @@ export class CloudSyncCoordinator { item.sha256 !== conflict.remote_sha256 || local.content.sha256 !== conflict.local_sha256 ) { - throw new Error('This Cloud conflict changed. Sync again to compare the latest versions.') + throw new Error(CONFLICT_CHANGED_AGAIN) } return { item: await this.hydrateManifestItem(item), @@ -1400,7 +1583,7 @@ export class CloudSyncCoordinator { if (item.content_ref) return item if (item.content) return { ...item, content: item.content } if (!this.remote.revision) { - throw new Error(`Manifest item ${item.item_id} did not include content`) + throw cloudProtocolError(`Manifest item ${item.item_id} did not include content`) } const { data } = await this.remote.revision(this.vaultId, item.item_id, item.revision) @@ -1411,12 +1594,14 @@ export class CloudSyncCoordinator { data.kind !== item.kind || data.deleted ) { - throw new Error( - `Cloud revision for ${item.path} did not match the sync manifest. Retry sync.` + throw new CloudIssueError( + 'download-damaged', + `Cloud revision for ${item.path} did not match the sync manifest. Retry sync.`, + item.path ) } if (data.content_ref) { - if (data.content) throw new Error('Ambiguous Cloud revision content.') + if (data.content) throw cloudProtocolError('Ambiguous Cloud revision content.') const referenced = { ...item, content: undefined, content_ref: data.content_ref } this.validateWireItem(referenced) return referenced @@ -1438,8 +1623,10 @@ export class CloudSyncCoordinator { content.media_type !== item.media_type || !hasInlineData(content) ) { - throw new Error( - `Cloud revision for ${item.path} did not match the sync manifest. Retry sync.` + throw new CloudIssueError( + 'download-damaged', + `Cloud revision for ${item.path} did not match the sync manifest. Retry sync.`, + item.path ) } @@ -1453,17 +1640,17 @@ export class CloudSyncCoordinator { bytes = new Uint8Array(binary.length) for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index) } else { - throw new Error('Encrypted cloud sync content must be decrypted before filesystem apply') + throw cloudProtocolError('Encrypted cloud sync content must be decrypted before filesystem apply') } if (bytes.byteLength !== item.byte_length) { - throw new Error(`Cloud revision for ${item.path} has invalid bytes. Retry sync.`) + throw new CloudIssueError('download-damaged', `Cloud revision for ${item.path} has invalid bytes. Retry sync.`, item.path) } const digest = await crypto.subtle.digest('SHA-256', bytes.buffer) const hash = [...new Uint8Array(digest)] .map((byte) => byte.toString(16).padStart(2, '0')) .join('') if (hash !== item.sha256) { - throw new Error(`Cloud revision for ${item.path} has an invalid hash. Retry sync.`) + throw new CloudIssueError('download-damaged', `Cloud revision for ${item.path} has an invalid hash. Retry sync.`, item.path) } return content } @@ -1487,7 +1674,7 @@ export class CloudSyncCoordinator { if (this.referencesEnabled && (!Number.isSafeInteger(response.cursor) || response.cursor < 0 || !Array.isArray(response.data) || (response.next_page !== null && (response.next_page !== page + 1 || response.data.length !== MANIFEST_PAGE_SIZE)))) { - throw new Error('Invalid reference-mode manifest cursor or pagination.') + throw cloudProtocolError('Invalid reference-mode manifest cursor or pagination.') } cursor ??= response.cursor @@ -1498,7 +1685,7 @@ export class CloudSyncCoordinator { for (const item of response.data) { this.validateWireItem(item) if (this.referencesEnabled && !includeContent && !item.content_ref) { - throw new Error('Reference-mode manifest omitted a content reference.') + throw cloudProtocolError('Reference-mode manifest omitted a content reference.') } } items.push(...response.data) @@ -1509,10 +1696,23 @@ export class CloudSyncCoordinator { if (stable && cursor !== null) return { cursor, items } } - throw new Error('Vault changed repeatedly while the initial sync manifest was loading') + throw new CloudIssueError('remote-changed', 'Vault changed repeatedly while the initial sync manifest was loading') } } +type CloudSyncPull = { + state: CloudSyncState + pulled: number + localConflicts: CloudSyncLocalConflict[] +} + +/** Cloud pruned the part of the change log this cursor still needed. */ +function isCloudCursorExpired(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const value = error as { status?: unknown; code?: unknown } + return value.status === 410 && value.code === 'CURSOR_EXPIRED' +} + function isCloudManifestTooLarge(error: unknown): boolean { if (!error || typeof error !== 'object') return false const value = error as { status?: unknown; code?: unknown } @@ -1641,7 +1841,7 @@ function assertCloudSnapshotIsCurrent( ): void { const current = manifest.items.find((item) => item.item_id === conflict.item_id) if (conflict.cloud.path === null) { - if (current) throw new Error('The Cloud version changed. Sync again before choosing a version.') + if (current) throw new Error(CONFLICT_CHANGED_AGAIN) return } if ( @@ -1651,7 +1851,7 @@ function assertCloudSnapshotIsCurrent( current.sha256 !== (conflict.cloud.content ?? conflict.cloud.content_ref)?.sha256 || current.byte_length !== (conflict.cloud.content ?? conflict.cloud.content_ref)?.byte_length ) { - throw new Error('The Cloud version changed. Sync again before choosing a version.') + throw new Error(CONFLICT_CHANGED_AGAIN) } } @@ -1731,6 +1931,10 @@ function publicConflictVersion( } } +function withConflict(state: CloudSyncState, conflict: CloudSyncStoredConflict): CloudSyncState { + return { ...state, pending_conflicts: { ...state.pending_conflicts, [conflict.id]: conflict } } +} + function withoutConflict(state: CloudSyncState, conflictId: string): CloudSyncState { const pending = { ...state.pending_conflicts } delete pending[conflictId] @@ -1916,27 +2120,46 @@ function manifestState( state.cursor = cursor for (const item of items) { - state.items[item.item_id] = { - item_id: item.item_id, - path: item.path, - kind: item.kind, - revision: item.revision, - sha256: item.sha256, - byte_length: item.byte_length, - media_type: item.media_type - } + state.items[item.item_id] = trackedManifestItem(item) } return state } -function manifestUpsert(item: CloudSyncManifestItem): CloudSyncChange { +function trackedManifestItem(item: CloudSyncManifestItem): CloudSyncTrackedItem { + return { + item_id: item.item_id, + path: item.path, + kind: item.kind, + revision: item.revision, + sha256: item.sha256, + byte_length: item.byte_length, + media_type: item.media_type + } +} + +function sameTrackedItem(tracked: CloudSyncTrackedItem, item: CloudSyncManifestItem): boolean { + return ( + tracked.revision === item.revision && + tracked.path === item.path && + tracked.kind === item.kind && + tracked.sha256 === item.sha256 && + tracked.byte_length === item.byte_length && + tracked.media_type === item.media_type + ) +} + +function manifestUpsert( + item: CloudSyncManifestItem, + sequence = 0, + previousPath: string | null = null +): CloudSyncChange { return { - sequence: 0, + sequence, item_id: item.item_id, type: 'upsert', path: item.path, - previous_path: null, + previous_path: previousPath, revision: item.revision, content: item.content, ...(item.content_ref ? { content_ref: item.content_ref } : {}) diff --git a/packages/shared-domain/src/cloud-sync-engine.ts b/packages/shared-domain/src/cloud-sync-engine.ts index c1108870..de88d007 100644 --- a/packages/shared-domain/src/cloud-sync-engine.ts +++ b/packages/shared-domain/src/cloud-sync-engine.ts @@ -10,6 +10,7 @@ import type { } from '@zennotes/bridge-contract/cloud-sync' import { cloudSyncPathKey, normalizeCloudSyncPath, shouldSyncVaultPath } from './cloud-sync' import { validateCloudSyncContentReference } from './cloud-sync-content' +import { CloudIssueError, cloudProtocolError } from './cloud-error-copy' export interface CloudSyncLocalItem { path: string @@ -112,7 +113,7 @@ export function planCloudSyncMutations( } const key = cloudSyncPathKey(normalizedItem.path) if (localByPathKey.has(key)) { - throw new Error(`Vault contains a portable path collision at ${normalizedItem.path}`) + throw new CloudIssueError('path-collision', `Vault contains a portable path collision at ${normalizedItem.path}`, normalizedItem.path) } localByPathKey.set(key, normalizedItem) } @@ -218,7 +219,7 @@ export function resolveCloudSyncMutations( for (const acknowledgement of response.acknowledged) { const mutation = mutationByOperation.get(acknowledgement.operation_id) if (!mutation || mutation.item_id !== acknowledgement.item_id) { - throw new Error( + throw cloudProtocolError( `Server acknowledged an unknown sync operation ${acknowledgement.operation_id}` ) } @@ -231,7 +232,7 @@ export function resolveCloudSyncMutations( const previousItem = nextState.items[mutation.item_id] if (mutation.type === 'move') { if (!previousItem) { - throw new Error(`Server acknowledged a move for unknown item ${mutation.item_id}`) + throw cloudProtocolError(`Server acknowledged a move for unknown item ${mutation.item_id}`) } nextState.items[mutation.item_id] = { ...previousItem, @@ -262,7 +263,7 @@ export function reduceCloudSyncChange( ): CloudSyncState { if (change.sequence <= state.cursor) return state if (change.sequence !== state.cursor + 1) { - throw new Error(`Expected sync sequence ${state.cursor + 1}, received ${change.sequence}`) + throw cloudProtocolError(`Expected sync sequence ${state.cursor + 1}, received ${change.sequence}`) } const items = { ...state.items } @@ -270,18 +271,18 @@ export function reduceCloudSyncChange( delete items[change.item_id] } else if (change.type === 'move') { const previousItem = items[change.item_id] - if (!previousItem) throw new Error(`Received a move for unknown item ${change.item_id}`) + if (!previousItem) throw cloudProtocolError(`Received a move for unknown item ${change.item_id}`) items[change.item_id] = { ...previousItem, path: change.path, revision: change.revision } } else { - if (change.content_ref && change.content) throw new Error('Ambiguous Cloud change content.') + if (change.content_ref && change.content) throw cloudProtocolError('Ambiguous Cloud change content.') const content = change.content_ref ? validateCloudSyncContentReference(change.content_ref, change) : change.content - if (!content) throw new Error(`Upsert change ${change.sequence} did not include content`) + if (!content) throw cloudProtocolError(`Upsert change ${change.sequence} did not include content`) items[change.item_id] = { item_id: change.item_id, path: change.path, diff --git a/packages/shared-domain/src/cloud-sync-host-service.test.ts b/packages/shared-domain/src/cloud-sync-host-service.test.ts index 8ad493a8..29d6961b 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.test.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.test.ts @@ -607,7 +607,7 @@ describe('CloudSyncHostService', () => { local_conflicts: [] }) - await expect(service.createBackup(hostVault)).rejects.toThrow('Resolve sync conflicts') + await expect(service.createBackup(hostVault)).rejects.toThrow('Some files are waiting for review') expect(client.createBackup).not.toHaveBeenCalled() }) @@ -778,3 +778,28 @@ describe('CloudSyncHostService vault replaced by an encrypted one', () => { expect(isCloudVaultSuperseded(null)).toBe(false) }) }) + +describe('CloudSyncHostService back after Cloud pruned its change log', () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', name: 'Notes', cursor: 0, + created_at: '2026-10-13T12:00:00.000Z', updated_at: '2026-10-13T12:00:00.000Z' + } + + it('keeps the link and catches up from the manifest on a 410 CURSOR_EXPIRED', async () => { + const { service, client, persistence, hostVault, retiredStates } = setup([remoteVault]) + await service.link(hostVault, remoteVault.id) + await service.sync(hostVault) + client.changes.mockRejectedValueOnce(Object.assign( + new Error('This cursor is older than the retained change log. Rebuild this vault from its manifest.'), + { name: 'CloudServiceRequestError', status: 410, code: 'CURSOR_EXPIRED', details: { pruned_through: 30, cursor: 40 } } + )) + client.manifest.mockResolvedValue({ data: [], cursor: 40, next_page: null }) + + await expect(service.sync(hostVault)).resolves.toMatchObject({ cursor: 40, pending_conflicts: [] }) + + expect(await service.linkedVault(hostVault)).not.toBeNull() + expect(retiredStates).toEqual([]) + const state = await persistence.loadState(hostVault.key, 'https://zennotes.org', remoteVault.id) + expect((state as CloudSyncState).cursor).toBe(40) + }) +}) diff --git a/packages/shared-domain/src/cloud-sync-host-service.ts b/packages/shared-domain/src/cloud-sync-host-service.ts index aea82c01..77dd1cad 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.ts @@ -20,6 +20,12 @@ import type { import type { CloudSyncApiClient } from './cloud-sync-api' import { cloudBackupCreateError, cloudBackupItemsPage, restoreCloudBackup } from './cloud-backup' import { normalizeCloudSyncPath } from './cloud-sync' +import { + CLOUD_NOT_LINKED_MESSAGE, + CLOUD_NOT_SIGNED_IN_MESSAGE, + CLOUD_OTHER_ACCOUNT_MESSAGE, + CLOUD_VAULT_NAME_REQUIRED_MESSAGE +} from './cloud-error-copy' import { CLOUD_VAULT_REMOVED_MESSAGE, CLOUD_VAULT_SUPERSEDED_MESSAGE, @@ -125,7 +131,7 @@ export class CloudSyncHostService { async createAndLink(vault: CloudSyncHostVault, name: string): Promise { const normalizedName = name.trim() - if (!normalizedName) throw new Error('Cloud vault name is required.') + if (!normalizedName) throw new Error(CLOUD_VAULT_NAME_REQUIRED_MESSAGE) const { account, client } = await this.connection() const remoteVault = (await client.createVault(normalizedName)).data @@ -286,11 +292,11 @@ export class CloudSyncHostService { private async run(vault: CloudSyncHostVault): Promise { const link = await this.linkedVault(vault) - if (!link) throw new Error('Link this local vault to a ZenNotes Cloud vault before syncing.') + if (!link) throw new Error(CLOUD_NOT_LINKED_MESSAGE) const { account, client } = await this.connection() if (link.base_url !== account.base_url) { - throw new Error('This vault is linked to a different ZenNotes Cloud account.') + throw new Error(CLOUD_OTHER_ACCOUNT_MESSAGE) } const coordinator = new CloudSyncCoordinator( @@ -430,7 +436,7 @@ export class CloudSyncHostService { }> { const status = await this.dependencies.accountStatus() if (status.state !== 'connected' || !status.account) { - throw new Error('Connect ZenNotes Cloud before using sync.') + throw new Error(CLOUD_NOT_SIGNED_IN_MESSAGE) } return { account: status.account, @@ -444,10 +450,10 @@ export class CloudSyncHostService { link: CloudVaultLink }> { const link = await this.linkedVault(vault) - if (!link) throw new Error('Link this local vault to a ZenNotes Cloud vault first.') + if (!link) throw new Error(CLOUD_NOT_LINKED_MESSAGE) const connection = await this.connection() if (link.base_url !== connection.account.base_url) { - throw new Error('This vault is linked to a different ZenNotes Cloud account.') + throw new Error(CLOUD_OTHER_ACCOUNT_MESSAGE) } return { ...connection, link } } @@ -533,6 +539,6 @@ function assertBackupReady(summary: CloudSyncRunSummary): void { summary.bootstrap_conflicts.length > 0 || (summary.pending_conflicts?.length ?? 0) > 0 ) { - throw new Error('Resolve sync conflicts before creating a cloud backup.') + throw new Error('Some files are waiting for review. Settle them first (Review), then create the backup.') } } diff --git a/packages/shared-domain/src/cloud-sync-portable-filesystem.ts b/packages/shared-domain/src/cloud-sync-portable-filesystem.ts index 191eb2c8..952ebf33 100644 --- a/packages/shared-domain/src/cloud-sync-portable-filesystem.ts +++ b/packages/shared-domain/src/cloud-sync-portable-filesystem.ts @@ -16,6 +16,7 @@ import type { CloudSyncRepository, CloudSyncRepositoryConflict } from './cloud-s import type { CloudSyncLocalItem, CloudSyncTrackedItem } from './cloud-sync-engine' import type { CloudSyncDownloadSource, CloudSyncStagedFile, CloudSyncStagedConflict } from './cloud-sync-content' import { randomUUID } from './random-uuid' +import { cloudProtocolError } from './cloud-error-copy' const TEXT_EXTENSIONS = new Set([ '.base', @@ -186,7 +187,7 @@ export class PortableCloudSyncRepository implements CloudSyncRepository { return } - if (!change.content) throw new Error(`Upsert change ${change.sequence} did not include content`) + if (!change.content) throw cloudProtocolError(`Upsert change ${change.sequence} did not include content`) await this.validateContent(change.content) const previousPath = this.path(previous?.path ?? change.previous_path ?? change.path) @@ -270,7 +271,7 @@ export class PortableCloudSyncRepository implements CloudSyncRepository { throw new Error('Choose a different filename inside the synced vault.') } if ((await this.fs.stat(localCopyPath)) !== null) { - throw new Error(`${localCopyPath} already exists.`) + throw new Error(`A file named “${localCopyPath}” already exists. Choose another name.`) } await this.fs.rename(originalPath, localCopyPath) @@ -321,7 +322,7 @@ export class PortableCloudSyncRepository implements CloudSyncRepository { for (const file of files) { if (cloudSyncPathKey(file.path) === expectedKey) continue if ((await this.fs.stat(file.path)) !== null) { - throw new Error(`${file.path} already exists. Choose another filename.`) + throw new Error(`A file named “${file.path}” already exists. Choose another name.`) } } @@ -546,13 +547,13 @@ export class PortableCloudSyncRepository implements CloudSyncRepository { await this.fs.writeBase64(path, content.data) return } - throw new Error('Encrypted cloud sync content must be decrypted before filesystem apply') + throw cloudProtocolError('Encrypted cloud sync content must be decrypted before filesystem apply') } private async validateContent(content: CloudSyncContent): Promise { if (content.data === '' && content.byte_length > 0 && (!this.fs.validateContent || !this.fs.writeContent)) { - throw new Error('File content has no readable source bytes.') + throw cloudProtocolError('File content has no readable source bytes.') } await this.fs.validateContent?.(content) } diff --git a/packages/shared-domain/src/cloud-sync-references.test.ts b/packages/shared-domain/src/cloud-sync-references.test.ts index 210d46da..d74d5ef9 100644 --- a/packages/shared-domain/src/cloud-sync-references.test.ts +++ b/packages/shared-domain/src/cloud-sync-references.test.ts @@ -834,3 +834,47 @@ describe('content reference capability negotiation', () => { expect(send).toHaveBeenCalledOnce() }) }) + +describe('reference-mode rebuild after CURSOR_EXPIRED', () => { + it('streams what changed in Cloud and keeps a local edit Cloud also changed', async () => { + const big = entry('big', 3) + const note = entry('note', 4, 'cloud text v4') + const before = { big: entry('big', 1), note: entry('note', 1, 'old text') } + const tracked = (item: Entry) => { + const { content: _content, content_ref: _ref, ...metadata } = item + return metadata + } + // big.md was edited on this device while it was away; note.md was not. + const edited = { ...local(before.big), content: { ...local(before.big).content, sha256: hash('edited here') } } + const f = fixture( + [big, note], + { ...emptyCloudSyncState('vault'), cursor: 2, items: { big: tracked(before.big), note: tracked(before.note) } }, + [edited, local(before.note)] + ) + for (let sequence = 1; sequence <= 5; sequence++) { + f.feed.push({ item_id: 'pruned', revision: sequence, sequence, type: 'delete', path: 'pruned.md', previous_path: null }) + } + const follow = vi.mocked(f.remote.changes).getMockImplementation()! + vi.mocked(f.remote.changes).mockImplementation(async (vault, after, ...rest) => { + if (after < 5) { + throw Object.assign(new Error('This cursor is older than the retained change log.'), { + name: 'CloudServiceRequestError', status: 410, code: 'CURSOR_EXPIRED', details: { pruned_through: 5, cursor: 5 } + }) + } + return follow(vault, after, ...rest) + }) + + const result = await f.coordinator.sync() + + expect(f.files.get('note.md')).toEqual(local(note)) + expect(f.files.get('big.md')).toEqual(edited) + expect(f.repository.applyStagedCloudContent).toHaveBeenCalledOnce() + expect(f.remote.mutate).not.toHaveBeenCalled() + expect(result.pendingConflicts).toEqual([ + expect.objectContaining({ item_id: 'big', kind: 'content', path: 'big.md' }) + ]) + expect(f.state()?.pending_conflicts?.big?.cloud).toMatchObject({ revision: 3, content_ref: big.content_ref }) + expect(f.state()?.cursor).toBe(5) + expect(f.state()?.items).toEqual({ big: tracked(big), note: tracked(note) }) + }) +}) diff --git a/packages/shared-domain/src/cloud-sync-resolution.test.ts b/packages/shared-domain/src/cloud-sync-resolution.test.ts index 99e082e1..8fb39ef1 100644 --- a/packages/shared-domain/src/cloud-sync-resolution.test.ts +++ b/packages/shared-domain/src/cloud-sync-resolution.test.ts @@ -118,7 +118,7 @@ describe('single-note Cloud resolution', () => { ] }) await expect(f.coordinator.resolveConflict(f.resolution)).rejects.toThrow( - 'Cloud version changed while saving' + 'changed again on another device' ) expect(f.mutate).toHaveBeenCalledOnce() expect(f.write).not.toHaveBeenCalled() diff --git a/packages/shared-domain/src/pasted-image.test.ts b/packages/shared-domain/src/pasted-image.test.ts index 5fff0023..81ec296f 100644 --- a/packages/shared-domain/src/pasted-image.test.ts +++ b/packages/shared-domain/src/pasted-image.test.ts @@ -4,6 +4,26 @@ import { pastedImageFilename } from './pasted-image' const at = new Date(2026, 7, 6, 9, 5, 3) describe('pastedImageFilename', () => { + it("names a clipboard bitmap by the time, not Chromium's image.png (#775)", () => { + const now = new Date(2026, 9, 19, 13, 45, 12) + expect(pastedImageFilename({ mimeType: 'image/png', suggestedName: 'image.png' }, now)).toBe( + 'Pasted Image 2026-10-19 134512.png' + ) + expect(pastedImageFilename({ mimeType: 'image/jpeg', suggestedName: 'Image.JPEG' }, now)).toBe( + 'Pasted Image 2026-10-19 134512.jpeg' + ) + }) + + it('keeps the name of a real file that merely starts with image (#775)', () => { + const now = new Date(2026, 9, 19, 13, 45, 12) + expect(pastedImageFilename({ mimeType: 'image/png', suggestedName: 'image-2.png' }, now)).toBe( + 'image-2.png' + ) + expect(pastedImageFilename({ mimeType: 'image/png', suggestedName: 'Diagram.png' }, now)).toBe( + 'Diagram.png' + ) + }) + it('falls back to the timestamped name when nothing is suggested', () => { expect(pastedImageFilename({ mimeType: 'image/png' }, at)).toBe( 'Pasted Image 2026-08-06 090503.png' diff --git a/packages/shared-domain/src/pasted-image.ts b/packages/shared-domain/src/pasted-image.ts index 6f9bca9b..3fc0a5b6 100644 --- a/packages/shared-domain/src/pasted-image.ts +++ b/packages/shared-domain/src/pasted-image.ts @@ -72,6 +72,15 @@ export function pastedImageExtension(input: PastedImageNameInput): string { throw new Error('Clipboard item is not an image.') } +/** Chromium, which the desktop app and Android's WebView run on, names every + * bitmap pasted from the clipboard `image.png`. Kept as the name, it made + * every paste `image.png`, then `image (2).png` and on (#775), so that stem + * counts as no name and a paste gets the timestamped one. A file copied from + * the Finder or Explorer arrives under its own name and keeps it. */ +function isClipboardPlaceholderName(base: string): boolean { + return base.toLowerCase() === 'image' +} + export function pastedImageFilename(input: PastedImageNameInput, now: Date): string { const ext = pastedImageExtension(input) const rawName = baseName(input.suggestedName ?? '') @@ -82,6 +91,7 @@ export function pastedImageFilename(input: PastedImageNameInput, now: Date): str .replace(/\s+/g, ' ') .trim() const fallbackBase = `Pasted Image ${pastedImageTimestamp(now)}` - const finalBase = base && base !== '.' && base !== '..' ? base : fallbackBase + const finalBase = + base && base !== '.' && base !== '..' && !isClipboardPlaceholderName(base) ? base : fallbackBase return `${finalBase}${ext}` } diff --git a/packages/shared-domain/src/tasks-excluded-folders.test.ts b/packages/shared-domain/src/tasks-excluded-folders.test.ts index 609d0cc4..ddc9ec11 100644 --- a/packages/shared-domain/src/tasks-excluded-folders.test.ts +++ b/packages/shared-domain/src/tasks-excluded-folders.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { isPathExcludedFromTasks, normalizeTasksExcludedFolder, - normalizeTasksExcludedFolders + normalizeTasksExcludedFolders, + normalizeTasksIncludedFolders, + rewriteTasksFoldersForRename } from './tasks-excluded-folders' describe('normalizeTasksExcludedFolder (#458)', () => { @@ -61,3 +63,72 @@ describe('isPathExcludedFromTasks (#458)', () => { expect(isPathExcludedFromTasks('inbox/Books/dune.md', [])).toBe(false) }) }) + +describe('isPathExcludedFromTasks with Task folders (#962)', () => { + it('keeps the whole vault when no Task folders are set', () => { + expect(isPathExcludedFromTasks('inbox/Idea.md', [], [])).toBe(false) + expect(isPathExcludedFromTasks('inbox/Idea.md', [])).toBe(false) + }) + + it('leaves out every note outside the Task folders', () => { + expect(isPathExcludedFromTasks('Tasks/Today.md', [], ['Tasks'])).toBe(false) + expect(isPathExcludedFromTasks('Tasks/Work/Q4.md', [], ['Tasks'])).toBe(false) + expect(isPathExcludedFromTasks('inbox/Books.md', [], ['Tasks'])).toBe(true) + expect(isPathExcludedFromTasks('TasksArchive/Old.md', [], ['Tasks'])).toBe(true) + }) + + it('counts a note in any of several Task folders', () => { + const included = ['Tasks', 'inbox/Projects'] + expect(isPathExcludedFromTasks('inbox/Projects/Site.md', [], included)).toBe(false) + expect(isPathExcludedFromTasks('inbox/Reading.md', [], included)).toBe(true) + }) + + it('still excludes a folder inside a Task folder', () => { + expect( + isPathExcludedFromTasks('Projects/Archive/Old.md', ['Projects/Archive'], ['Projects']) + ).toBe(true) + expect( + isPathExcludedFromTasks('Projects/Live.md', ['Projects/Archive'], ['Projects']) + ).toBe(false) + }) + + it('validates Task folders like excluded ones', () => { + expect(normalizeTasksIncludedFolders(['Tasks/', '../x', 'Tasks', 'inbox\\Work'])).toEqual([ + 'Tasks', + 'inbox/Work' + ]) + }) +}) + +describe('rewriteTasksFoldersForRename (#962)', () => { + it('moves a renamed folder and the folders inside it', () => { + expect( + rewriteTasksFoldersForRename( + { includedFolders: ['inbox/Tasks', 'inbox/Work'], excludedFolders: ['inbox/Tasks/Someday'] }, + 'inbox/Tasks', + 'inbox/Todo' + ) + ).toEqual({ includedFolders: ['inbox/Todo', 'inbox/Work'], excludedFolders: ['inbox/Todo/Someday'] }) + }) + + it('follows a move into another folder', () => { + expect( + rewriteTasksFoldersForRename({ includedFolders: ['inbox/Tasks'] }, 'inbox/Tasks', 'inbox/Areas/Tasks') + ).toEqual({ includedFolders: ['inbox/Areas/Tasks'] }) + }) + + it('leaves look-alike names and other folders alone', () => { + const tasks = { includedFolders: ['inbox/TasksArchive', 'quick/Tasks'] } + expect(rewriteTasksFoldersForRename(tasks, 'inbox/Tasks', 'inbox/Todo')).toEqual(tasks) + }) + + it('collapses a rename onto an entry that is already listed', () => { + expect( + rewriteTasksFoldersForRename({ includedFolders: ['inbox/A', 'inbox/B'] }, 'inbox/A', 'inbox/B') + ).toEqual({ includedFolders: ['inbox/B'] }) + }) + + it('passes absent settings through', () => { + expect(rewriteTasksFoldersForRename(undefined, 'inbox/A', 'inbox/B')).toBeUndefined() + }) +}) diff --git a/packages/shared-domain/src/tasks-excluded-folders.ts b/packages/shared-domain/src/tasks-excluded-folders.ts index 81dc5a3a..82144c31 100644 --- a/packages/shared-domain/src/tasks-excluded-folders.ts +++ b/packages/shared-domain/src/tasks-excluded-folders.ts @@ -1,10 +1,12 @@ -// The vault-level "exclude this folder from Tasks" list (#458). Stored in -// `.zennotes/vault.json` under `tasks.excludedFolders` as vault-relative -// directory paths exactly as they exist on disk, so remapped system folders -// (#115) need no translation: every scanner matches against a note's real -// relative path. Desktop main, the MCP server, and the iPhone bridge all -// import these helpers; the Go server mirrors them in -// internal/vault/tasks_exclude.go: change both together and keep the rules +// The vault-level folder lists for Tasks. `tasks.excludedFolders` (#458) keeps +// folders out; `tasks.includedFolders`, the Task folders (#962), narrows Tasks +// to the folders it names, and an empty list means the whole vault. Both live +// in `.zennotes/vault.json` as vault-relative directory paths exactly as they +// exist on disk, so remapped system folders (#115) need no translation: every +// scanner matches against a note's real relative path. Desktop main, the MCP +// server, and the iPhone bridge all import these helpers; the Go server +// mirrors them in internal/vault/tasks_exclude.go and the Go CLI in +// internal/vault/settings.go: change them together and keep the rules // byte-compatible. /** Validate one excluded-folder entry: forward slashes, no empty or dot @@ -38,16 +40,57 @@ export function normalizeTasksExcludedFolders(value: unknown): string[] { return out } -/** Whether a vault-relative POSIX path lives inside any excluded folder. - * Segment-prefix match, case-sensitive like the rest of the vault layer: - * `inbox/Books` excludes `inbox/Books/x.md` and `inbox/Books/sub/y.md`, - * never `inbox/Bookshelf.md`. */ +/** The Task folders list (#962) is validated exactly like the excluded one. */ +export const normalizeTasksIncludedFolder = normalizeTasksExcludedFolder +export const normalizeTasksIncludedFolders = normalizeTasksExcludedFolders + +/** Whether a vault-relative POSIX path is kept out of Tasks by the folder + * lists. Segment-prefix match, case-sensitive like the rest of the vault + * layer: `inbox/Books` excludes `inbox/Books/x.md` and `inbox/Books/sub/y.md`, + * never `inbox/Bookshelf.md`. Once `included` names folders, a path outside + * all of them is out as well; `excluded` still applies inside them, so + * `Projects` with `Projects/Archive` excluded leaves the archive out. A caller + * that passes no `included` list gets the exclusion rule alone. */ export function isPathExcludedFromTasks( relPath: string, - excluded: readonly string[] + excluded: readonly string[], + included: readonly string[] = [] ): boolean { - for (const folder of excluded) { + if (included.length > 0 && !isInsideAnyFolder(relPath, included)) return true + return isInsideAnyFolder(relPath, excluded) +} + +function isInsideAnyFolder(relPath: string, folders: readonly string[]): boolean { + for (const folder of folders) { if (relPath === folder || relPath.startsWith(folder + '/')) return true } return false } + +/** Repoint one list's entries after a folder rename or move: an entry naming + * the folder, or a folder inside it, follows it to its new path. */ +function rewriteFolderList(list: readonly string[], oldRel: string, newRel: string): string[] { + return normalizeTasksExcludedFolders( + list.map((entry) => + entry === oldRel || entry.startsWith(oldRel + '/') ? newRel + entry.slice(oldRel.length) : entry + ) + ) +} + +/** Both folder lists after a folder rename or move, so they keep naming the + * same folders. A renamed Task folder (#962) left behind would match nothing, + * and once it was the only one, Tasks would go empty. */ +export function rewriteTasksFoldersForRename< + T extends { excludedFolders?: string[]; includedFolders?: string[] } +>(tasks: T | undefined, oldRel: string, newRel: string): T | undefined { + if (!tasks || !oldRel || !newRel || oldRel === newRel) return tasks + return { + ...tasks, + ...(tasks.excludedFolders + ? { excludedFolders: rewriteFolderList(tasks.excludedFolders, oldRel, newRel) } + : {}), + ...(tasks.includedFolders + ? { includedFolders: rewriteFolderList(tasks.includedFolders, oldRel, newRel) } + : {}) + } +} diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 7fbc6e2d..b943668a 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.71.0", + "version": "2.72.0", "type": "module", "exports": { ".": "./src/index.ts"