From e096798612511b05aa9481eabe9215df6691ca58 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 08:53:55 -0500 Subject: [PATCH 01/15] Fix(sidebar): the icon and color pickers take the keyboard (#960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the sidebar, `m` and Change color… or Change icon… opened a picker, but the keys kept driving the sidebar behind it. j and k moved the row cursor under the backdrop, l opened that row and pulled focus out of the dialog into the editor, and Enter opened the row instead of picking. Only Tab reached the choices. With Vim off, arrows and Enter did the same. The picker did hold DOM focus. Panel keys follow the store's focused panel instead, which stays on the sidebar while a dialog is open, and both routers (VimNav, and the Vim-off handler in App) stood down only for dialogs that carry a data-* marker of their own. The pickers are plain Modal shells with none. Both routers now also stand down for any aria-modal dialog, which every Modal panel sets, so the other unmarked dialogs are covered too. The pickers then answer the keys themselves: arrows move between the choices, and h j k l do with Vim on, as single-key motions do everywhere. Up and down keep the column the grid shows (three, or two on a narrow window), and an edge stays put. Enter picks the focused choice, Escape closes, and the picker opens on the color or icon already set, with a focus ring on the choice. Left alone: pane focus (Alt+h/j/k/l) and the global shortcuts guarded in App keep their own marker lists. Closing that gap is a separate change. Nine tests for the grid. Driven over CDP on a built app with Vim on and off. Before, j j l moved the sidebar from Bravo to Delta and opened Delta behind the open picker, and the shipped 2.71.0 did the same with arrows and Enter with Vim off. After, the sidebar stays put, the keys walk the grid, and Enter writes the color or icon to vault.json. Reported by @Luistfer. --- packages/app-core/src/App.tsx | 6 +- .../src/components/FolderColorPickerModal.tsx | 55 +++++++----- .../src/components/FolderIconPickerModal.tsx | 55 +++++++----- packages/app-core/src/components/VimNav.tsx | 7 +- packages/app-core/src/lib/picker-grid.test.ts | 65 ++++++++++++++ packages/app-core/src/lib/picker-grid.ts | 84 +++++++++++++++++++ 6 files changed, 228 insertions(+), 44 deletions(-) create mode 100644 packages/app-core/src/lib/picker-grid.test.ts create mode 100644 packages/app-core/src/lib/picker-grid.ts diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index cb32c9631..5218dd033 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) { diff --git a/packages/app-core/src/components/FolderColorPickerModal.tsx b/packages/app-core/src/components/FolderColorPickerModal.tsx index d0502cf26..83c2e1f00 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 ( + + ) + })} +
{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 000000000..dc5da8d82 --- /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 a78c14b25..087e35fb8 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 7625aa79a..483304828 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 33248d5ac..0b4218e2d 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 7f9e32887..6340c0194 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 03dad4b8a..7dd294ccc 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/PublishNoteModal.tsx b/packages/app-core/src/components/PublishNoteModal.tsx index 1b0aba391..da5402316 100644 --- a/packages/app-core/src/components/PublishNoteModal.tsx +++ b/packages/app-core/src/components/PublishNoteModal.tsx @@ -4,6 +4,8 @@ import type { CloudPublishedNote } from '@zennotes/bridge-contract/cloud-sync' import { CloudPublishUnconfirmedError, publishCloudNoteWithFeedback, type PublishableCloudNote } from '../lib/cloud-publishing' import { notifyPublishedNoteChanged } from '../lib/published-note-events' import { Button } from './ui/Button' +import type { CloudErrorCopy } from '@zennotes/shared-domain/cloud-error-copy' +import { CloudErrorMessage, cloudErrorCopy } from './CloudErrorMessage' import { Modal } from './ui/Modal' export function PublishNoteModal({ @@ -18,7 +20,7 @@ export function PublishNoteModal({ const [existing, setExisting] = useState(null) const [loading, setLoading] = useState(true) const [publishing, setPublishing] = useState(false) - const [error, setError] = useState(null) + const [error, setError] = useState(null) const [notice, setNotice] = useState(null) useEffect(() => { @@ -32,7 +34,7 @@ export function PublishNoteModal({ }) .catch((reason: unknown) => { if (!cancelled) { - setError(reason instanceof Error ? reason.message : 'Could not load publishing settings.') + setError(cloudErrorCopy(reason, "ZenNotes couldn't load your publishing settings. Try again in a moment.", bridge)) } }) .finally(() => { @@ -61,7 +63,7 @@ export function PublishNoteModal({ notifyPublishedNoteChanged({ notePath: note.path, url: reason.publicNote.url }) } } else { - setError(reason instanceof Error ? reason.message : 'Could not publish this note.') + setError(cloudErrorCopy(reason, "ZenNotes couldn't publish this note. Try again in a moment.", bridge)) } } finally { setPublishing(false) @@ -83,7 +85,7 @@ export function PublishNoteModal({

{error && (
- {error} +
)} diff --git a/packages/app-core/src/components/StatusBar.tsx b/packages/app-core/src/components/StatusBar.tsx index d2089528b..f39e47704 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/lib/cloud-auto-sync.test.ts b/packages/app-core/src/lib/cloud-auto-sync.test.ts index 90cc88487..ef2fa569d 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 c81866a89..8bd792848 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 a089a08e6..ca8b1222a 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/help.ts b/packages/app-core/src/lib/help.ts index bc3c08ed9..d889d6a9c 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: diff --git a/packages/shared-domain/src/cloud-auth-flow.test.ts b/packages/shared-domain/src/cloud-auth-flow.test.ts index 8a83ab5f7..15ce1a3d3 100644 --- a/packages/shared-domain/src/cloud-auth-flow.test.ts +++ b/packages/shared-domain/src/cloud-auth-flow.test.ts @@ -111,7 +111,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 +120,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,7 +140,7 @@ 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() }) diff --git a/packages/shared-domain/src/cloud-auth-flow.ts b/packages/shared-domain/src/cloud-auth-flow.ts index 4a1a2625a..3221251d0 100644 --- a/packages/shared-domain/src/cloud-auth-flow.ts +++ b/packages/shared-domain/src/cloud-auth-flow.ts @@ -3,6 +3,7 @@ import type { CloudAccountConnectResult, CloudAccountStatus } from '@zennotes/bridge-contract/cloud-sync' +import { CLOUD_SIGN_IN_EXPIRED_MESSAGE, CLOUD_SIGN_IN_UNEXPECTED_MESSAGE } from './cloud-error-copy' const AUTH_LIFETIME_MS = 5 * 60 * 1000 const REVOKE_TIMEOUT_MS = 5_000 @@ -139,7 +140,7 @@ 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, { @@ -151,7 +152,7 @@ export class CloudAuthFlow { app_version: this.dependencies.appVersion }) 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 a3d0987cc..18dab7812 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 dbd868eb9..b952c6fe8 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 90e6b8261..9ac9eae23 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 55f468afa..ff4eacab1 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 e6b886c6c..c54b22b9f 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 000000000..75b3572d4 --- /dev/null +++ b/packages/shared-domain/src/cloud-error-copy.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest' +import { + CLOUD_OFFLINE_MESSAGE, + CLOUD_SIGNED_OUT_MESSAGE, + CLOUD_TIMEOUT_MESSAGE, + CLOUD_UNEXPECTED_ANSWER_MESSAGE, + CLOUD_UNEXPECTED_MESSAGE, + CloudIssueError, + cloudProtocolError, + 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('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('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') + ) + expect(copy.message).toBe(CLOUD_UNEXPECTED_ANSWER_MESSAGE) + 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('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 000000000..82fb052d0 --- /dev/null +++ b/packages/shared-domain/src/cloud-error-copy.ts @@ -0,0 +1,478 @@ +/** + * 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.' +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." +const ACCESS_NOT_ENABLED = "ZenNotes Cloud isn't turned on for this account yet. Choose a plan under Billing on zennotes.org." +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.' + +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']) + +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) } +} + +/** + * 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 other = (message: string) => ({ message, kind: 'other' as const }) + + if ( + code === 'UNAUTHENTICATED' || + code === 'ACTIVE_DEVICE_REQUIRED' || + status === 401 || + (status === 403 && raw.text === 'Invalid ability provided.') + ) { + 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) + case 'SYNC_QUOTA_EXCEEDED': + case 'QUOTA_EXCEEDED': + case 'CAPACITY_EXCEEDED': + if (/vault limit/i.test(raw.text)) return other(VAULT_LIMIT) + if (/publish/i.test(raw.text) || raw.details?.dimension === 'publish') return other(PUBLISH_FULL) + if (/^Restoring this note/i.test(raw.text)) return null + return other(CAPACITY_FULL) + case 'FILE_SIZE_LIMIT_EXCEEDED': + if (/published asset/i.test(raw.text)) return other(PUBLISH_FILE_TOO_LARGE) + if (/^This backup note/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 '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 (/invalid JSON response/i.test(raw.text)) 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) +} + +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 03765a3d3..7292b83da 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 7ad45e52a..03c8b3c38 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() diff --git a/packages/shared-domain/src/cloud-sync-content.ts b/packages/shared-domain/src/cloud-sync-content.ts index dff5a1aed..e55c326b0 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 48563c941..7451c2323 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 () => { diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index 9dbfc6c3b..f1e907f9f 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 @@ -318,12 +328,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 +399,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 +427,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) }) @@ -611,10 +617,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 +641,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 +699,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. @@ -723,7 +729,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])) @@ -925,7 +931,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 +1016,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 +1040,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 +1070,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 +1098,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 +1109,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 +1134,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 +1175,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) @@ -1336,7 +1342,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) { @@ -1365,9 +1371,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 +1389,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 +1404,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 +1415,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 +1444,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 +1461,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 +1495,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 +1506,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,7 +1517,7 @@ 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') } } @@ -1641,7 +1649,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 +1659,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) } } diff --git a/packages/shared-domain/src/cloud-sync-engine.ts b/packages/shared-domain/src/cloud-sync-engine.ts index c1108870b..de88d0075 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 8ad493a8d..eef57f8ad 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() }) diff --git a/packages/shared-domain/src/cloud-sync-host-service.ts b/packages/shared-domain/src/cloud-sync-host-service.ts index aea82c01e..77dd1cadc 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 191eb2c85..952ebf33f 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-resolution.test.ts b/packages/shared-domain/src/cloud-sync-resolution.test.ts index 99e082e1b..8fb39ef15 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() From aac55e5d65c5d89a09747b34ac1e8a3f38569a74 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 09:48:29 -0500 Subject: [PATCH 05/15] Fix(editor): Enter in a fenced code block in a callout keeps the quote (#961) The follow-up #961 left open. With the caret on a code line of a fence inside a quote or callout (`> xy` under `> ```js`), Enter started a bare line. A line without its `>` leaves the quote, so whatever was typed next fell out of the callout, the code card ended there and the closing fence opened a stray empty block below it. The markdown Enter command returns no markup context inside fenced code, and the default newline copies only leading whitespace, which on a quoted line is nothing. The fence Enter command that already handles the opener line (#405) now also takes the content and closing lines of a fence whose opener sits in a quote. The new line gets the opener's prefix with list markers turned into spaces and quote markers kept, the same rule as the opener and the block snippet, plus whatever indentation the line carries past it. Where the quote markup ends is read from the parse, so code that starts with `>` (a `>>>` prompt) is not taken for a marker. Otherwise it behaves like the default newline: whitespace after the caret goes, and a line left holding only its markup is trimmed to it. The closing line keeps the quote too, so the caret stays in the callout after the block. Unquoted fences, several carets, and a caret inside the quote markup are left to the default command, as is Vim normal mode, where Enter is the motion. In a quoted fence the code language's own indentation (after a `{`) is not applied: it counts columns from a line start that holds the `>`. Twelve tests through the editor's real Enter chain, mounted with the note grammar so a ```js body parses as JavaScript, as in EditorPane: content and closing lines, nested quotes, a fence in a quoted list item, extra indentation, a mid-line split, a `>>>` line, a trimmed blank line, Vim off, Vim normal mode, and two controls (a line with no `>` after the quote, and an unquoted fence keeping its bracket indentation). Driven over CDP on a built app with Vim on and off: before, every quoted case typed a bare line and the card broke; after, each keeps `> `, `> > ` or `> ` and the block stays whole. Not changed: Vim `o` and `O` open a bare line on any quoted line, fenced or not. That is a separate change. --- .../src/lib/cm-code-fence-indent.test.ts | 112 +++++++++++++++++- .../app-core/src/lib/cm-code-fence-indent.ts | 85 +++++++++++-- 2 files changed, 184 insertions(+), 13 deletions(-) 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 018af66ec..13d179e6a 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,23 +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: [ ...(withSnippets ? [markdownSnippetExtension()] : []), - vim(), - markdown({ base: markdownLanguage, addKeymap: false }), + ...(vimMode ? [vim()] : []), + note ? noteMarkdown() : markdown({ base: markdownLanguage, addKeymap: false }), vimAwareMarkdownKeymap, - keymap.of([...vimAwareDefaultKeymap(true)]) + keymap.of([...vimAwareDefaultKeymap(vimMode)]) ] }), parent: document.body @@ -127,3 +140,94 @@ describe('block Enter inside quotes and callouts (#961)', () => { 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 6c201b3e6..d940b88ef 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,19 +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 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). - 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 }, @@ -61,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 +} From edea7da018cd647818b884bc080beb60286233a7 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 10:53:10 -0500 Subject: [PATCH 06/15] Fix(cloud): word the website's new answers too The website now words its own refusals (ZenNotes/website bfad4b9), and the translator from 72ab890e matched a few of the old sentences to tell limits apart. With the new text a vault-limit refusal read as a full plan, a publishing attachment over the size limit read as a sync file that "stays on this device", and a backup restore over a limit was told that "some changes are waiting on this device". Limits are now told apart by the dimension Cloud sends with them (sync_active_vaults, publish_*), with the old and the new sentences as the fallback for a service that sends none. Restore refusals ("Restoring this backup ...", "A file in this backup ...") keep the service's own words, as restore-a-note ones already did. GONE, a code the service now sends for a bare 410, keeps its sentence too. The invite-only answer said to choose a plan under Billing. In invite-only mode a plan does not open Cloud, so it now says Cloud is invite-only and the account has not been invited yet, as the service does. Two tests: limits by dimension and by the new sentences, and a 403 UNAUTHENTICATED (an outdated sign-in) treated as signed out. shared-domain 2,020 pass. --- .../src/cloud-error-copy.test.ts | 16 +++++++++++++ .../shared-domain/src/cloud-error-copy.ts | 23 +++++++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/shared-domain/src/cloud-error-copy.test.ts b/packages/shared-domain/src/cloud-error-copy.test.ts index 75b3572d4..70c705e3d 100644 --- a/packages/shared-domain/src/cloud-error-copy.test.ts +++ b/packages/shared-domain/src/cloud-error-copy.test.ts @@ -98,6 +98,22 @@ describe('describeCloudError', () => { .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/) diff --git a/packages/shared-domain/src/cloud-error-copy.ts b/packages/shared-domain/src/cloud-error-copy.ts index 82fb052d0..92cc78fdd 100644 --- a/packages/shared-domain/src/cloud-error-copy.ts +++ b/packages/shared-domain/src/cloud-error-copy.ts @@ -108,7 +108,9 @@ const CONTENT_UNAVAILABLE = 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." -const ACCESS_NOT_ENABLED = "ZenNotes Cloud isn't turned on for this account yet. Choose a plan under Billing on zennotes.org." +// 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 = @@ -200,7 +202,7 @@ const NETWORK_TEXT = 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']) +const PLAIN_SERVER_CODES = new Set(['VAULT_CHANGED', 'BACKUP_QUOTA_EXCEEDED', 'RESTORE_ACCESS_REVOKED', 'GONE']) interface RawCloudError { name: string @@ -305,6 +307,7 @@ function issueMessage(issue: CloudIssue, path: string | null): string { 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 ( @@ -325,16 +328,22 @@ function serviceMessage(raw: RawCloudError): { message: string; kind: CloudError 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 (/vault limit/i.test(raw.text)) return other(VAULT_LIMIT) - if (/publish/i.test(raw.text) || raw.details?.dimension === 'publish') return other(PUBLISH_FULL) - if (/^Restoring this note/i.test(raw.text)) return null + 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 (/published asset/i.test(raw.text)) return other(PUBLISH_FILE_TOO_LARGE) - if (/^This backup note/i.test(raw.text)) return null + 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) From 6db3043d92128f21bec1def846428c923bb7dc04 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 11:20:11 -0500 Subject: [PATCH 07/15] Fix(cloud): a device away past the change log catches up by itself ZenNotes Cloud keeps its change log for 90 days (sync.change_retention_days) and answers 410 CURSOR_EXPIRED to a device whose cursor is older than what is left. No app handled it. The coordinator asked /changes after the same stale cursor on every run and every run failed, and unlinking and relinking did not help, because the desktop keys its sync state by vault folder and server and reused it. A laptop left in a drawer for a season could not sync again, and the honest message from 72ab890e was "choose Report an Issue". The coordinator now catches that answer (status 410, code CURSOR_EXPIRED) in both pulls of a run and rebuilds from the manifest, then follows the log again from the manifest's cursor. It lives in the shared coordinator, so desktop and both phones get it with no host change. A first sync knows nothing about this device's files. This rebuild still knows what sync last agreed on for each, so it replays what the pruned log would have said, item by item, from the difference between the tracked state and the manifest, through the same path the change feed takes. Both repositories already refuse to replace or delete a file whose bytes are not what sync last agreed on, so that guard covers every write: - Changed or renamed in Cloud, untouched here: lands. - Edited here, untouched in Cloud: pushed as an edit on its tracked revision. Deleted here: pushed as a delete. Created here: uploaded. - Edited on both sides: queued for a decision, never overwritten. - New to this device: lands like a first sync. A queued decision moves to the Cloud version it now faces, without touching the file. The order is what the manifest cannot give back: deletes run first so the paths they free are free, then renames, then edits, then arrivals. Whether an arrival is already on disk is judged from a fresh scan, because a delete just before it can take away a file with the same bytes at its path. A chain of renames through each other's paths can come out as conflicts, never as an overwrite. If one file fails midway, what landed is saved at the old cursor, so a retry rebuilds from there and does not redo a change undone since (#813). A file deleted in Cloud and edited here is kept and queued as a delete conflict, as the feed would. The manifest lists only live files, so the revision that deleted it is unknown, and an upsert onto a deleted item needs that revision. Such a conflict carries a null Cloud revision, and keeping this device's version sends it back as a new file. A file deleted on both sides just drops the question. The translator now says sync is catching up by comparing every file with Cloud and that edits here are kept, instead of pointing to Report an Issue. It shows only if a catch-up is cut short. Tests: an expired cursor with no local edits (edit, rename, delete and new file in Cloud, then the log resumes), with local edits (edit, delete, new file, and a file both sides changed), a Cloud delete of a file edited here (keep it, or accept the delete), a queued decision moved forward, a same-bytes file re-created where a deleted one stood, and a failure midway. Also a metadata-first host downloading only the changed file, the reference-mode streaming path, the phones' host service keeping the link, and the desktop repository and state store on disk, once through the real desktop HTTP client reading Cloud's 410 body. typecheck clean; shared-domain 2,030, app-core 3,303, desktop 1,179 pass. Not verified: a built app against a Cloud with a pruned log, and the phone builds. --- .../src/main/cloud-sync-filesystem.test.ts | 148 +++++++++ .../src/cloud-error-copy.test.ts | 5 +- .../shared-domain/src/cloud-error-copy.ts | 5 + .../src/cloud-sync-bootstrap.test.ts | 40 +++ .../src/cloud-sync-coordinator.test.ts | 251 +++++++++++++++ .../src/cloud-sync-coordinator.ts | 286 ++++++++++++++---- .../src/cloud-sync-host-service.test.ts | 25 ++ .../src/cloud-sync-references.test.ts | 44 +++ 8 files changed, 751 insertions(+), 53 deletions(-) diff --git a/apps/desktop/src/main/cloud-sync-filesystem.test.ts b/apps/desktop/src/main/cloud-sync-filesystem.test.ts index d3af0116d..9b98a5c73 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/packages/shared-domain/src/cloud-error-copy.test.ts b/packages/shared-domain/src/cloud-error-copy.test.ts index 70c705e3d..4d7576311 100644 --- a/packages/shared-domain/src/cloud-error-copy.test.ts +++ b/packages/shared-domain/src/cloud-error-copy.test.ts @@ -133,7 +133,10 @@ describe('describeCloudError', () => { const copy = describeCloudError( serviceError('This cursor is older than the retained change log. Rebuild this vault from its manifest.', 410, 'CURSOR_EXPIRED') ) - expect(copy.message).toBe(CLOUD_UNEXPECTED_ANSWER_MESSAGE) + // 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') }) diff --git a/packages/shared-domain/src/cloud-error-copy.ts b/packages/shared-domain/src/cloud-error-copy.ts index 92cc78fdd..f943d5701 100644 --- a/packages/shared-domain/src/cloud-error-copy.ts +++ b/packages/shared-domain/src/cloud-error-copy.ts @@ -102,6 +102,9 @@ 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.' +// Sync rebuilds from the full list by itself; this shows only when that was cut short. +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.` @@ -353,6 +356,8 @@ function serviceMessage(raw: RawCloudError): { message: string; kind: CloudError 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': diff --git a/packages/shared-domain/src/cloud-sync-bootstrap.test.ts b/packages/shared-domain/src/cloud-sync-bootstrap.test.ts index 03c8b3c38..b8dddda50 100644 --- a/packages/shared-domain/src/cloud-sync-bootstrap.test.ts +++ b/packages/shared-domain/src/cloud-sync-bootstrap.test.ts @@ -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-coordinator.test.ts b/packages/shared-domain/src/cloud-sync-coordinator.test.ts index 7451c2323..1a0388926 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.test.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.test.ts @@ -2732,3 +2732,254 @@ 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('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 f1e907f9f..de1962ca0 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -301,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 @@ -476,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) @@ -561,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) @@ -591,14 +599,152 @@ 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, then edits, then files new to this device. A chain + * of renames through each other's paths can come 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) + } + for (const id of moves) { + 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) + } + for await (const item of this.bootstrapItems(manifest, (candidate) => edits.has(candidate.item_id))) { + const before = previous.items[item.item_id] + await land(manifestUpsert(item, manifest.cursor, before && before.path !== item.path ? before.path : null), item) + } + // 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[] = [] @@ -714,13 +860,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) @@ -741,22 +881,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++ } @@ -773,6 +898,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[], @@ -1220,7 +1364,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)) @@ -1267,9 +1413,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) { @@ -1280,21 +1428,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) || @@ -1362,7 +1508,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) } } @@ -1521,6 +1667,19 @@ export class CloudSyncCoordinator { } } +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 } @@ -1739,6 +1898,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] @@ -1924,27 +2087,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-host-service.test.ts b/packages/shared-domain/src/cloud-sync-host-service.test.ts index eef57f8ad..29d6961b0 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.test.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.test.ts @@ -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-references.test.ts b/packages/shared-domain/src/cloud-sync-references.test.ts index 210d46da4..d74d5ef9a 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) }) + }) +}) From cc4cdb5180bcb3bc732d9e14a88b03515cfd1ea2 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 13:23:19 -0500 Subject: [PATCH 08/15] Feat(tasks): Task folders narrow Tasks to the folders you choose (#962) A vault moving into ZenNotes often has checkboxes everywhere. The only way to keep them out of Tasks was to exclude folder after folder, and to remember every new one. The ask was the other direction: one Tasks folder, and nothing else counts. The vault's `tasks` settings gain `includedFolders`, the Task folders, next to `excludedFolders`. Empty means the whole vault, as before. Once it names folders, only the notes inside them feed the Tasks list, boards and calendars, and the excluded list still carves subfolders out of them. The rule lives in the shared `isPathExcludedFromTasks`, whose new third argument is optional, so a caller that passes only the excluded list keeps today's behavior. Desktop main, the MCP server and the Node zn read both lists, and `includeExcluded` / `--include-excluded` scan past both. Settings, Tasks, Checklists shows the Task folders above the excluded folders (one row component serves both now), and the sidebar folder menu, which `m` opens in Vim mode, gains Use as Task Folder / Stop Using as Task Folder next to Exclude from Tasks. Two things came along because a Task folder makes them matter more. The exclusion toggle rebuilt `tasks` from its own list, so excluding a folder would have dropped the Task folders; both toggles now go through one helper that keeps the other list. And a folder rename or move now repoints both lists, in main and in the renderer's copy, because a renamed Task folder left behind would match nothing and empty Tasks. The exclusion list never followed a rename either. Left alone: the phone bridges apply the lists themselves (the iPhone passes only the excluded list, Android neither), so mobile is a follow-up and the in-app help leaves it out for now. An older build that rewrites vault.json rebuilds `tasks` from the fields it knows and drops `includedFolders`. Tests for the rule, the rename rewrite, the toggle helper, the MCP scan and Settings search ("tasks folder" still finds where new task files go). Driven over CDP on a built app: `m` on a folder, a filter and Enter set the Task folder and Tasks dropped every other note; excluding a subfolder inside it kept both lists; Stop Using brought the whole vault back; renaming the folder moved both entries and Tasks kept its contents. Reported by @davi-jorge-art. --- apps/desktop/src/main/tasks.ts | 24 +++- apps/desktop/src/main/vault.ts | 36 ++++-- apps/desktop/src/mcp/instructions.ts | 3 +- apps/desktop/src/mcp/server.ts | 2 +- apps/desktop/src/mcp/vault-ops.test.ts | 37 ++++++ apps/desktop/src/mcp/vault-ops.ts | 41 ++++--- .../src/components/SettingsModal.test.ts | 1 + .../app-core/src/components/SettingsModal.tsx | 111 +++++++++++++----- packages/app-core/src/components/Sidebar.tsx | 17 ++- packages/app-core/src/lib/help.ts | 2 +- .../src/lib/tasks-folder-lists.test.ts | 33 ++++++ .../app-core/src/lib/tasks-folder-lists.ts | 27 +++++ packages/app-core/src/lib/vault-layout.ts | 21 +++- .../src/lib/vault-settings-conflict.ts | 1 + packages/app-core/src/store.ts | 36 +++++- packages/bridge-contract/src/ipc.ts | 11 +- .../src/tasks-excluded-folders.test.ts | 73 +++++++++++- .../src/tasks-excluded-folders.ts | 69 +++++++++-- 18 files changed, 454 insertions(+), 91 deletions(-) create mode 100644 packages/app-core/src/lib/tasks-folder-lists.test.ts create mode 100644 packages/app-core/src/lib/tasks-folder-lists.ts diff --git a/apps/desktop/src/main/tasks.ts b/apps/desktop/src/main/tasks.ts index 240f17cc0..02d3ef555 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 37f086ed9..a1b403d46 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 9043d80d6..695bea03c 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 3a81e651a..a3cd66179 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 a6e4f18f5..f08efdb26 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 e7e3092f4..59a660858 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/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index d5cd5416b..8bea77f36 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -297,6 +297,7 @@ describe("SettingsModal date note directories", () => { ["tasks folder", "Default tasks location", "tasks-location"], ["task folder", "Default tasks location", "tasks-location"], ["new task", "Default tasks location", "tasks-location"], + ["task folders", "Task folders", "tasks-task-folders"], ["drawings folder", "Default drawings location", "drawings-location"], ["database location", "Default databases location", "databases-location"], ] as const; diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index dca1cf7f4..4cfa2bd2b 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -3486,6 +3486,23 @@ export function SettingsModal(): JSX.Element { "old", ], }, + { + id: "tasks-task-folders", + title: "Task folders", + description: + "Collect tasks only from the folders you list and leave every other checkbox alone. Empty means the whole vault. Stored in the vault, honored by every runtime.", + keywords: [ + "task folders", + "only", + "include", + "included", + "limit", + "restrict", + "checklist", + "folder", + "checkbox", + ], + }, { id: "tasks-excluded-folders", title: "Folders excluded from Tasks", @@ -3547,9 +3564,16 @@ export function SettingsModal(): JSX.Element {
- + +
diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 9e5a963e1..fdb380c07 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/lib/help.ts b/packages/app-core/src/lib/help.ts index d889d6a9c..bea565653 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -292,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', 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 000000000..48b697d14 --- /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 000000000..e04660c60 --- /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 722b8b670..9d5d900be 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 e10d898c2..12735768e 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/store.ts b/packages/app-core/src/store.ts index fb5bde008..7b4b0ac83 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/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index bb8c7cb3f..b109fc9dd 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/src/tasks-excluded-folders.test.ts b/packages/shared-domain/src/tasks-excluded-folders.test.ts index 609d0cc47..ddc9ec11d 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 81dc5a3ae..82144c311 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) } + : {}) + } +} From 3e7e23568da1580855a79ce45743d0de58824483 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 14:10:36 -0500 Subject: [PATCH 09/15] Release(cli): bundle the verified 0.9.0 terminal release The bundled CLI is the floor a desktop build guarantees, and 0.9.0 is the newest release. It carries two zn releases into a desktop-managed install from the first launch instead of the first daily update check: 0.8.1, where a finished task note stays in its Custom status column on the TUI's board and `zn mcp` gives assistants the desktop's instructions again, and 0.9.0, which follows the vault's Task folders (#962) in `zn task list`, `zn task watch` and `zn mcp`, and makes the TUI's Settings save both Tasks folder lists under `tasks` where every app reads them. The pin is the release's terminal-release.json copied byte for byte after it verified against packaging/release-signing/zn-release-1.pub in ZenNotes/tui (source commit a0d335c, integration protocol 1, all four archives matching checksums.txt). Staged through `npm run terminal:stage`; the native darwin-arm64 binary reports zn v0.9.0. The in-app manual's CLI card says 0.9.0 and what 0.8.1 and 0.9.0 change. --- apps/desktop/terminal-release.json | 20 ++++++++++---------- packages/app-core/src/lib/help.ts | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index a106126af..ccdd7c80c 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/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index bea565653..38f6ae4c1 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1269,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', From a6b96a65f76018e41df71e0a503eedfce7463816 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 14:11:32 -0500 Subject: [PATCH 10/15] Docs(whats-new): the 2.72.0 highlights What's New opens on the first launch after the update with four highlights. First, Task folders (#962): where to turn one on and what it does to every other checkbox, with where both folder lists live. Second, Cloud errors that say what happened and what to do, and where Details, Copy details and Report an Issue sit. Third, a device back after months away catching up by itself, keeping the edits made while it was away. Fourth, the pickers taking the keyboard (#960) and blocks staying inside a callout (#961). --- packages/app-core/src/lib/whats-new.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index ad27d225a..fd0d82f0b 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: [ From 5a52b4841f5953e97665ecaa30b98a0fb9be9ef5 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 14:11:39 -0500 Subject: [PATCH 11/15] Release: align desktop and shared packages at 2.72.0 --- apps/desktop/package.json | 2 +- apps/quicklook/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 20 ++++++++++---------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6b88af844..776c2f23f 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/quicklook/package.json b/apps/quicklook/package.json index 9408b8e0e..32c08ce75 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 a0954f5ba..27829a49b 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 0670482a5..dd1b07888 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 85e5b3566..d07cdae83 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 7e84fa54d..5355cfbdc 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 21e23d94d..c918d7785 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/bridge-contract/package.json b/packages/bridge-contract/package.json index 9d1311e92..3917ad879 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/shared-domain/package.json b/packages/shared-domain/package.json index 2146969c3..a4fb88330 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-ui/package.json b/packages/shared-ui/package.json index 7fbc6e2d7..b943668a4 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" From 4e2637a41496e570eb1f2a7ad88ef233afd000d1 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 14:20:10 -0500 Subject: [PATCH 12/15] Fix(cloud): a storage or proxy 401 no longer signs the device out A pre-release review found that the error translator (72ab890e) read any 401 as Cloud refusing this device's sign-in. Signing out then revoked the device on the service: logout() sends DELETE /devices/current with the token, which was still valid. Two 401s that are not Cloud's refusal took that path. A presigned direct upload the storage host answered 401 arrives as DIRECT_UPLOAD_FAILED with the storage's status, and a proxy or captive-portal page answering 401 arrives with no Cloud JSON at all. A storage hiccup during a large upload could revoke a device whose sign-in was fine. Only Cloud's API answer signs out now: the code UNAUTHENTICATED (401, or 403 for an outdated sign-in) or ACTIVE_DEVICE_REQUIRED, or a code-less 401 or "Invalid ability provided." 403 whose body was Cloud's JSON. Any other code keeps its own words, so the upload reads as a large file that could not be uploaded and will be tried again. A body that was not Cloud's JSON (the clients' "ZenNotes Cloud request failed (401).", "invalid JSON response" and "unexpected response") reads as an answer from something other than ZenNotes Cloud, such as public Wi-Fi. Found alongside: a 429 or 5xx during the sign-in exchange said "sync will try again in a few minutes", and an unreachable service said "sync picks up where it left off". Sign-in never retries by itself, and the request it answered is already spent. cloudSignInExchangeError words those three as "choose Connect ZenNotes Cloud again" and keeps the technical line. Desktop wraps its exchange fetch with it, and the phones get it through CloudAuthFlow. A refused code keeps the existing "didn't go through" sentence. Tests: a storage 401, three proxy bodies, and the real signed-out cases (401 with and without the code, 403 UNAUTHENTICATED, ACTIVE_DEVICE_REQUIRED), and sign-in wording for 429, 503 and an unreachable service in the translator, the desktop CloudAuthManager (which also stops waiting) and the shared CloudAuthFlow. Typecheck clean; shared-domain 2,043, app-core 3,308 and desktop 1,183 pass, run with other agents' uncommitted edits present in the checkout. Not rebuilt or driven in the app, by request: the release gates run elsewhere. --- apps/desktop/src/main/cloud-auth.test.ts | 34 ++++++++++- apps/desktop/src/main/cloud-auth.ts | 54 +++++++++------- .../shared-domain/src/cloud-auth-flow.test.ts | 11 ++++ packages/shared-domain/src/cloud-auth-flow.ts | 27 +++++--- .../src/cloud-error-copy.test.ts | 38 ++++++++++++ .../shared-domain/src/cloud-error-copy.ts | 61 ++++++++++++++++--- 6 files changed, 183 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/main/cloud-auth.test.ts b/apps/desktop/src/main/cloud-auth.test.ts index 49e40709d..f25e4d98a 100644 --- a/apps/desktop/src/main/cloud-auth.test.ts +++ b/apps/desktop/src/main/cloud-auth.test.ts @@ -7,7 +7,11 @@ import { normalizeCloudBaseUrl, resolveCloudBaseUrl, } from "./cloud-auth"; -import { describeCloudError } from "@zennotes/shared-domain/cloud-error-copy"; +import { + CLOUD_SIGN_IN_OFFLINE_MESSAGE, + CLOUD_SIGN_IN_TROUBLE_MESSAGE, + describeCloudError, +} from "@zennotes/shared-domain/cloud-error-copy"; const temporaryDirectories: string[] = []; @@ -178,6 +182,34 @@ describe("CloudAuthManager", () => { 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"); diff --git a/apps/desktop/src/main/cloud-auth.ts b/apps/desktop/src/main/cloud-auth.ts index e49983e94..7f6701acc 100644 --- a/apps/desktop/src/main/cloud-auth.ts +++ b/apps/desktop/src/main/cloud-auth.ts @@ -11,6 +11,7 @@ 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"; @@ -160,34 +161,41 @@ export class CloudAuthManager { pending: PendingCloudAuth, request: CloudAuthDeepLinkRequest, ): Promise { - const response = await this.fetchImplementation( - `${pending.base_url}/api/v1/app/exchange`, - { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", + 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: worded like any other Cloud answer. + // Busy or broken service: "connect again", since sign-in never retries. if (response.status === 429 || response.status >= 500) { - throw new CloudServiceRequestError( - refusal.message ?? `ZenNotes Cloud sign-in failed (${response.status}).`, - response.status, - refusal.code, + throw cloudSignInExchangeError( + new CloudServiceRequestError( + refusal.message ?? `ZenNotes Cloud sign-in failed (${response.status}).`, + response.status, + refusal.code, + ), ); } throw new Error( diff --git a/packages/shared-domain/src/cloud-auth-flow.test.ts b/packages/shared-domain/src/cloud-auth-flow.test.ts index 15ce1a3d3..085badd9a 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, @@ -145,6 +146,16 @@ describe('CloudAuthFlow', () => { 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 3221251d0..63299057b 100644 --- a/packages/shared-domain/src/cloud-auth-flow.ts +++ b/packages/shared-domain/src/cloud-auth-flow.ts @@ -3,7 +3,11 @@ import type { CloudAccountConnectResult, CloudAccountStatus } from '@zennotes/bridge-contract/cloud-sync' -import { CLOUD_SIGN_IN_EXPIRED_MESSAGE, CLOUD_SIGN_IN_UNEXPECTED_MESSAGE } from './cloud-error-copy' +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 @@ -143,14 +147,19 @@ export class CloudAuthFlow { 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(CLOUD_SIGN_IN_UNEXPECTED_MESSAGE) } diff --git a/packages/shared-domain/src/cloud-error-copy.test.ts b/packages/shared-domain/src/cloud-error-copy.test.ts index 4d7576311..d6417e116 100644 --- a/packages/shared-domain/src/cloud-error-copy.test.ts +++ b/packages/shared-domain/src/cloud-error-copy.test.ts @@ -1,12 +1,16 @@ 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' @@ -66,6 +70,26 @@ describe('describeCloudError', () => { } }) + 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') @@ -216,6 +240,20 @@ describe('describeCloudError', () => { 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 index f943d5701..4cfd45a6e 100644 --- a/packages/shared-domain/src/cloud-error-copy.ts +++ b/packages/shared-domain/src/cloud-error-copy.ts @@ -138,6 +138,12 @@ 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 = @@ -277,6 +283,29 @@ export function describeCloudError(error: unknown, fallback = CLOUD_UNEXPECTED_M 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. @@ -313,14 +342,7 @@ function serviceMessage(raw: RawCloudError): { message: string; kind: CloudError const dimension = typeof raw.details?.dimension === 'string' ? raw.details.dimension : '' const other = (message: string) => ({ message, kind: 'other' as const }) - if ( - code === 'UNAUTHENTICATED' || - code === 'ACTIVE_DEVICE_REQUIRED' || - status === 401 || - (status === 403 && raw.text === 'Invalid ability provided.') - ) { - return { message: CLOUD_SIGNED_OUT_MESSAGE, kind: 'signed-out' } - } + if (isRefusedSignIn(raw)) return { message: CLOUD_SIGNED_OUT_MESSAGE, kind: 'signed-out' } switch (code) { case 'RATE_LIMITED': @@ -398,12 +420,33 @@ function serviceMessage(raw: RawCloudError): { message: string; kind: CloudError if (status === 429) return other(SLOW_DOWN) if (status >= 500) return other(SERVER_TROUBLE) - if (/invalid JSON response/i.test(raw.text)) return other(NOT_CLOUD_ANSWER) + 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) { From 339f613d5375d4089dd8827ada04a529fec96306 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 14:20:23 -0500 Subject: [PATCH 13/15] Fix(editor): Vim normal mode keeps input methods out of every editor (#921) On macOS, with a Chinese or Japanese input method on and Vim mode on, a markdown file opened from outside the vault took the input method's text in normal mode: hjkl made the candidate window flash, and `ia` was typed into the file instead of entering insert mode. Notes in the vault were fine, because the main editor carries the input-method guard from #84 and #464, which makes the editor non-editable outside insert mode so an input method has nowhere to compose. The floating note and Quick capture carry it too. The external-file window, the pinned reference pane and the template editor built Vim without it. All three now install the guard the same way, following the same setting (Keep the input method out of normal mode). A new test reads every app-core source that turns Vim on and fails when one of them leaves the guard out, which is how three editors slipped through. Checked over CDP with a file opened from the command line outside the vault, Vim on, normal mode: on the installed 2.71.0 the editor was contenteditable and an input-method commit (Input.insertText "ia") wrote `ia` into the file; on this build the editor is not editable in normal mode and the same commit changes nothing. Reported by @tonghejiao. --- .../src/components/ExternalFileApp.tsx | 5 ++++ .../src/components/PinnedReferencePane.tsx | 5 ++++ .../src/components/TemplateEditorModal.tsx | 5 ++++ .../src/lib/cm-vim-ime-guard-coverage.test.ts | 27 +++++++++++++++++++ 4 files changed, 42 insertions(+) create mode 100644 packages/app-core/src/lib/cm-vim-ime-guard-coverage.test.ts diff --git a/packages/app-core/src/components/ExternalFileApp.tsx b/packages/app-core/src/components/ExternalFileApp.tsx index 1163bc8dc..e1278f24f 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/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index 68041df63..c9f975654 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -28,6 +28,7 @@ import { import { vim } from '@replit/codemirror-vim' import { history, historyKeymap, indentWithTab } from '@codemirror/commands' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' +import { isTouchPrimaryDevice, vimImeGuard } from '../lib/cm-vim-ime-guard' import { getKeymapBinding, type KeymapOverrides } from '../lib/keymaps' import { keyBindingsFor } from '../lib/vim-half-page-keymap' import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' @@ -241,6 +242,10 @@ export function PinnedReferencePane(): JSX.Element | null { noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: viewPathRef.current })), appMarkdownSnippetExtension(), vimCompartment.of(s0.vimMode ? vim() : []), + // The same input-method guard as the main editor (#84, #464, #921). + vimImeGuard( + () => useStore.getState().vimBlockImeInNormalMode && !isTouchPrimaryDevice() + ), vimVisualHighlightExtension, history(), drawSelectionCompartment.of(cursorDrawSelection(s0.cursorBlink)), diff --git a/packages/app-core/src/components/TemplateEditorModal.tsx b/packages/app-core/src/components/TemplateEditorModal.tsx index 3aa94d8a5..abcdf21dc 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/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 000000000..074c8b360 --- /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([]) + }) +}) From 48a8039d12858af13b3cdc50ae11b91b4e2757bf Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 14:21:12 -0500 Subject: [PATCH 14/15] Fix(cloud): a chain of renames lands in order after an expired cursor A pre-release review found a way for 6db3043d's rebuild to remove a note. While a device was away, Cloud renamed note Y from B.md to C.md, then note X from A.md to B.md. The rebuild cannot see that order and landed renames in tracked-item order, so X could go first, find B.md still held by Y, and queue a move conflict whose local side was Y's file at B.md. Y then moved on to C.md. With nothing left at B.md, the conflict's local side was looked up by hash and landed on Y's file at C.md, so choosing the Cloud version wrote B.md and removed C.md. The next sync sent Y's delete to Cloud and uploaded A.md again as a copy of X. A rename with an edit made the same false conflict, without the loss. Renames, with or without new bytes, now land in passes: one waits while the path it moves to is still the tracked path of a file that is itself leaving, and goes once that file has moved. A queued decision never moves its file, so it holds its path. Only a true ring (A to B, B to C, C to A) cannot be ordered without a temporary name. It lands as before: the repository refuses each move into a taken path, nothing on disk changes, each note is queued for review, and choosing Cloud on each settles the rotation. The no-overwrite promise is unchanged. Also corrects a claim in 6db3043d. It said the translator's CURSOR_EXPIRED words show when a catch-up is cut short. They cannot: the coordinator rebuilds on that answer, and the pull after the rebuild starts at the manifest's cursor, which Cloud never refuses (it refuses only a cursor below changes_pruned_through). A rebuild cut short surfaces its own failure, and the next run rebuilds again. The claim is dropped, not made reachable. The comment now says a current sync never shows the words; they stay for anything else that meets the code. Three tests through the portable repository: the B to C, A to B chain in the order that used to fail (no conflict, nothing pushed, a second sync clean), a longer chain with an edit in the middle, and a three-way ring (queued, files untouched, settled on Cloud, then a clean sync). The two chain tests fail without the ordering. typecheck clean; shared-domain 2,046, app-core 3,308, desktop 1,183 pass. --- .../shared-domain/src/cloud-error-copy.ts | 4 +- .../src/cloud-sync-coordinator.test.ts | 70 +++++++++++++++++++ .../src/cloud-sync-coordinator.ts | 59 ++++++++++++---- 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/packages/shared-domain/src/cloud-error-copy.ts b/packages/shared-domain/src/cloud-error-copy.ts index 4cfd45a6e..5c01ec288 100644 --- a/packages/shared-domain/src/cloud-error-copy.ts +++ b/packages/shared-domain/src/cloud-error-copy.ts @@ -102,7 +102,9 @@ 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.' -// Sync rebuilds from the full list by itself; this shows only when that was cut short. +// 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 = diff --git a/packages/shared-domain/src/cloud-sync-coordinator.test.ts b/packages/shared-domain/src/cloud-sync-coordinator.test.ts index 1a0388926..6a4a09760 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.test.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.test.ts @@ -2954,6 +2954,76 @@ describe('CloudSyncCoordinator: a cursor older than the change log (CURSOR_EXPIR 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 diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index de1962ca0..44d735c0a 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -635,9 +635,9 @@ export class CloudSyncCoordinator { * 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, then edits, then files new to this device. A chain - * of renames through each other's paths can come out as conflicts, never as - * an overwrite. + * 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) @@ -706,17 +706,50 @@ export class CloudSyncCoordinator { revision: previous.items[id]?.revision ?? pending[id]?.cloud.revision ?? 0 }, undefined) } - for (const id of moves) { - 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) + // 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 } - for await (const item of this.bootstrapItems(manifest, (candidate) => edits.has(candidate.item_id))) { - const before = previous.items[item.item_id] - await land(manifestUpsert(item, manifest.cursor, before && before.path !== item.path ? before.path : null), item) + 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 From 3792c985eeb5c0d5f60c20be34898397901b5ae3 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 14:24:35 -0500 Subject: [PATCH 15/15] Fix(assets): a pasted screenshot is named for when you pasted it (#775) Every image pasted from the clipboard landed in assets/ as image.png, then image 2.png, image 3.png and on. The naming already falls back to `Pasted Image