From a0ab671ca57120e1041d2ba7654e4c3e0ea407f4 Mon Sep 17 00:00:00 2001 From: Syed-Perssonify Date: Tue, 20 Oct 2026 10:08:19 +0530 Subject: [PATCH] feat: add version badge component and shared document notices - Implemented a version badge component to display the app's version and branch information. - Created a shared document notice component to inform users about shared documents and provide options to make them editable. - Developed a share dialog for sharing documents and folders with various access options. - Added utility functions for handling native context menus. - Introduced a shared badge component to indicate the sharing status of documents in the library. - Created database schema for published documents to manage live shares effectively. --- CLAUDE.md | 7 + apps/api/src/app.ts | 3 +- apps/api/src/modules/sync/routes/index.ts | 167 ++++- apps/api/tests/integration/sync.test.ts | 136 +++- apps/web/CHANGELOG.md | 27 +- apps/web/CLAUDE.md | 70 +- apps/web/ONBOARDING.md | 4 +- apps/web/build/build-info.d.mts | 6 + apps/web/build/build-info.mjs | 36 + apps/web/docs/database.md | 4 +- apps/web/docs/domain-glossary.md | 2 +- apps/web/docs/features.md | 8 +- apps/web/docs/local-development.md | 2 +- apps/web/package.json | 2 +- apps/web/src/App.tsx | 50 +- apps/web/src/EditorHeader.tsx | 2 + apps/web/src/PerssonifyEditor.tsx | 42 +- .../__tests__/admin-prefs-fallback.test.js | 29 +- apps/web/src/__tests__/cloud-sync.test.js | 190 ++++- .../src/__tests__/converter-linear.test.js | 4 +- .../src/__tests__/fixtures/marked-parity.json | 19 +- apps/web/src/__tests__/folder-reorder.test.js | 49 ++ .../src/__tests__/g4-ads-admin-gates.test.jsx | 4 +- .../src/__tests__/library-panel-perf.test.jsx | 20 +- .../src/__tests__/list-heading-trap.test.js | 99 +++ apps/web/src/__tests__/live-share.test.js | 171 +++++ .../__tests__/native-context-menu.test.jsx | 53 ++ .../web/src/__tests__/pkg-cjs-require.test.js | 4 +- apps/web/src/__tests__/share-dialog.test.jsx | 97 +++ apps/web/src/app/cloud/api.ts | 30 +- apps/web/src/app/cloud/engine.ts | 171 ++++- apps/web/src/app/cloud/index.ts | 122 +++- apps/web/src/app/publish/api.ts | 105 +++ apps/web/src/app/publish/index.ts | 325 +++++++++ apps/web/src/components/ui/dialog.tsx | 94 +++ apps/web/src/constants.ts | 9 + apps/web/src/editor/props.ts | 22 +- apps/web/src/editor/useAutosave.ts | 30 + apps/web/src/editor/useCardHandlers.ts | 8 + apps/web/src/editor/useEditorBoot.ts | 5 + apps/web/src/editor/useExportActions.ts | 43 +- apps/web/src/editor/useImportPipeline.ts | 4 +- apps/web/src/editor/useSharedDocLock.ts | 55 ++ apps/web/src/engine/preprocessor.ts | 72 ++ apps/web/src/engine/turndown-rules.ts | 46 +- apps/web/src/engine/useMarkdownEngine.ts | 6 +- apps/web/src/i18n/en/account.ts | 3 + apps/web/src/i18n/en/context.ts | 1 + apps/web/src/i18n/en/dialogs.ts | 27 + apps/web/src/i18n/en/editor.ts | 1 + apps/web/src/i18n/en/library.ts | 13 +- apps/web/src/i18n/en/menu.ts | 2 +- apps/web/src/i18n/en/notices.ts | 17 + apps/web/src/i18n/en/settings.ts | 4 +- apps/web/src/i18n/en/status.ts | 3 + apps/web/src/i18n/en/tour.ts | 2 +- apps/web/src/store/documents/document.ts | 12 +- apps/web/src/store/documents/folders.ts | 77 +- .../src/store/documents/live-share-meta.ts | 51 ++ apps/web/src/store/documents/live-share.ts | 49 ++ apps/web/src/store/history/snapshot-store.ts | 1 + apps/web/src/store/importExport/share-link.ts | 16 + apps/web/src/store/preferences/uiStore.ts | 25 +- .../src/ui/chrome/editorContextMenu/index.tsx | 16 +- .../chrome/keyboardShortcutsDialog/index.tsx | 98 +-- apps/web/src/ui/chrome/statusBar/index.tsx | 5 + apps/web/src/ui/chrome/versionBadge/index.tsx | 45 ++ .../ui/history/versionHistorySheet/index.tsx | 1 + .../ui/notices/durabilityNotices/index.tsx | 7 +- apps/web/src/ui/notices/noticeStrip/index.tsx | 2 +- .../src/ui/notices/sharedDocNotice/index.tsx | 105 +++ apps/web/src/ui/pages/aboutPage/index.tsx | 8 +- .../src/ui/settings/settingsSheet/index.tsx | 14 +- apps/web/src/ui/share/shareDialog/index.tsx | 215 ++++++ .../ui/shared/utils/native-context-menu.ts | 27 + .../web/src/ui/workspace/folderTree/index.tsx | 96 ++- .../src/ui/workspace/sharedBadge/index.tsx | 43 ++ .../src/ui/workspace/workspacePanel/index.tsx | 93 ++- apps/web/vite.app.config.ts | 5 + bun.lock | 2 +- packages/db/drizzle/0002_publish.sql | 13 + packages/db/drizzle/meta/0002_snapshot.json | 683 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 5 +- packages/db/src/schema/publish.ts | 30 + packages/db/test/migrations.test.ts | 2 +- 86 files changed, 4032 insertions(+), 248 deletions(-) create mode 100644 apps/web/build/build-info.d.mts create mode 100644 apps/web/build/build-info.mjs create mode 100644 apps/web/src/__tests__/list-heading-trap.test.js create mode 100644 apps/web/src/__tests__/live-share.test.js create mode 100644 apps/web/src/__tests__/native-context-menu.test.jsx create mode 100644 apps/web/src/__tests__/share-dialog.test.jsx create mode 100644 apps/web/src/app/publish/api.ts create mode 100644 apps/web/src/app/publish/index.ts create mode 100644 apps/web/src/components/ui/dialog.tsx create mode 100644 apps/web/src/editor/useSharedDocLock.ts create mode 100644 apps/web/src/store/documents/live-share-meta.ts create mode 100644 apps/web/src/store/documents/live-share.ts create mode 100644 apps/web/src/ui/chrome/versionBadge/index.tsx create mode 100644 apps/web/src/ui/notices/sharedDocNotice/index.tsx create mode 100644 apps/web/src/ui/share/shareDialog/index.tsx create mode 100644 apps/web/src/ui/shared/utils/native-context-menu.ts create mode 100644 apps/web/src/ui/workspace/sharedBadge/index.tsx create mode 100644 packages/db/drizzle/0002_publish.sql create mode 100644 packages/db/drizzle/meta/0002_snapshot.json create mode 100644 packages/db/src/schema/publish.ts diff --git a/CLAUDE.md b/CLAUDE.md index 24e6495..8abb300 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,13 @@ break). Every path in it is relative to `apps/web`. missing locally is downloaded again. Server writes are conditional on the client's `base` revision under a row lock (`apps/api/src/modules/sync/routes/index.ts`). The client half is tested against the real server half by `apps/web/src/__tests__/cloud-contract.test.js`. +- **`GET /api/published/:id` is the API's one public read of user content** + (live shares, PL-51): no session, the link IS the permission — the id is 128 + random bits (`newPublishedId`), there is no listing, and it serves only the copy + the owner published (`users//published/.json.gz`, indexed by the + `published_document` table, migration `0002_publish`), never their synced notes. + Publishing and unpublishing are `/api/sync/published/:docId`, behind the sync + middleware like every other write. - **No code here may ever delete a database.** The RDS instance and the secret holding its password are `RemovalPolicy.RETAIN` with deletion protection (`infra/test/stack.test.ts` pins both): a failed deploy's rollback, a stack diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 010eaff..12ccd08 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,7 +6,7 @@ import { requireOriginSecret } from "./middlewares/originVerify/index.ts"; import { authRoutes } from "./modules/auth/index.ts"; import { healthRoutes } from "./modules/health/index.ts"; import { importRelayRoutes } from "./modules/importRelay/index.ts"; -import { syncRoutes, type SyncDeps } from "./modules/sync/index.ts"; +import { publishedRoutes, syncRoutes, type SyncDeps } from "./modules/sync/index.ts"; export interface AppDeps { env: Env; @@ -36,6 +36,7 @@ export function createApp({ env, pingDatabase, pingStorage, auth, sync }: AppDep app.route("/", healthRoutes({ pingDatabase, pingStorage })); app.route("/", authRoutes(auth, missingAuthSettings(env))); app.route("/", syncRoutes(sync ?? { db: null, storage: null, userIdOf: null }) as unknown as Hono); + app.route("/", publishedRoutes(sync ?? { db: null, storage: null })); app.notFound((c) => c.json({ error: "not_found" }, 404)); app.onError((err, c) => { diff --git a/apps/api/src/modules/sync/routes/index.ts b/apps/api/src/modules/sync/routes/index.ts index 7e8f44f..1158b4d 100644 --- a/apps/api/src/modules/sync/routes/index.ts +++ b/apps/api/src/modules/sync/routes/index.ts @@ -2,7 +2,7 @@ import type { Database } from "@markdown-studio/db"; import { schema } from "@markdown-studio/db"; import { and, eq, inArray } from "drizzle-orm"; import { Hono, type Context } from "hono"; -import { randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; import { gunzipSync, gzipSync } from "node:zlib"; import { z } from "zod"; import type { Storage } from "../../../lib/storage/index.ts"; @@ -11,7 +11,8 @@ import type { Storage } from "../../../lib/storage/index.ts"; * /api/sync — each signed-in user's library in the cloud: documents, the * images in them, and the folder tree. * - * GET /api/sync/state every document's revision (tombstones too) + the folder tree + * GET /api/sync/state?since= every document's revision (tombstones too) + the folder tree, + * and its `version` — or { unchanged } when that is still `since` * GET /api/sync/documents/:id one document (gzip JSON) * PUT /api/sync/documents/:id store one { base, record } (gzip JSON body) * DELETE /api/sync/documents/:id?base=&at= delete one (kept as a tombstone) @@ -24,6 +25,9 @@ import type { Storage } from "../../../lib/storage/index.ts"; * POST /api/sync/backups/upload a presigned upload for one day's backup ZIP * GET /api/sync/backups the backups kept * GET /api/sync/backups/:date a presigned download of one + * PUT /api/sync/published/:docId publish (or update) a document's live share { revision, title, document } → { id } + * DELETE /api/sync/published/:docId stop publishing it + * GET /api/published/:id?since= a live share, for anyone with its link (publishedRoutes below) * * Every write is CONDITIONAL: the client says which server revision its change * is based on (`base`), and a row lock makes the check and the write one step. @@ -81,6 +85,28 @@ const BackupUpload = z.object({ force: z.boolean().optional(), }); +/** A live share's public id: 128 random bits, base64url (`newPublishedId`). */ +export const PUBLISHED_ID = /^[A-Za-z0-9_-]{22}$/; +/** A published copy carries its images inline, so it may be larger than a synced note's record. */ +export const MAX_PUBLISHED_BYTES = 32 * 1024 * 1024; +const PutPublished = z.object({ + /** The document revision (`updated`) this copy was made from. */ + revision: iso, + title: z.string().max(500), + /** The document as the editor exports it (the `perssonify-document` envelope). */ + document: z + .object({ _format: z.literal("perssonify-document"), canonical: z.object({ type: z.literal("doc") }).passthrough() }) + .passthrough(), + /** + * UPDATE ONLY: the live share's public id, which must still be this + * document's. The sync engine republishes with it, so a republish racing + * "Stop sharing live" cannot bring the share back under a new id. Absent + * (the Share dialog): publish, creating the share if there is none. + */ + id: z.string().regex(/^[A-Za-z0-9_-]{22}$/).optional(), +}); +const newPublishedId = () => randomBytes(16).toString("base64url"); + /** At most this many versions are listed for one note. */ export const MAX_LISTED_VERSIONS = 50; const MIN_VERSION_GAP_MS = 5 * 60_000; @@ -104,7 +130,32 @@ export function thinVersions(newestFirst: T[ return kept; } +/** + * What `GET /api/sync/state` describes, as one short string: every document's + * revision and tombstone, and the folder tree's revision — exactly what a + * device plans its sync from. Equal versions mean the same plan, so a device + * that sends back the version it holds (`?since=`) is told "unchanged" instead + * of being sent the whole list again. Order-independent (sorted by id). + */ +export function stateVersion( + docs: { id: string; updated: string; deleted: boolean }[], + libraryUpdated: string | null, + published: { docId: string; id: string; revision: string }[] = [], +): string { + const h = createHash("sha256"); + const byKey = (key: (x: T) => string) => (a: T, b: T) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0); + for (const d of [...docs].sort(byKey((x) => x.id))) { + h.update(`${d.id}\u0000${d.updated}\u0000${d.deleted ? 1 : 0}\n`); + } + h.update(`library\u0000${libraryUpdated ?? ""}\n`); + for (const p of [...published].sort(byKey((x) => x.docId))) { + h.update(`published\u0000${p.docId}\u0000${p.id}\u0000${p.revision}\n`); + } + return h.digest("base64url").slice(0, 32); +} + const docKey = (userId: string, docId: string) => `users/${userId}/documents/${docId}.json.gz`; +const publishedKey = (userId: string, docId: string) => `users/${userId}/published/${docId}.json.gz`; const assetKey = (userId: string, hash: string) => `users/${userId}/assets/${hash}`; const backupPrefix = (userId: string) => `users/${userId}/backups/`; const backupKey = (userId: string, date: string) => `${backupPrefix(userId)}${date}.zip`; @@ -135,6 +186,15 @@ export function syncRoutes({ db, storage, userIdOf }: SyncDeps): Hono { const D = schema.syncDocument; const A = schema.syncAsset; const L = schema.syncLibrary; + const P = schema.publishedDocument; + type Tx = Parameters[0]>[0]; + /** Stop publishing a document (if it is): its row and its public copy. */ + const unpublish = async (tx: Tx, userId: string, docId: string) => { + const [row] = await tx.select({ id: P.id }).from(P).where(and(eq(P.userId, userId), eq(P.docId, docId))).for("update"); + if (!row) return; + await tx.delete(P).where(eq(P.id, row.id)); + await storage!.remove(publishedKey(userId, docId)); + }; app.get("/api/sync/state", async (c: Ctx) => { const userId = c.get("userId"); @@ -143,7 +203,15 @@ export function syncRoutes({ db, storage, userIdOf }: SyncDeps): Hono { .from(D) .where(eq(D.userId, userId)); const [lib] = await db!.select({ tree: L.folderTree, updated: L.updated }).from(L).where(eq(L.userId, userId)); - return c.json({ documents: rows, library: lib ?? null }); + // The documents this user publishes, and which revision each live copy + // holds: a device that has a newer one updates it (the sync engine). + const published = await db!.select({ docId: P.docId, id: P.id, revision: P.revision }).from(P).where(eq(P.userId, userId)); + // A device polls this every few seconds while it is in use. When nothing + // changed since the state it last read (`?since=` the version it was + // given), the answer is just that — not the whole manifest again. + const version = stateVersion(rows, lib?.updated ?? null, published); + if (c.req.query("since") === version) return c.json({ unchanged: true, version }); + return c.json({ documents: rows, library: lib ?? null, published, version }); }); app.get("/api/sync/documents/:id", async (c: Ctx) => { @@ -203,8 +271,12 @@ export function syncRoutes({ db, storage, userIdOf }: SyncDeps): Hono { if (!DOC_ID.test(id) || Number.isNaN(Date.parse(at))) return c.json({ error: "bad_request" }, 400); return db!.transaction(async (tx) => { const [row] = await tx.select().from(D).where(and(eq(D.userId, userId), eq(D.docId, id))).for("update"); + if (row && row.updated !== base) return c.json({ error: "conflict", current: { updated: row.updated, deleted: row.deleted } }, 409); + // A deleted note is no longer shared live: its public copy goes with it, + // here, in the deletion itself — not left to a device that may be + // offline, or closed, by the time it would have asked. + await unpublish(tx, userId, id); if (!row) return c.json({ deleted: true }); - if (row.updated !== base) return c.json({ error: "conflict", current: { updated: row.updated, deleted: row.deleted } }, 409); // The bucket is versioned: the content stays recoverable for 30 days. await storage!.remove(docKey(userId, id)); await tx.update(D).set({ deleted: true, updated: at, size: 0, modifiedAt: new Date() }).where(and(eq(D.userId, userId), eq(D.docId, id))); @@ -358,5 +430,92 @@ export function syncRoutes({ db, storage, userIdOf }: SyncDeps): Hono { return c.json({ url: await storage!.presignGet(key, { filename: `markdown-studio-backup-${date}.zip` }) }); }); + // ── Publishing (live shares) ────────────────────────────────────────────── + // The owner's device uploads a read-only copy of one document — the editor's + // own export envelope, images inline — and gets back the public id its link + // carries. Every device of the owner keeps it current: `state` lists what is + // published and from which revision, and a device holding a newer one + // publishes again. Followers read it through publishedRoutes, below. + + app.put("/api/sync/published/:docId", async (c: Ctx) => { + const userId = c.get("userId"); + const docId = c.req.param("docId") ?? ""; + if (!DOC_ID.test(docId)) return c.json({ error: "bad_id" }, 400); + let parsed: z.infer; + try { + const raw = Buffer.from(await c.req.arrayBuffer()); + const text = (c.req.header("content-encoding") === "gzip" ? gunzipSync(raw, { maxOutputLength: MAX_PUBLISHED_BYTES }) : raw).toString("utf8"); + if (Buffer.byteLength(text) > MAX_PUBLISHED_BYTES) return c.json({ error: "too_large" }, 413); + parsed = PutPublished.parse(JSON.parse(text)); + } catch { + return c.json({ error: "bad_document" }, 400); + } + const title = parsed.title.slice(0, 500); + return db!.transaction(async (tx) => { + let id: string; + if (parsed.id) { + // Update only: the share must still exist, under that id. + const [row] = await tx.select({ id: P.id }).from(P).where(and(eq(P.userId, userId), eq(P.docId, docId))).for("update"); + if (!row || row.id !== parsed.id) return c.json({ error: "not_published" }, 409); + id = row.id; + } else { + // Create or keep: one statement, so two first publishes at once agree + // on one id (the second waits on the first's row) instead of the loser + // hitting the unique index. + const [row] = await tx + .insert(P) + .values({ id: newPublishedId(), userId, docId, title, revision: parsed.revision }) + .onConflictDoUpdate({ target: [P.userId, P.docId], set: { modifiedAt: new Date() } }) + .returning({ id: P.id }); + id = row!.id; + } + const body = gzipSync(Buffer.from(JSON.stringify({ v: 1, id, title, revision: parsed.revision, document: parsed.document }), "utf8")); + await storage!.put(publishedKey(userId, docId), body, { contentType: "application/json", contentEncoding: "gzip" }); + await tx.update(P).set({ title, revision: parsed.revision, size: body.length, modifiedAt: new Date() }).where(eq(P.id, id)); + return c.json({ id, revision: parsed.revision }); + }); + }); + + app.delete("/api/sync/published/:docId", async (c: Ctx) => { + const userId = c.get("userId"); + const docId = c.req.param("docId") ?? ""; + if (!DOC_ID.test(docId)) return c.json({ error: "bad_id" }, 400); + return db!.transaction(async (tx) => { + await unpublish(tx, userId, docId); + return c.json({ deleted: true }); + }); + }); + + return app; +} + +/** + * GET /api/published/:id — a live share, for anyone holding its link (no + * sign-in: the link IS the permission, and its id is 128 random bits). A + * follower polls with `?since=` the revision it has, and while the owner has + * not changed the document the answer is a few bytes. Unpublished: 404, and + * the follower keeps its last copy as its own. + */ +export function publishedRoutes({ db, storage }: Pick): Hono { + const app = new Hono(); + app.get("/api/published/:id", async (c) => { + c.header("Cache-Control", "no-store"); + if (!db || !storage) return c.json({ error: "not_configured" }, 503); + // `not_published` — not the app's generic `not_found` — is what tells a + // follower the owner stopped sharing it, so a 404 from anything else (a + // route missing mid-deploy) is not mistaken for that. + const id = c.req.param("id") ?? ""; + if (!PUBLISHED_ID.test(id)) return c.json({ error: "not_published" }, 404); + const P = schema.publishedDocument; + const [row] = await db.select({ userId: P.userId, docId: P.docId, revision: P.revision }).from(P).where(eq(P.id, id)); + if (!row) return c.json({ error: "not_published" }, 404); + if (c.req.query("since") === row.revision) return c.json({ unchanged: true, revision: row.revision }); + const obj = await storage.get(publishedKey(row.userId, row.docId)); + if (!obj) return c.json({ error: "not_published" }, 404); + return new Response(obj.body, { + status: 200, + headers: { "Content-Type": "application/json", "Content-Encoding": "gzip", "Cache-Control": "no-store", "X-Revision": row.revision }, + }); + }); return app; } diff --git a/apps/api/tests/integration/sync.test.ts b/apps/api/tests/integration/sync.test.ts index e8b26af..0a34183 100644 --- a/apps/api/tests/integration/sync.test.ts +++ b/apps/api/tests/integration/sync.test.ts @@ -6,7 +6,7 @@ import { createHash } from "node:crypto"; import { gunzipSync, gzipSync } from "node:zlib"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createStorage, type Storage } from "../../src/lib/storage/index.ts"; -import { MAX_DOCUMENT_BYTES, MAX_LISTED_VERSIONS, SYNC_HEADER, SYNC_HEADER_VALUE, thinVersions } from "../../src/modules/sync/index.ts"; +import { MAX_DOCUMENT_BYTES, MAX_LISTED_VERSIONS, stateVersion, SYNC_HEADER, SYNC_HEADER_VALUE, thinVersions } from "../../src/modules/sync/index.ts"; import { testApp } from "../fixtures/index.ts"; describe("the versions listed", () => { @@ -27,6 +27,24 @@ describe("the versions listed", () => { }); }); +describe("the state version a device polls with", () => { + const docs = [ + { id: "a", updated: "2026-10-05T10:00:00.000Z", deleted: false }, + { id: "b", updated: "2026-10-05T11:00:00.000Z", deleted: true }, + ]; + it("does not depend on the order the rows come back in", () => { + expect(stateVersion(docs, "rev1")).toBe(stateVersion([...docs].reverse(), "rev1")); + }); + it("changes with any revision, tombstone or the folder tree", () => { + const v = stateVersion(docs, "rev1"); + expect(stateVersion([docs[0]!, { ...docs[1]!, deleted: false }], "rev1")).not.toBe(v); + expect(stateVersion([{ ...docs[0]!, updated: "2026-10-05T10:00:01.000Z" }, docs[1]!], "rev1")).not.toBe(v); + expect(stateVersion(docs, "rev2")).not.toBe(v); + expect(stateVersion(docs, null)).not.toBe(v); + expect(stateVersion(docs.slice(0, 1), "rev1")).not.toBe(v); + }); +}); + describe("/api/sync without its dependencies", () => { it("is 503 until the database, storage and sign-in are configured", async () => { const res = await testApp().request("/api/sync/state"); @@ -123,6 +141,23 @@ describe.skipIf(!dbServer || !s3Endpoint)("/api/sync", () => { expect(results.map((r) => r.status).sort()).toEqual([200, 409]); }); + it("answers a poll with 'unchanged' while nothing has changed since the version it was given", async () => { + const full = (await (await app.request("/api/sync/state", { headers: as("alice") })).json()) as { version: string; documents: unknown[] }; + expect(full.version).toEqual(expect.any(String)); + expect(full.documents.length).toBeGreaterThan(0); + const poll = async (since: string) => (await (await app.request(`/api/sync/state?since=${encodeURIComponent(since)}`, { headers: as("alice") })).json()) as Record; + expect(await poll(full.version)).toEqual({ unchanged: true, version: full.version }); + // Any change — here a write — moves the version, and the poll gets the state again. + const current = (full.documents as { id: string; updated: string }[]).find((d) => d.id === "d1")!.updated; + expect((await put("alice", "d1", current, record("d1", "2026-10-05T10:30:00.000Z", "third"))).status).toBe(200); + const after = await poll(full.version); + expect(after.unchanged).toBeUndefined(); + expect(after.version).not.toBe(full.version); + expect((after.documents as { id: string; updated: string }[]).find((d) => d.id === "d1")!.updated).toBe("2026-10-05T10:30:00.000Z"); + // A stale or made-up version is simply a full answer. + expect((await poll("nope")).documents).toBeDefined(); + }); + it("keeps each user's library to themselves", async () => { expect((await get("bob", "d1")).res.status).toBe(404); expect((await state("bob")).documents).toEqual([]); @@ -276,4 +311,103 @@ describe.skipIf(!dbServer || !s3Endpoint)("/api/sync", () => { expect((await post("alice", "upload", { assets: [{ hash: "nothex", mime: "image/png", size: 10 }] })).status).toBe(400); }); }); + + describe("live shares", () => { + const envelope = (text: string) => ({ _format: "perssonify-document", title: "Shared", canonical: record("p1", "2026-10-05T10:00:00.000Z", text).canonical }); + const publish = (user: string, docId: string, revision: string, text: string, headers: Record = as(user, { "Content-Type": "application/json" })) => + app.request(`/api/sync/published/${docId}`, { method: "PUT", headers, body: JSON.stringify({ revision, title: "Shared", document: envelope(text) }) }); + const read = async (id: string, since?: string) => { + const res = await app.request(`/api/published/${id}${since ? `?since=${encodeURIComponent(since)}` : ""}`); + const gz = res.headers.get("content-encoding") === "gzip"; + const body = res.status === 200 ? JSON.parse((gz ? gunzipSync(Buffer.from(await res.arrayBuffer())) : Buffer.from(await res.arrayBuffer())).toString()) : null; + return { res, body }; + }; + + it("publishing needs the owner signed in, and the request header", async () => { + expect((await publish("alice", "p1", "2026-10-05T10:00:00.000Z", "x", { [SYNC_HEADER]: SYNC_HEADER_VALUE, "Content-Type": "application/json" })).status).toBe(401); + expect((await publish("alice", "p1", "2026-10-05T10:00:00.000Z", "x", { "x-test-user": "alice", "Content-Type": "application/json" })).status).toBe(403); + }); + + it("publishes a document anyone with the link can read, and lists it in the owner's state", async () => { + const res = await publish("alice", "p1", "2026-10-05T10:00:00.000Z", "first"); + expect(res.status).toBe(200); + const { id, revision } = (await res.json()) as { id: string; revision: string }; + expect(id).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(revision).toBe("2026-10-05T10:00:00.000Z"); + const { res: got, body } = await read(id); // no sign-in + expect(got.status).toBe(200); + expect(got.headers.get("x-revision")).toBe(revision); + expect(body).toMatchObject({ v: 1, id, title: "Shared", revision, document: { _format: "perssonify-document" } }); + expect(body.document.canonical.content[0].content[0].text).toBe("first"); + const st = (await (await app.request("/api/sync/state", { headers: as("alice") })).json()) as { published: unknown[] }; + expect(st.published).toEqual([{ docId: "p1", id, revision }]); + expect(((await (await app.request("/api/sync/state", { headers: as("bob") })).json()) as { published: unknown[] }).published).toEqual([]); + }); + + it("answers a follower that is up to date with 'unchanged'; an update keeps the link", async () => { + const st = (await (await app.request("/api/sync/state", { headers: as("alice") })).json()) as { published: { id: string; revision: string }[] }; + const { id, revision } = st.published[0]!; + expect((await read(id, revision)).body).toEqual({ unchanged: true, revision }); + const again = (await (await publish("alice", "p1", "2026-10-05T11:00:00.000Z", "second")).json()) as { id: string }; + expect(again.id).toBe(id); + const { body } = await read(id, revision); + expect(body.revision).toBe("2026-10-05T11:00:00.000Z"); + expect(body.document.canonical.content[0].content[0].text).toBe("second"); + }); + + it("keeps each owner's publications to themselves", async () => { + const bobs = (await (await publish("bob", "p1", "2026-10-05T12:00:00.000Z", "bob's")).json()) as { id: string }; + const st = (await (await app.request("/api/sync/state", { headers: as("alice") })).json()) as { published: { id: string }[] }; + expect(bobs.id).not.toBe(st.published[0]!.id); + expect((await read(st.published[0]!.id)).body.document.canonical.content[0].content[0].text).toBe("second"); + }); + + it("stops when unpublished: the link is gone, and a racing republish cannot bring it back", async () => { + const st = (await (await app.request("/api/sync/state", { headers: as("alice") })).json()) as { published: { id: string }[] }; + const id = st.published[0]!.id; + expect((await app.request("/api/sync/published/p1", { method: "DELETE", headers: as("alice") })).status).toBe(200); + const gone = await read(id); + expect(gone.res.status).toBe(404); + expect(await gone.res.json()).toEqual({ error: "not_published" }); // what tells a follower it ended + // A device's sync, still holding the old id, republishes: refused. + const republish = await app.request("/api/sync/published/p1", { + method: "PUT", + headers: as("alice", { "Content-Type": "application/json" }), + body: JSON.stringify({ id, revision: "2026-10-05T13:00:00.000Z", title: "Shared", document: envelope("late") }), + }); + expect(republish.status).toBe(409); + expect(((await (await app.request("/api/sync/state", { headers: as("alice") })).json()) as { published: unknown[] }).published).toEqual([]); + }); + + it("two first publishes of a note at once agree on one link", async () => { + const [a, b] = await Promise.all([ + publish("alice", "p3", "2026-10-05T10:00:00.000Z", "a"), + publish("alice", "p3", "2026-10-05T10:00:01.000Z", "b"), + ]); + expect([a.status, b.status]).toEqual([200, 200]); + expect(((await a.json()) as { id: string }).id).toBe(((await b.json()) as { id: string }).id); + }); + + it("deleting the note itself takes its live share down with it", async () => { + const rec = record("p4", "2026-10-05T10:00:00.000Z", "shared, then deleted"); + expect((await put("alice", "p4", null, rec)).status).toBe(200); + const { id } = (await (await publish("alice", "p4", rec.updated, "shared, then deleted")).json()) as { id: string }; + expect((await read(id)).res.status).toBe(200); + const del = await app.request(`/api/sync/documents/p4?base=${encodeURIComponent(rec.updated)}&at=2026-10-05T14:00:00.000Z`, { method: "DELETE", headers: as("alice") }); + expect(del.status).toBe(200); + expect((await read(id)).res.status).toBe(404); + }); + + it("refuses bad input, and unknown or malformed links", async () => { + expect((await app.request("/api/published/not-a-real-id")).status).toBe(404); + expect((await app.request("/api/published/AAAAAAAAAAAAAAAAAAAAAA")).status).toBe(404); + const bad = await app.request("/api/sync/published/p2", { + method: "PUT", + headers: as("alice", { "Content-Type": "application/json" }), + body: JSON.stringify({ revision: "2026-10-05T10:00:00.000Z", title: "x", document: { _format: "something-else", canonical: { type: "doc" } } }), + }); + expect(bad.status).toBe(400); + expect((await publish("alice", "-bad", "2026-10-05T10:00:00.000Z", "x")).status).toBe(400); + }); + }); }); diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md index 5be73cb..abd7ee9 100644 --- a/apps/web/CHANGELOG.md +++ b/apps/web/CHANGELOG.md @@ -10,7 +10,32 @@ and every feature commit subject after v1.35 carries its `(vX.Y.Z)` tag. > [`docs/v1-to-current-changelog.md`](docs/v1-to-current-changelog.md) — the full, > area-grouped feature delta from the initial import ("v1") to the current release. -## [Unreleased] +## [1.78.0] — unreleased + +### PL-51 — bugs and tweaks (19 October 2026) +- **Lists no longer turn into headings.** Starting a nested item under a list + item in the markdown pane (Enter, Backspace, `-`; or Tab on a new `- `) made + the item's text a heading and pushed it out of the list in the rich pane. An + empty item made with Enter in the rich pane now shows in the markdown too. +- **The keyboard-shortcuts list fits the screen** and closes with ×, Close, + Escape or a click outside. +- **Library: sort and create anywhere.** A ⇅ menu sorts the library by Last + edited, Name, Date created or a Custom order you drag (dragging while sorted by + name or date switches to Custom). Right-click anywhere in it for New document + and New folder. +- **Spelling suggestions are back.** Shift + right-click opens the browser's own + menu; Settings ▸ Editor ▸ Custom right-click menu turns the app's menu off. +- **Sync across devices is quicker and cheaper**, and two tabs on the same note + no longer overwrite each other: polling follows use (every 5 s while things are + changing, 15 s on screen, 5 min in the background, at once when you come back), + an unchanged account costs a few bytes, a save in one tab is never undone by a + sync running in another, and switching tabs saves first. +- **Share, three ways.** The Share dialog picks the layout a link opens in + (Split, Markdown or HTML) and what the recipient gets: an editable copy, a + read-only copy, or a **live** read-only copy that follows your edits — the + library marks it, with a dot when it has updated. +- **The version is shown** in the header and the status bar (with the branch + and commit on a preview build), and on the About page. An external production-readiness audit of 1.77.0 (18 September 2026) raised 114 findings. This release fixes 90 of them in code and does the code side of 17 diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index cc8f499..15a9a4d 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -445,12 +445,19 @@ Finding IDs cited as `A0xx` come from the 2026-09 production-readiness audit; - **Admin settings** (`SettingsSheet` "Admin" section) are operator/debug toggles, **hidden unless `adminSettingsAvailable()`** (`utils/build-gates.ts`: a dev build, or `localStorage["pf:admin"] === "1"`, a per-browser opt-in for support sessions — - A081): `contextMenuEnabled` (`pf:context-menu`; gates the custom - `EditorContextMenu`, off → native browser menu), `consoleEnabled` (`pf:console`, - **default off**; gates the diagnostics Console tray tab), and Show ads (dev builds - only). **While the section is hidden those switches read their DEFAULTS** - (`uiStore`), or a value stored before the gate existed would stick with no control - left to change it — `admin-prefs-fallback.test.js`. Add new admin-only modules here. + A081): `consoleEnabled` (`pf:console`, **default off**; gates the diagnostics + Console tray tab), and Show ads (dev builds only). **While the section is hidden + those switches read their DEFAULTS** (`uiStore`), or a value stored before the gate + existed would stick with no control left to change it — `admin-prefs-fallback.test.js`. + Add new admin-only modules here. `contextMenuEnabled` (`pf:context-menu`) USED to be + one; it is an everyday switch now, Settings ▸ Editor ▸ **Custom right-click menu**, + honoured in every build (PL-51: off → the browser's own menu, which is the only + route to its spelling suggestions). **Shift + right-click always reaches the + browser's menu** over the editor's and the library's custom ones + (`passShiftContextMenuToBrowser`, `ui/shared/utils/native-context-menu.ts`: the + event is stopped in the CAPTURE phase, before Base UI's trigger and its document + listener cancel the default), and the editor's menu ends with that hint — + `native-context-menu.test.jsx`. - **Diagnostics console** (`src/ui/diagnostics/consolePanel/index.tsx` + `src/store/diagnostics/console-commands.ts`): an admin-gated (`consoleEnabled`) bottom-tray tab running a **CURATED, READ-ONLY** command registry (`CONSOLE_COMMANDS` + `runConsoleCommand`) over EXISTING read @@ -763,8 +770,20 @@ Finding IDs cited as `A0xx` come from the 2026-09 production-readiness audit; over), and a single `DropdownMenu` whose ⋮ triggers are DETACHED (a Base UI `createHandle` handle + a row payload), mounted only while open. Two menu roots per row were most of what froze the editor at a few hundred documents. The - context menu honors the admin `contextMenuEnabled` flag (off → no trigger, native - menu), mirroring `EditorContextMenu`. Do NOT duplicate row-menu item definitions + context menu honors the `contextMenuEnabled` setting (off → no trigger, native + menu), mirroring `EditorContextMenu`. **Right-click anywhere in the Files list + creates** (PL-51): a document row's menu starts with New document / New folder + (in that document's folder), a folder's with New document / New subfolder, and a + right-click on blank space — a second ContextMenu around the list's scroller in + `WorkspacePanel`, which the tree's own trigger stops before it — offers New + document / New folder at the top level (`CreateItems`, `LibraryBackgroundMenuItems`). + **Library order is a per-browser preference** (`pf:library-sort`, the ⇅ menu in the + Files header, `LibrarySort` in `store/documents/folders.ts`): `updated` (default — + the original order: drag order first, folders A–Z, notes newest-edited), `name` + (A–Z, numbers in order), `created` (newest first; a folder's time is in its id) and + `manual` (drag order, the rest in the order they were made, so nothing re-sorts). + `name` and `created` ignore drag order (it is kept), so dragging to reorder in + them switches to `manual` — `folder-reorder.test.js`. Do NOT duplicate row-menu item definitions per primitive — extend the shared body. A folder dropped on a document row lands in that document's folder (`docRowDrop`) — the drop used to bubble to the root handler and move it to the top level (A066). **Per-document export** (`onExportDoc` → @@ -1156,7 +1175,21 @@ Finding IDs cited as `A0xx` come from the 2026-09 production-readiness audit; documents. Google returns to `/` on success and `/login?error=` on failure (`signInTargets`), both trusted origins of the API (`CORS_ORIGINS`). - **Cloud sync** (`src/app/cloud/`, signed-in only): documents, their images and the - folder tree, to the account (API `/api/sync`, content in S3). **The engine works + folder tree, to the account (API `/api/sync`, content in S3). **Polling is cheap and + follows use** (`index.ts`): every 5 s for a minute after anything moved, every 15 s + while the page is on screen, every 5 min behind other windows, and at once when the + page is shown again, focused or back online. Each cycle sends the `version` of the + state it last read (`state(since)`, engine `serverState`); while it still matches, + the API answers `{ unchanged }` (`stateVersion`, a digest of every revision, the + tree and the live shares) and the engine plans from its cached copy. A cycle that + finds the lock busy RETRIES shortly (it used to claim "synced" and drop the change + that woke it), and the day's backup runs under its own lock, never inside a sync + cycle. **Nothing a cycle writes over is newer than what it planned from**: a pull or + a remote deletion first re-checks the local revision (`stillPlanned`) — a save that + landed mid-cycle, in any tab, is never overwritten (`again` re-plans) — and the + pending-deletion list is MERGED back (`settlePending`), never replaced by the copy + the cycle loaded. The editor also SAVES on blur / page hide (`useAutosave`), so the + same note open in two tabs rarely meets "Changed in another tab". **The engine works like ANOTHER TAB**: its own `IndexedDBStorageAdapter` on the app's database, and what it changes is announced on the tab-sync channel (a second BroadcastChannel, `from: "cloud-sync:…"`), so the editor's tested multi-tab paths do the rest — a @@ -1186,7 +1219,24 @@ Finding IDs cited as `A0xx` come from the 2026-09 production-readiness audit; reads so the library and embed never import app code): the bucket's past versions of the open note, THINNED by the API (`thinVersions`) so they span the 30 days; bringing one back records an `"account-version"` snapshot and restores THAT, so it - is undone like any restore. The avatar is the initials on `--primary`, never the + is undone like any restore. **Live shares** (`src/app/publish/`, PL-51) ride on it: + the Share dialog (`ui/share/shareDialog`) publishes a READ-ONLY copy of one note + (`buildLiveDocument`: the export envelope with its images, without the owner's id or + metadata) to `PUT /api/sync/published/:docId`, and `/#live=` opens it for + anyone — `GET /api/published/:id` needs no sign-in; the id is 128 random bits. The + sync engine REPUBLISHES a published note whose local revision moved + (`syncPublished`; the state lists `published`) and unpublishes one it deletes. A + follower's copy is an ordinary note with `metadata.liveShare` (`live-share-meta.ts`): + it opens read-only (`useSharedDocLock` — the session flag, on transitions only), the + library marks it (`SharedDocBadge`, a dot while an update is unseen), and + `startFollowing` checks for new versions (`?since=`), keeps the replaced copy as a + `"live-update"` snapshot and writes the OWNER's revision, so every device following + it agrees; a 404 ends it (the note becomes the user's own). Read-only share links + (`#share=` with `readOnly`, `metadata.readOnlyShare`) share the lock and the notice + (`SharedDocNotice`); `view` sets the layout a share opens in. A live copy carries its + images inside, so it is refused above 4 MB (a Vercel Function's request limit). + Tests: `live-share`, `share-dialog`, `cloud-sync` (republish) and the API's + `sync.test.ts`. The avatar is the initials on `--primary`, never the Google picture. Tests: `cloud-plan`, `cloud-folder-merge`, `cloud-sync` (two devices, in-memory account), `cloud-backup`, `cloud-versions` and `cloud-contract` (this code against the REAL API, Postgres and S3 — skipped without diff --git a/apps/web/ONBOARDING.md b/apps/web/ONBOARDING.md index 8c2c931..e3e7fbc 100644 --- a/apps/web/ONBOARDING.md +++ b/apps/web/ONBOARDING.md @@ -373,7 +373,7 @@ Every await is followed by a generation check, so an older switch can't hydrate | `canonical` | **Layer 2.** ProseMirror JSON, the source of truth. `sanitizeCanonicalNode` (`src/store/documents/canonical-sanitize.ts`) strips dangerous URLs and validates colours, fonts, alignment and theme-image sources at the import and storage-load boundary. | | `residuals` | **Layer 3.** `ResidualItem[]`: what ingest could not represent. Shown in `ResidualsDialog`. | | `ingestReport` | Fidelity summary. May include `preIngestHtml` (up to 50K characters). | -| `metadata`, `tags` | Values from `:::fields:::` blocks, the card fields `cardColor`/`cardPinned`, and tags. | +| `metadata`, `tags` | Values from `:::fields:::` blocks, the card fields `cardColor`/`cardPinned`, a shared note's `liveShare`/`readOnlyShare` (`store/documents/live-share-meta.ts`), and tags. | | `created`, `updated` | ISO timestamps. The adapter re-stamps `updated` on every save unless `_preserveUpdated` is set. | | `schemaVersion` | The canonical format version it was written in. It is never downgraded. Content with a newer version opens read-only. | @@ -421,7 +421,7 @@ The manifest row, `DocumentSummary` (`:83-92`), is `{id, title, updated, created These are on-disk contracts. Do not rename them. - **localStorage:** - - `pf:*` UI prefs in `src/store/preferences/uiStore.ts`: workspace, paste-styled, case-content-only, show-ads (it has an effect only in development builds), read-only, context-menu, console, keyboard-shortcuts, font-size, line-height, reading-width, spellcheck, focus-mode, docnav-width, workspace-tab. + - `pf:*` UI prefs in `src/store/preferences/uiStore.ts`: workspace, paste-styled, case-content-only, show-ads (it has an effect only in development builds), read-only, context-menu, console, keyboard-shortcuts, font-size, line-height, reading-width, spellcheck, focus-mode, docnav-width, workspace-tab, library-sort. - Find/Replace `pf:fr-*`, `pf:card-view`, `pf:tour-seen-v1`. - Durability (`src/store/persistence/storage-durability.ts`): `pf:library-canary`, `pf:last-full-backup`, `pf:content-since`, `pf:backup-nudge-snoozed-until`, `pf:persist-requested-at`, `pf:install-tip-snoozed-until`. - Privacy and gates: `pf:import-relay` (the relay consent), `pf:block-remote-images`, `pf:admin` (shows the Admin settings in a production build). diff --git a/apps/web/build/build-info.d.mts b/apps/web/build/build-info.d.mts new file mode 100644 index 0000000..b86f85b --- /dev/null +++ b/apps/web/build/build-info.d.mts @@ -0,0 +1,6 @@ +/** Types for build/build-info.mjs (plain ESM so node-run tests can import it). */ +export interface BuildInfo { + branch: string | null; + commit: string | null; +} +export function buildInfo(opts?: { env?: Record; cwd?: string }): BuildInfo; diff --git a/apps/web/build/build-info.mjs b/apps/web/build/build-info.mjs new file mode 100644 index 0000000..b45732c --- /dev/null +++ b/apps/web/build/build-info.mjs @@ -0,0 +1,36 @@ +// Which branch and commit a build of the standalone app was made from, for the +// version shown in its header and status bar (src/constants.ts BUILD_INFO). +// +// The host's own variables first — Vercel checks the repository out WITHOUT +// its .git history in some setups, and a CI runner may be on a detached HEAD — +// then git itself; null when neither is there (a tarball, a sandbox). Plain +// ESM so the Vite configs and node-run tests can both import it. +import { execFileSync } from "node:child_process"; + +/** Run git quietly; null when git or the repository is not there. */ +function git(args, cwd) { + try { + const out = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim(); + return out || null; + } catch { + return null; + } +} + +/** + * `{ branch, commit }` — the commit shortened to 7 characters, the way git and + * GitHub show it. `env` and `cwd` are parameters for tests. + */ +export function buildInfo({ env = process.env, cwd = process.cwd() } = {}) { + const branch = + env.VERCEL_GIT_COMMIT_REF || + env.GITHUB_HEAD_REF || // a pull request's source branch + env.GITHUB_REF_NAME || + git(["rev-parse", "--abbrev-ref", "HEAD"], cwd); + const sha = env.VERCEL_GIT_COMMIT_SHA || env.GITHUB_SHA || git(["rev-parse", "HEAD"], cwd); + return { + // A detached checkout reports "HEAD", which names no branch. + branch: branch && branch !== "HEAD" ? branch : null, + commit: sha ? sha.slice(0, 7) : null, + }; +} diff --git a/apps/web/docs/database.md b/apps/web/docs/database.md index 8331ca8..3634822 100644 --- a/apps/web/docs/database.md +++ b/apps/web/docs/database.md @@ -77,7 +77,7 @@ erDiagram | `residuals` | `ResidualItem[]` | **Layer 3:** content ingest couldn't represent (unknown elements, styles, attributes, HTML comments) | | `ingestReport` | object | Per-ingest summary: processed tags, dropped/transformed counts, fidelity score | | `markdownCache` / `htmlCache` / `fidelityReport` | derived | Recomputed; **not persisted** | -| `metadata` | `Record` | Free-form; field-block values and a card's colour/pin (`cardColor`/`cardPinned`) live here | +| `metadata` | `Record` | Free-form; field-block values, a card's colour/pin (`cardColor`/`cardPinned`) and a shared note's flags (`liveShare`, `readOnlyShare` — `store/documents/live-share-meta.ts`) live here | | `tags` | `string[]` | Searchable tags | | `created` / `updated` | ISO string | Timestamps. `updated` is also the revision the multi-tab guard compares | | `schemaVersion` | number | The canonical format the content was written in (absent on older records) — see below | @@ -134,7 +134,7 @@ These are enforced by code, not by the store: 1. **Every `__manifest` entry has a matching `documents` record, and vice-versa.** The IndexedDB adapter writes a record and the manifest in ONE transaction (`save`/`delete`), rebuilding a missing or unreadable manifest inside that transaction before applying the change, and every listing checks the manifest against the ids actually stored (`sameIdSet`, [src/store/persistence/idb-storage-adapter.ts:266](../src/store/persistence/idb-storage-adapter.ts#L266)) and rebuilds it from the documents when they differ (A030). A desync heals on the next listing instead of surviving reloads. (The `window.storage` adapter cannot enumerate its keys, so it keeps an unreadable list aside under `doc:__manifest.unreadable` instead.) 2. **Derived fields are never trusted from storage.** `markdownCache`/`htmlCache`/`fidelityReport` are not written and are recomputed on read. -3. **`canonical` is always a ProseMirror `doc`.** Ingest guarantees this; the default is `{ type:"doc", content:[{ type:"paragraph" }] }` (`createDocument`, [src/store/documents/document.ts:123](../src/store/documents/document.ts#L123)). Content that does not fit this build's schema opens read-only (see `schemaVersion` above). +3. **`canonical` is always a ProseMirror `doc`.** Ingest guarantees this; the default is `{ type:"doc", content:[{ type:"paragraph" }] }` (`createDocument`, [src/store/documents/document.ts:129](../src/store/documents/document.ts#L129)). Content that does not fit this build's schema opens read-only (see `schemaVersion` above). 4. **The limit is the browser's quota, and the browser enforces it.** A write it refuses (`QuotaExceededError`) is translated to `StorageQuotaError` ([src/store/persistence/idb-storage-adapter.ts:88](../src/store/persistence/idb-storage-adapter.ts#L88)), which the save path turns into the terminal status `full` ("Storage full — not saving"). With a host `maxBytes`, the cap is checked inside the write transaction against the real current usage, *before* anything is written. There is no default cap — the old 50 MB one measured characters × 2 and stopped libraries at ~18 MB of markdown (A086). 5. **A failed read is an error, never a miss** (the `window.storage` adapter excepted, see above). `get()` throws `StorageReadError` ([src/store/persistence/idb-storage-adapter.ts:118](../src/store/persistence/idb-storage-adapter.ts#L118)) — `corrupt` for a record that is present but will not parse — and the save path fails closed on it: it writes nothing and retries, so the blank-over-content backstop, the multi-tab check and the pre-overwrite snapshot can never mistake an unreadable document for an absent one (A029). A revision-only read (`getRevision`) fails closed the same way, and a save from a BLANK editor always reads the record whole: the blank backstop judges against storage, never against a memory of it. 6. **IDB transaction callbacks may only await `idbRequest()` calls** — awaiting an arbitrary Promise inside `idbTransaction()` aborts the transaction (browser auto-commit rule). See the comment at [src/store/persistence/idb-storage-adapter.ts:229](../src/store/persistence/idb-storage-adapter.ts#L229). This is a real footgun if you add storage code. diff --git a/apps/web/docs/domain-glossary.md b/apps/web/docs/domain-glossary.md index c943b6f..a274c16 100644 --- a/apps/web/docs/domain-glossary.md +++ b/apps/web/docs/domain-glossary.md @@ -6,7 +6,7 @@ Plain-language definitions of the vocabulary you'll hit reading this codebase, e An **external** (non-wiki) link edge between documents, supplied by an embedding host rather than parsed from `[[…]]` content. Stored separately and merged into the graph. → [src/integration/associations.ts](../src/integration/associations.ts), `mergeGraphWithAssociations` ([:171](../src/integration/associations.ts#L171)). Contrast with [Wiki link](#wiki-link). ### Autosave -Trailing-timer save of the active document to IndexedDB, `AUTOSAVE_MS` (5000 ms, [src/constants.ts:63](../src/constants.ts#L63)). → `saveActiveDoc` ([src/store/documents/documentStore.ts:348](../src/store/documents/documentStore.ts#L348)), which resolves `true` only when the write committed. Three save statuses are terminal — autosave does not re-arm on them: `blocked` (a blank editor over stored content, or a document from a newer build), `conflict` (another tab saved newer content) and `full` (the browser's storage quota is exhausted). +Trailing-timer save of the active document to IndexedDB, `AUTOSAVE_MS` (5000 ms, [src/constants.ts:80](../src/constants.ts#L80)). → `saveActiveDoc` ([src/store/documents/documentStore.ts:348](../src/store/documents/documentStore.ts#L348)), which resolves `true` only when the write committed. Three save statuses are terminal — autosave does not re-arm on them: `blocked` (a blank editor over stored content, or a document from a newer build), `conflict` (another tab saved newer content) and `full` (the browser's storage quota is exhausted). ### Backlink The reverse of a wiki link: documents that link **to** the current one. Stored in the link index's `reverse` map. → [src/store/links/link-index.ts:250](../src/store/links/link-index.ts#L250), surfaced in [src/ui/links/backlinksPanel/index.tsx](../src/ui/links/backlinksPanel/index.tsx). diff --git a/apps/web/docs/features.md b/apps/web/docs/features.md index 17fc60f..70fa8c1 100644 --- a/apps/web/docs/features.md +++ b/apps/web/docs/features.md @@ -6,7 +6,7 @@ The single most-read doc after the [README](README.md). Each feature is self-con **Where state lives** (referenced constantly below): - `useDocumentStore` — [src/store/documents/documentStore.ts:879](../src/store/documents/documentStore.ts#L879) — documents, active doc, tabs, save status, counts, storage handle. -- `useUIStore` — [src/store/preferences/uiStore.ts:153](../src/store/preferences/uiStore.ts#L153) — view mode, sidebar/graph/find toggles, sync mode. +- `useUIStore` — [src/store/preferences/uiStore.ts:159](../src/store/preferences/uiStore.ts#L159) — view mode, sidebar/graph/find toggles, sync mode. - `useEditorStore` — [src/store/editor/editorStore.ts:92](../src/store/editor/editorStore.ts#L92) — active formatting state, link index, backlinks, graph data. --- @@ -33,7 +33,7 @@ The single most-read doc after the [README](README.md). Each feature is self-con - **What:** Show only the markdown pane, only the rich-text pane, or both side by side. - **Who:** Any user, at any width. Below 768px wide, Split stacks the panes one above the other — but only when there is height for two (500px or more), or on a touch device held upright; a landscape phone keeps them side by side rather than squeezing two editors into a strip (`splitShouldStack`, [src/ui/panes/editorPanes/split-layout.ts](../src/ui/panes/editorPanes/split-layout.ts)). -- **Entry point:** `ModeSwitcher` ([src/ui/chrome/modeSwitcher/index.tsx:34](../src/ui/chrome/modeSwitcher/index.tsx#L34)) → `useUIStore.setMode`. Mode constants: `MODES` = `{ MD, HTML, SPLIT }` ([src/constants.ts:34](../src/constants.ts#L34)). +- **Entry point:** `ModeSwitcher` ([src/ui/chrome/modeSwitcher/index.tsx:34](../src/ui/chrome/modeSwitcher/index.tsx#L34)) → `useUIStore.setMode`. Mode constants: `MODES` = `{ MD, HTML, SPLIT }` ([src/constants.ts:51](../src/constants.ts#L51)). - **Flow:** `setMode(m)` → `useUIStore.mode` updates → `EditorLayout` shows the panes for that mode → `PerssonifyEditor` persists it as `editorMode` in prefs through `usePersistPreferences` ([src/editor/useEditorBoot.ts:30](../src/editor/useEditorBoot.ts#L30)). - **Edge cases:** the toolbar compacts when its OWN width drops below `TOOLBAR_COMPACT_BP` (1080px, measured with a `ResizeObserver`, so a library mount in a narrow container compacts too — [src/constants.ts](../src/constants.ts)); the ☰ menu collapses below 768px ([src/hooks/use-mobile.ts](../src/hooks/use-mobile.ts)); the split stack follows the width-and-height rule above. Below 450px of height the standalone shell lets the page scroll instead of shrinking the editor to nothing (usable at 400% zoom). @@ -126,7 +126,7 @@ The single most-read doc after the [README](README.md). Each feature is self-con - **Clean** — the style carrier is dropped, so text colour, highlight and font family are stripped; output is portable GFM. - **Styled** — the carrier is preserved inline, so those three survive the round trip. - **Neither mode touches** underline, subscript, superscript or an image's width/height: their rules live in `addBaseRules`, which both Turndown instances receive, so they are written as ``/``/``/`` either way. **Nor does either mode carry text alignment**, which has no rule at all. Measured per case in [src/__tests__/clean-vs-styled.test.js](../src/__tests__/clean-vs-styled.test.js); `fidelity.ts` classifies from these same rule tables, and got it wrong in both directions before that test existed. -- **Entry point:** Pills in the pane header (`SyncModePills`, [src/ui/panes/editorPanes/index.tsx:88](../src/ui/panes/editorPanes/index.tsx#L88)); state `useUIStore.syncMode` (defaults to `"styled"`, [src/store/preferences/uiStore.ts:168](../src/store/preferences/uiStore.ts#L168)). +- **Entry point:** Pills in the pane header (`SyncModePills`, [src/ui/panes/editorPanes/index.tsx:88](../src/ui/panes/editorPanes/index.tsx#L88)); state `useUIStore.syncMode` (defaults to `"styled"`, [src/store/preferences/uiStore.ts:174](../src/store/preferences/uiStore.ts#L174)). - **Where the branch lives:** where the sync engine reads the rich pane (`readRich`), `convert` picks the converter ([src/engine/useSyncEngine.ts:38](../src/engine/useSyncEngine.ts#L38)) — `mode === "styled" ? eng.htmlToMd : eng.htmlToCleanMd`, and the per-block converter (`docToMd`) is handed the same mode. The two converters are two Turndown instances built by `createMarkdownEngine` ([src/engine/useMarkdownEngine.ts:234](../src/engine/useMarkdownEngine.ts#L234)): `tdInline` (styled, adds the inline-style rule) vs `tdClean` (base rules only). Both share the markdown escaper, which backslash-escapes literal text that would otherwise turn into markup on the next pass (a `<` before a tag-like character, `&` before an entity, `N)` at a line start — and, only while their feature is on, a `$` exactly where it would open a formula, `~~`, and a recognised curly group). Ordinary prose such as `a < b`, `Tom & Jerry` or `$5` stays as typed. - **Paste Styled:** a **persisted preference, on by default** ([src/ui/panes/editorPanes/index.tsx](../src/ui/panes/editorPanes/index.tsx), `pf:paste-styled`) — pasting keeps the source's formatting. It used to default to stripping and disarm itself after one paste; that one-shot reset was removed, since with styled as the default it turned the preference off the first time it was used. **Holding ⇧ inverts it for a single paste.** The modifier does not come from the paste event (`ClipboardEvent` carries no `shiftKey` — measured); `TiptapPane.handlePaste` reads `view.input.shiftKey` and passes it explicitly. - **Related:** [data-pipeline.md](data-pipeline.md), [domain-glossary.md#clean-mode--styled-mode](domain-glossary.md#clean-mode--styled-mode). @@ -164,7 +164,7 @@ The single most-read doc after the [README](README.md). Each feature is self-con ### 12. Auto-save & session restore - **What:** Edits persist automatically; the last active document reopens on reload. -- **Entry point:** Autosave interval `AUTOSAVE_MS` (5000 ms, [src/constants.ts:63](../src/constants.ts#L63)). Restore: `useDocumentStore.initialize()` ([src/store/documents/documentStore.ts:326](../src/store/documents/documentStore.ts#L326)) reads the manifest and the `lastActiveDocId` pref. It is re-entrant: an editor that is unmounted and mounted again (inside a host app) re-opens the open document for the new instance instead of seeding another copy, and unmounting is itself a document-leaving path that flushes and stashes what was typed. +- **Entry point:** Autosave interval `AUTOSAVE_MS` (5000 ms, [src/constants.ts:80](../src/constants.ts#L80)). Restore: `useDocumentStore.initialize()` ([src/store/documents/documentStore.ts:326](../src/store/documents/documentStore.ts#L326)) reads the manifest and the `lastActiveDocId` pref. It is re-entrant: an editor that is unmounted and mounted again (inside a host app) re-opens the open document for the new instance instead of seeding another copy, and unmounting is itself a document-leaving path that flushes and stashes what was typed. - **Data:** Writes `doc` records + `__manifest` + `__prefs` to IndexedDB — see [database.md](database.md). - **Storage limit:** the app imposes no cap of its own — the limit is the browser's quota (read with `navigator.storage.estimate()`), plus an optional host `maxBytes`. A write the browser refuses for lack of space becomes the terminal status `full` (**"Storage full — not saving"**): autosave stops retrying until a document is deleted or a real edit is made, and the toolbar's Save can retry once space is freed. See [runbooks/storage-quota-exceeded.md](runbooks/storage-quota-exceeded.md). - **Reads that fail are errors, not "not found".** An adapter's `get()` returns null only for a genuine miss and throws `StorageReadError` otherwise, so the save path fails closed — it writes nothing over a document it could not read — and boot skips an unreadable document instead of stopping. The document list is checked against the stored ids and rebuilt from the documents when it is missing, unreadable or out of step. diff --git a/apps/web/docs/local-development.md b/apps/web/docs/local-development.md index 439e54d..9f9f79b 100644 --- a/apps/web/docs/local-development.md +++ b/apps/web/docs/local-development.md @@ -37,7 +37,7 @@ Everything else is decided by the build itself: `import.meta.env.DEV` ([src/util |------|-------|-------| | Sync idle delay | `SYNC_MIN_DELAY_MS`…`SYNC_MAX_DELAY_MS` = 120…800 ([src/constants.ts](../src/constants.ts)) | derived from measured projection cost, not doc size | | Sync max-wait ceiling | `SYNC_MIN_MAX_WAIT_MS`…`SYNC_MAX_MAX_WAIT_MS` = 250…1200 ([src/constants.ts](../src/constants.ts)) | how far the other pane may lag while you keep typing | -| Autosave interval | `AUTOSAVE_MS` = 5000 ([src/constants.ts:63](../src/constants.ts#L63)) | | +| Autosave interval | `AUTOSAVE_MS` = 5000 ([src/constants.ts:80](../src/constants.ts#L80)) | | | Toolbar compact breakpoint | `TOOLBAR_COMPACT_BP = 1080` ([src/constants.ts](../src/constants.ts)) | | | IndexedDB name / limit | `perssonify-docs` (`DEFAULT_DB_NAME`, [src/store/persistence/idb-storage-adapter.ts:159](../src/store/persistence/idb-storage-adapter.ts#L159)) | no cap of its own — the browser's quota, unless a host passes `storageConfig.maxBytes`; see [database.md](database.md) | | Theme storage key | `perssonify-theme` (next-themes `storageKey`, [src/main.tsx:61](../src/main.tsx#L61)) | localStorage | diff --git a/apps/web/package.json b/apps/web/package.json index ab4ef52..955e70b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@perssonify/editor", - "version": "1.77.0", + "version": "1.78.0", "description": "Markdown Studio — a private, offline-capable dual-pane Markdown editor for focused writing and serious Markdown.", "keywords": [ "markdown", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fe583e9..e868866 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,10 +5,12 @@ import { AccountCheckScreen } from "./app/account/accountCheckScreen"; import { AccountMenu } from "./app/account/accountMenu"; import { SignInNudge } from "./app/account/signInNudge"; import { prepareCloudLibrary, startCloudSync, useCloudStatus } from "./app/cloud"; +import { liveSharesApi, prepareLiveLink, readLiveSeed, startFollowing, type LiveLinkOutcome } from "./app/publish"; import { useAppRouter } from "./app/router"; import { APP_NAME, MODES } from "./constants"; import { useUIStore } from "./store/preferences/uiStore"; -import { readSharePayloadFromHash } from "./store/importExport/share-link"; +import { readSharePayloadFromHash, type ShareView } from "./store/importExport/share-link"; +import { toast as toastSonner } from "sonner"; import { DEFAULT_CONTENT } from "./constants"; import { STARTER_DOCUMENTS } from "./data/starter-documents"; import { EditorHeader } from "./EditorHeader"; @@ -84,6 +86,35 @@ export function App() { if (typeof window === "undefined") return undefined; return readSharePayloadFromHash() ?? undefined; }); + /** A LIVE share link (`#live=`): fetched before the editor opens — it + * is imported as a followed copy, or the copy followed here is opened + * (app/publish). Only in builds with the account server, which serves it. */ + const [liveSeed] = useState(() => (accountsEnabled() ? readLiveSeed() : null)); + const [liveOutcome, setLiveOutcome] = useState(null); + useEffect(() => { + if (!liveSeed) return; + let cancelled = false; + void prepareLiveLink(liveSeed.id, liveSeed.view).then((o) => { if (!cancelled) setLiveOutcome(o); }); + return () => { cancelled = true; }; + }, [liveSeed]); + // A live link that could not be opened says so once the editor (and its + // toasts) is up — the editor opens normally underneath. + useEffect(() => { + if (liveOutcome?.kind !== "failed") return; + const message = liveOutcome.gone ? t("account.liveShareGone") : t("account.liveShareUnreachable"); + const show = () => { setTimeout(() => toastSonner.error(message, { duration: 6000 }), 400); }; + if (useUIStore.getState().editorReady) { show(); return; } + const unsub = useUIStore.subscribe((s) => { if (s.editorReady) { unsub(); show(); } }); + return unsub; + }, [liveOutcome, t]); + // The layout a shared link asks for (`view`), for this visit only — the + // same hand-off to the editor's boot as `?view_mode=` below. + useEffect(() => { + const view: ShareView | undefined = shareSeed?.view ?? liveSeed?.view; + if (!view) return; + useUIStore.getState().setMode(view === "md" ? MODES.MD : view === "html" ? MODES.HTML : MODES.SPLIT); + try { sessionStorage.setItem("pf:session-view-mode", view); } catch { /* ignore */ } + }, [shareSeed, liveSeed]); // Config deep-links: apply ?ads=, ?readonly=, and ?view_mode= (md|html|split) // on load, then strip them from the URL. `ads`/`readonly` are applied for the @@ -126,13 +157,16 @@ export function App() { } catch { /* ignore */ } }, []); - // Strip `#share=…` from the address after it was captured (shareSeed), so a - // refresh does not re-import the shared documents. Any other hash is kept. + // Strip `#share=…` / `#live=…` (and its `view`) from the address after it + // was captured (shareSeed, liveSeed), so a refresh does not import the + // shared documents again. Any other hash is kept. useEffect(() => { - if (typeof window === "undefined" || !/[#&]share=/.test(window.location.hash)) return; + if (typeof window === "undefined" || !/[#&](share|live)=/.test(window.location.hash)) return; try { const params = new URLSearchParams(window.location.hash.replace(/^#/, "")); params.delete("share"); + params.delete("live"); + params.delete("view"); const rest = params.toString(); window.history.replaceState({}, "", window.location.pathname + window.location.search + (rest ? `#${rest}` : "")); } catch { /* ignore */ } @@ -170,6 +204,9 @@ export function App() { useEffect(() => { if (accounts) void useAccount.getState().refresh(); }, [accounts]); + // Live documents followed in this browser: kept up to date while the app + // is open, signed in or not (app/publish). + useEffect(() => (accounts ? startFollowing() : undefined), [accounts]); useEffect(() => { if (!accounts && (route === "login" || route === "profile")) navigate("editor", { replace: true }); }, [accounts, route, navigate]); @@ -252,6 +289,7 @@ export function App() { // here is ready for the account: a signed-in visitor on a new device must // open on their notes, not on freshly seeded first-visit notes. if (accounts && accountStatus === "loading") return ; + if (liveSeed && !liveOutcome) return ; if (accounts && accountStatus === "signed-in" && accountUserId && cloudReadyFor !== accountUserId) { return ; } @@ -296,12 +334,14 @@ export function App() { // so the app's own notices (an embedder's storage is theirs). durabilityNotices initialImportUrl={importUrlSeed} - initialShare={shareSeed} + initialShare={shareSeed ?? (liveOutcome?.kind === "import" ? liveOutcome.share : undefined)} // Share links need an account: signed out, every share control // is greyed out and says so. Where no account service answers // (offline, an outage) there is nobody to sign in to, so sharing // stays as it was. shareDisabledReason={accounts && accountStatus === "signed-out" ? t("account.signInToShare") : undefined} + // Live shares are published from the account: offered once signed in. + liveShares={accounts && accountStatus === "signed-in" && cloudPhase !== "blocked" ? liveSharesApi : undefined} graphOpen={route === "graph"} // Opening the graph pushes (so browser Back closes it); closing // REPLACES the /graph entry — otherwise every toggle stacked two diff --git a/apps/web/src/EditorHeader.tsx b/apps/web/src/EditorHeader.tsx index a3637c2..0b5ff8e 100644 --- a/apps/web/src/EditorHeader.tsx +++ b/apps/web/src/EditorHeader.tsx @@ -2,6 +2,7 @@ import { cn } from "@/lib/utils"; import { FileText } from "lucide-react"; import type { ReactNode } from "react"; import { APP_BRAND, APP_NAME } from "./constants"; +import { VersionBadge } from "./ui/chrome/versionBadge"; interface EditorHeaderProps { /** @@ -34,6 +35,7 @@ export function EditorHeader({ accountControl }: EditorHeaderProps) { {APP_NAME} + {APP_BRAND} diff --git a/apps/web/src/PerssonifyEditor.tsx b/apps/web/src/PerssonifyEditor.tsx index 87d085f..4e9c216 100644 --- a/apps/web/src/PerssonifyEditor.tsx +++ b/apps/web/src/PerssonifyEditor.tsx @@ -15,6 +15,7 @@ import type { PerssonifyEditorProps } from "./editor/props"; import { useOverlayInert, useWorkspaceViews } from "./editor/useWorkspaceViews"; import { useAutosave } from "./editor/useAutosave"; import { useTabSync } from "./editor/useTabSync"; +import { useSharedDocLock } from "./editor/useSharedDocLock"; import { useCanonicalChangeHandler, useContentChangeEmitter } from "./editor/useChangeNotifications"; import { useHeadingNavigation, useOutline } from "./editor/useHeadingNavigation"; import { usePaneCommands } from "./editor/usePaneCommands"; @@ -31,6 +32,7 @@ import { useHeadingRenameLinks } from "./editor/useHeadingRenameLinks"; import { MemorySheet } from "./ui/diagnostics/memorySheet"; import { VersionHistorySheet } from "./ui/history/versionHistorySheet"; import { LinkBuilderDialog } from "./ui/share/linkBuilderDialog"; +import { ShareDialog, type ShareTarget } from "./ui/share/shareDialog"; import { groupDocsByFolder, useFolderStore } from "./store/documents/folders"; import { useUIStore } from "./store/preferences/uiStore"; import { MenuBar } from "./ui/chrome/menuBar"; @@ -107,6 +109,7 @@ function PerssonifyEditorInstance({ initialImportUrl, initialShare, shareDisabledReason, + liveShares, readOnly: readOnlyProp, fitParent = false, }: PerssonifyEditorProps) { @@ -569,6 +572,9 @@ function PerssonifyEditorInstance({ tiptapRef, cmRef, hydrateEditorWithDoc, claimEditorGeneration, isEditorGeneration, endCompositions, hasPendingProjection, flushPendingSync, cancelPendingSync, handleCloseTab, toast, }); + // A document shared read-only (a read-only link, or a live copy being + // followed) opens read-only (useSharedDocLock). + useSharedDocLock(); // ── Keyboard shortcuts ──────────────────────────────────────────────────── // Find/Replace can open in two modes: ⌘F (find) and ⌘H (replace expanded). @@ -623,11 +629,14 @@ function PerssonifyEditorInstance({ engine, syncMode, getCanonical, toast, toastRef, autosaveRef, exportDocsRef, }); // Sharing behind `shareDisabledReason` (the app: signed out): every share - // control says why instead of copying a link (share-gate.tsx). + // control says why instead of sharing (share-gate.tsx). Otherwise the Share + // dialog opens: how the link opens, and what the recipient gets. + const [shareTarget, setShareTarget] = useState(null); const shareDoc = useCallback((docId: string) => { - if (shareDisabledReason) toast.show(shareDisabledReason, "info"); - else void handleShareDoc(docId); - }, [shareDisabledReason, toast, handleShareDoc]); + if (shareDisabledReason) { toast.show(shareDisabledReason, "info"); return; } + const title = useDocumentStore.getState().documents.find((d) => d.id === docId)?.title; + setShareTarget({ kind: "doc", id: docId, name: title || t("common.untitled") }); + }, [shareDisabledReason, toast, t]); // A new note straight inside a folder (the folder's ⋮ menu): created like // any new note, then filed under the folder, which opens to show it. const handleNewDocInFolder = useCallback(async (folderId: string) => { @@ -637,9 +646,20 @@ function PerssonifyEditorInstance({ if (created && created !== before) useFolderStore.getState().moveDoc(created, folderId); }, [handleNewDoc]); const shareFolder = useCallback((folderId: string) => { - if (shareDisabledReason) toast.show(shareDisabledReason, "info"); - else void handleShareFolder(folderId); - }, [shareDisabledReason, toast, handleShareFolder]); + if (shareDisabledReason) { toast.show(shareDisabledReason, "info"); return; } + const name = useFolderStore.getState().folders.find((f) => f.id === folderId)?.name; + setShareTarget({ kind: "folder", id: folderId, name: name || t("library.folder") }); + }, [shareDisabledReason, toast, t]); + const closeShare = useCallback(() => setShareTarget(null), []); + // Publishing live reads the STORED note: save what is being typed first, as + // the copy-link path does (handleShareDoc). + const liveSharesSaved = useMemo(() => liveShares && { + ...liveShares, + publish: async (docId: string, view?: Parameters[1]) => { + try { await autosaveRef.current(); } catch { /* publish the stored copy */ } + return liveShares.publish(docId, view); + }, + }, [liveShares, autosaveRef]); // ── Library queries ─────────────────────────────────────────────────────── // Search, and the map's per-document headings, answered from what the link @@ -783,6 +803,14 @@ function PerssonifyEditorInstance({ onRestoreAsNew={restoreSnapshotAsNew} /> + {showFindReplace && ( diff --git a/apps/web/src/__tests__/admin-prefs-fallback.test.js b/apps/web/src/__tests__/admin-prefs-fallback.test.js index 85f7efc..7f05f46 100644 --- a/apps/web/src/__tests__/admin-prefs-fallback.test.js +++ b/apps/web/src/__tests__/admin-prefs-fallback.test.js @@ -1,12 +1,15 @@ /** * Admin-only switches must not stick once the Admin section is hidden. * - * The Settings "Admin" section (context-menu switch, diagnostics console) is - * hidden in production unless `pf:admin` is "1" (A081). A user who turned the - * custom context menu OFF before that gate existed would otherwise keep the - * native menu forever, with no control left to turn it back on. The store's - * initial state is read at module load, so each case loads a fresh store after - * choosing the build flavour and the stored values. + * The Settings "Admin" section (the diagnostics console) is hidden in + * production unless `pf:admin` is "1" (A081). A value stored before that gate + * existed would otherwise stick with no control left to change it. The + * store's initial state is read at module load, so each case loads a fresh + * store after choosing the build flavour and the stored values. + * + * The custom right-click menu USED to be one of them. It is an everyday switch + * now (Settings ▸ Editor — turning it off brings back the browser's own menu + * and its spelling suggestions), so a stored choice is honoured in every build. */ import { afterEach, describe, expect, it, vi } from "vitest"; @@ -31,19 +34,27 @@ const STALE = { "pf:context-menu": "false", "pf:console": "true" }; describe("admin-only preferences", () => { it("fall back to the defaults in production without the admin opt-in", async () => { const s = await freshStore({ dev: false, admin: false, stored: STALE }); - expect(s.contextMenuEnabled).toBe(true); expect(s.consoleEnabled).toBe(false); }); it("honour the stored values when the Admin section is available (opt-in)", async () => { const s = await freshStore({ dev: false, admin: true, stored: STALE }); - expect(s.contextMenuEnabled).toBe(false); expect(s.consoleEnabled).toBe(true); }); it("honour the stored values in a development build", async () => { const s = await freshStore({ dev: true, admin: false, stored: STALE }); - expect(s.contextMenuEnabled).toBe(false); expect(s.consoleEnabled).toBe(true); }); }); + +describe("the custom right-click menu (an everyday setting)", () => { + it.each([ + ["production", { dev: false, admin: false }], + ["an admin opt-in", { dev: false, admin: true }], + ["a development build", { dev: true, admin: false }], + ])("keeps the user's choice in %s", async (_name, flavour) => { + expect((await freshStore({ ...flavour, stored: { "pf:context-menu": "false" } })).contextMenuEnabled).toBe(false); + expect((await freshStore({ ...flavour, stored: {} })).contextMenuEnabled).toBe(true); + }); +}); diff --git a/apps/web/src/__tests__/cloud-sync.test.js b/apps/web/src/__tests__/cloud-sync.test.js index 60d1638..bf6248e 100644 --- a/apps/web/src/__tests__/cloud-sync.test.js +++ b/apps/web/src/__tests__/cloud-sync.test.js @@ -21,18 +21,27 @@ function fakeAccount() { let library = null; let libRev = 0; const conflict = (row) => ({ ok: false, current: row ? { updated: row.updated, deleted: row.deleted } : null }); + /** How each state read was answered: "full", or "unchanged" (a poll with the current version). */ + const stateReads = []; + /** The account's live shares, as `state` lists them. */ + const published = []; return { docs, assets, + stateReads, + published, get library() { return library; }, api: { - async state() { - return { - documents: [...docs].map(([id, d]) => ({ id, title: d.record?.title ?? "", updated: d.updated, deleted: d.deleted, size: 1 })), - library, - }; + // Like the API: a version of what the state describes, and "unchanged" + // to a poll that already holds it (routes/index.ts stateVersion). + async state(since) { + const documents = [...docs].map(([id, d]) => ({ id, title: d.record?.title ?? "", updated: d.updated, deleted: d.deleted, size: 1 })); + const version = JSON.stringify([documents.map((d) => [d.id, d.updated, d.deleted]).sort(), library?.updated ?? null, published]); + stateReads.push(since === version ? "unchanged" : "full"); + if (since === version) return { unchanged: true, version }; + return { documents, library, published: structuredClone(published), version }; }, async getDocument(id) { const d = docs.get(id); @@ -115,7 +124,7 @@ function memAssets() { } let seq = 0; -function device(account, userId = "u1") { +function device(account, userId = "u1", extra = {}) { const storage = new IndexedDBStorageAdapter({ dbName: `cloud-test-${++seq}`, snapshots: false }); const snapshots = []; const announced = []; @@ -130,6 +139,7 @@ function device(account, userId = "u1") { announce: (events) => announced.push(...events), kv: { get: (k) => kv.get(k) ?? null, set: (k, v) => void kv.set(k, v) }, now: () => T(59), + ...extra, }); const write = async (id, text, updated, extra = {}) => { const doc = createDocument({ id, title: `Doc ${id}`, canonical: extra.canonical ?? para(text) }); @@ -330,6 +340,174 @@ describe("two devices, one account", () => { expect(a.snapshots).toContainEqual({ docId: "n1", text: "v2", reason: "cloud-sync" }); }); + it("polls cheaply: an account that has not changed is not read again", async () => { + const acct = fakeAccount(); + const a = device(acct); + const b = device(acct); + await a.write("n1", "v1", T(1)); + await a.engine.cycle(); // full read, then a push + await b.engine.cycle(); // full read, pulls n1 + acct.stateReads.length = 0; + // Nothing changed anywhere: both are answered "unchanged", and do nothing. + expect(await b.engine.cycle()).toMatchObject({ pulled: 0, pushed: 0 }); + expect(acct.stateReads).toEqual(["unchanged"]); + // A's own push moved the account's version: A reads it once more, in full. + await a.engine.cycle(); + expect(acct.stateReads).toEqual(["unchanged", "full"]); + await a.engine.cycle(); + expect(acct.stateReads).toEqual(["unchanged", "full", "unchanged"]); + // A change on one device reaches the other through a full read again. + await a.write("n1", "v2", T(2)); + await a.engine.cycle(); + expect(await b.engine.cycle()).toMatchObject({ pulled: 1 }); + expect(textOf(await b.read("n1"))).toBe("v2"); + // Local changes are still found while the account is "unchanged". + await b.engine.cycle(); + await b.write("n2", "new on B", T(3)); + acct.stateReads.length = 0; + expect(await b.engine.cycle()).toMatchObject({ pushed: 1 }); + expect(acct.stateReads).toEqual(["unchanged"]); + }); + + // Two tabs of the same library: one runs the sync while the user types in + // either. A save that lands while a note is being downloaded must win. + it("never writes the account's copy over a save made here while it was being downloaded", async () => { + const acct = fakeAccount(); + const a = device(acct); + const b = device(acct); + await a.write("n1", "v1", T(1)); + await a.engine.cycle(); + await b.engine.cycle(); + await a.write("n1", "from A", T(5)); + await a.engine.cycle(); + // B's cycle plans a pull of A's edit; while it downloads, B's editor saves. + const realGet = acct.api.getDocument; + acct.api.getDocument = async (id) => { + acct.api.getDocument = realGet; + await b.write("n1", "typed on B meanwhile", T(9)); + return realGet(id); + }; + expect(await b.engine.cycle()).toMatchObject({ pulled: 0, again: true }); + expect(textOf(await b.read("n1"))).toBe("typed on B meanwhile"); + // The next cycle sees both changed: B's is newer and wins; A's is kept. + expect(await b.engine.cycle()).toMatchObject({ conflicts: 1, pushed: 1 }); + expect(textOf(acct.docs.get("n1").record)).toBe("typed on B meanwhile"); + expect(b.snapshots).toContainEqual({ docId: "n1", text: "from A", reason: "cloud-sync" }); + }); + + it("does not forget a note deleted here while a cycle was running", async () => { + const acct = fakeAccount(); + const a = device(acct); + await a.write("n1", "keep", T(1)); + await a.write("n2", "doomed", T(1)); + await a.engine.cycle(); + await a.write("n3", "new", T(2)); // gives the next cycle work to do + const realPut = acct.api.putDocument; + acct.api.putDocument = async (id, base, record) => { + acct.api.putDocument = realPut; + // The editor deletes n2 mid-cycle (its delete event reaches the engine). + await a.storage.delete("n2"); + a.engine.recordLocalDelete("n2", T(3)); + return realPut(id, base, record); + }; + await a.engine.cycle(); + // It used to be dropped from the pending list here — and downloaded again. + expect(await a.engine.cycle()).toMatchObject({ deletedThere: 1, pulled: 0 }); + expect(acct.docs.get("n2")).toMatchObject({ deleted: true }); + expect(await a.read("n2")).toBeNull(); + }); + + describe("live shares", () => { + // What the app wires in (app/cloud/index.ts → app/publish): the account + // keeps the published copy; here, a list of what was sent. + const publisher = (acct) => { + const calls = []; + return { + calls, + publish: { + seen: (entries) => calls.push(["seen", entries.map((e) => e.docId)]), + put: async (docId, live, id) => { + calls.push(["put", docId, live.revision, live.document.id, live.document.metadata, id]); + const e = acct.published.find((p) => p.docId === docId); + if (e) e.revision = live.revision; + }, + problem: (docId, problem) => calls.push(["problem", docId, problem]), + }, + }; + }; + + it("a published note edited here is published again; an unchanged one is left alone", async () => { + const acct = fakeAccount(); + const p = publisher(acct); + const a = device(acct, "u1", { publish: p.publish }); + await a.write("n1", "v1", T(1)); + await a.write("n2", "other", T(1)); + await a.engine.cycle(); + acct.published.push({ docId: "n1", id: "pub1", revision: T(1) }); + await a.engine.cycle(); + expect(p.calls.filter((c) => c[0] === "put")).toEqual([]); + await a.write("n1", "v2", T(4)); + await a.engine.cycle(); + // The copy carries neither the owner's id nor its metadata, and it is an + // UPDATE of that share (its id), never a new one. + expect(p.calls.filter((c) => c[0] === "put")).toEqual([["put", "n1", T(4), undefined, {}, "pub1"]]); + expect(acct.published[0].revision).toBe(T(4)); + await a.engine.cycle(); + expect(p.calls.filter((c) => c[0] === "put")).toHaveLength(1); + }); + + it("an edit made on another device is published by whichever device has it", async () => { + const acct = fakeAccount(); + const a = device(acct); + const p = publisher(acct); + const b = device(acct, "u1", { publish: p.publish }); + await a.write("n1", "v1", T(1)); + await a.engine.cycle(); + await b.engine.cycle(); + acct.published.push({ docId: "n1", id: "pub1", revision: T(1) }); + await a.write("n1", "v2 from A", T(5)); // A cannot publish (no publisher) + await a.engine.cycle(); + await b.engine.cycle(); // pulls it… + await b.engine.cycle(); // …and, holding the newer revision, publishes it + expect(acct.published[0].revision).toBe(T(5)); + }); + + it("a note too large to publish is not rebuilt every cycle — and says so — until it changes", async () => { + const acct = fakeAccount(); + const puts = []; + const problems = []; + const tooLarge = () => Object.assign(new Error("too large"), { name: "PublishTooLargeError" }); + const a = device(acct, "u1", { + publish: { + seen() {}, + put: async (docId, live) => { puts.push(live.revision); throw tooLarge(); }, + problem: (docId, problem) => problems.push([docId, problem]), + }, + }); + await a.write("n1", "v1", T(1)); + await a.engine.cycle(); + acct.published.push({ docId: "n1", id: "pub1", revision: T(0) }); + await a.engine.cycle(); + await a.engine.cycle(); + await a.engine.cycle(); + expect(puts).toEqual([T(1)]); + expect(problems).toEqual([["n1", "too-large"]]); + await a.write("n1", "v2, smaller", T(5)); + await a.engine.cycle(); + expect(puts).toEqual([T(1), T(5)]); + }); + + it("a publish that fails never fails the sync", async () => { + const acct = fakeAccount(); + const a = device(acct, "u1", { publish: { seen() {}, put: async () => { throw new Error("offline"); }, remove: async () => {} } }); + await a.write("n1", "v1", T(1)); + await a.engine.cycle(); + acct.published.push({ docId: "n1", id: "pub1", revision: T(0) }); + await a.write("n2", "new", T(2)); + expect(await a.engine.cycle()).toMatchObject({ pushed: 1 }); + }); + }); + it("leaves alone a document whose id the account cannot store", async () => { const acct = fakeAccount(); const a = device(acct); diff --git a/apps/web/src/__tests__/converter-linear.test.js b/apps/web/src/__tests__/converter-linear.test.js index 6a1d436..01b925b 100644 --- a/apps/web/src/__tests__/converter-linear.test.js +++ b/apps/web/src/__tests__/converter-linear.test.js @@ -113,5 +113,7 @@ describe("A083 — linear conversion is byte-identical to turndown's", () => { expect(turndownLinear(td, html)).toBe(td.turndown(html)); expect(turndownLinear(td, html, 7)).toBe(td.turndown(html)); } - }); + // Its own budget: ~1 s alone, but several times that with the whole suite + // running beside it, past the 5 s default. + }, 30_000); }); diff --git a/apps/web/src/__tests__/fixtures/marked-parity.json b/apps/web/src/__tests__/fixtures/marked-parity.json index be52aa5..f097703 100644 --- a/apps/web/src/__tests__/fixtures/marked-parity.json +++ b/apps/web/src/__tests__/fixtures/marked-parity.json @@ -3608,11 +3608,11 @@ "via": "engine", "features": 0, "md": "- [ ] foo\n ---\n", - "html": "
    \n
  • [ ] foo

    \n
  • \n
\n", + "html": "
    \n
  • foo
    ---
  • \n
\n", "changed": { - "why": "marked 18.0.3: a setext heading is not a task item's paragraph (GFM), so the item is a bullet whose heading reads [ ] foo.", + "why": "PL-51: a heading underline under a list item's first line is escaped in the md→rich projection (escapeListSetextUnderlines), so the item keeps its text (and, as a task item, its checkbox). The

it used to hold was hoisted out of the list by the rich pane, which left an empty item behind.", "editor": "changed", - "was": "
    \n
  • foo

    \n
  • \n
\n" + "was": "
    \n
  • [ ] foo

    \n
  • \n
\n" } }, { @@ -3621,7 +3621,12 @@ "via": "engine", "features": 12, "md": "- [ ] foo\n ---\n", - "html": "
    \n
  • [ ] foo

    \n
  • \n
\n" + "html": "
    \n
  • [ ] foo\n---
  • \n
\n", + "changed": { + "why": "PL-51: a heading underline under a list item's first line is escaped in the md→rich projection (escapeListSetextUnderlines), so the item keeps its text (and, as a task item, its checkbox). The

it used to hold was hoisted out of the list by the rich pane, which left an empty item behind.", + "editor": "changed", + "was": "
    \n
  • [ ] foo

    \n
  • \n
\n" + } }, { "id": "c388", @@ -3629,11 +3634,11 @@ "via": "engine", "features": 13, "md": "- [ ] foo\n ---\n", - "html": "
    \n
  • [ ] foo

    \n
  • \n
\n", + "html": "
    \n
  • foo
    ---
  • \n
\n", "changed": { - "why": "marked 18.0.3: a setext heading is not a task item's paragraph (GFM), so the item is a bullet whose heading reads [ ] foo.", + "why": "PL-51: a heading underline under a list item's first line is escaped in the md→rich projection (escapeListSetextUnderlines), so the item keeps its text (and, as a task item, its checkbox). The

it used to hold was hoisted out of the list by the rich pane, which left an empty item behind.", "editor": "changed", - "was": "
    \n
  • foo

    \n
  • \n
\n" + "was": "
    \n
  • [ ] foo

    \n
  • \n
\n" } }, { diff --git a/apps/web/src/__tests__/folder-reorder.test.js b/apps/web/src/__tests__/folder-reorder.test.js index eb0326c..6fe4aa2 100644 --- a/apps/web/src/__tests__/folder-reorder.test.js +++ b/apps/web/src/__tests__/folder-reorder.test.js @@ -50,6 +50,55 @@ describe("buildFolderTree ordering", () => { }); }); +// Settings-free: the library's sort menu (LibrarySort). "updated" is the +// default above; the others are what a user who does not want folders A–Z +// (or notes re-sorted on every edit) can choose. +describe("buildFolderTree sort modes", () => { + const made = (id, name, ms, extra = {}) => ({ id: `fld-${ms.toString(36)}-x-${id}`, name, parentId: null, ...extra }); + const folders = [ + made("b", "Beta", 2000), + made("a", "alpha", 3000), + made("c", "Charlie", 1000, { order: 0 }), // dragged first + ]; + const docs = [ + { ...doc("n2", "2024-01-01"), title: "banana", created: "2021-01-01" }, + { ...doc("n1", "2022-01-01"), title: "Apple", created: "2023-01-01" }, + { ...doc("n10", "2020-01-01"), title: "apple 10", created: "2022-01-01" }, + ]; + const names = (t) => t.rootFolders.map((n) => n.folder.name); + const ids = (t) => t.rootDocs.map((d) => d.id); + + it("name: folders and documents A–Z, case-insensitively and with numbers in order, ignoring drag order", () => { + const t = buildFolderTree(docs, folders, {}, { root: ["n2"] }, "name"); + expect(names(t)).toEqual(["alpha", "Beta", "Charlie"]); + expect(ids(t)).toEqual(["n1", "n10", "n2"]); + }); + + it("created: newest first, for folders (by when they were made) and documents", () => { + const t = buildFolderTree(docs, folders, {}, { root: ["n10"] }, "created"); + expect(names(t)).toEqual(["alpha", "Beta", "Charlie"]); + expect(ids(t)).toEqual(["n1", "n10", "n2"]); + }); + + it("manual: dragged items first, the rest in the order they were made — nothing alphabetical", () => { + const t = buildFolderTree(docs, folders, {}, { root: ["n10"] }, "manual"); + expect(names(t)).toEqual(["Charlie", "Beta", "alpha"]); + expect(ids(t)).toEqual(["n10", "n2", "n1"]); // n10 dragged; then oldest-created first + }); + + it("updated (the default) is the original order: drag order, then folders A–Z, notes newest-edited", () => { + expect(names(buildFolderTree(docs, folders, {}, {}, "updated"))).toEqual(["Charlie", "alpha", "Beta"]); + expect(names(buildFolderTree(docs, folders, {}, {}))).toEqual(["Charlie", "alpha", "Beta"]); + expect(ids(buildFolderTree(docs, folders, {}, {}))).toEqual(["n2", "n1", "n10"]); + }); + + it("folders without a timestamp in their id fall back to the order they are listed in", () => { + const plain = [{ id: "x2", name: "Second", parentId: null }, { id: "x1", name: "First", parentId: null }]; + expect(names(buildFolderTree([], plain, {}, {}, "manual"))).toEqual(["Second", "First"]); + expect(names(buildFolderTree([], plain, {}, {}, "created"))).toEqual(["First", "Second"]); + }); +}); + describe("folder store reorder actions", () => { beforeEach(() => { useFolderStore.setState({ folders: [], docParents: {}, docOrder: {}, expanded: {} }); diff --git a/apps/web/src/__tests__/g4-ads-admin-gates.test.jsx b/apps/web/src/__tests__/g4-ads-admin-gates.test.jsx index 51d4147..d95766f 100644 --- a/apps/web/src/__tests__/g4-ads-admin-gates.test.jsx +++ b/apps/web/src/__tests__/g4-ads-admin-gates.test.jsx @@ -158,8 +158,10 @@ describe("Settings ▸ Admin (A081)", () => { expect(screen.getByText("Settings")).toBeInTheDocument(); // the sheet did render expect(screen.queryByText("Admin")).toBeNull(); expect(screen.queryByText("Diagnostics console")).toBeNull(); - expect(screen.queryByText("Enable right-click menu")).toBeNull(); expect(screen.queryByText("Show ads")).toBeNull(); + // Not admin-only any more: the right-click menu switch is an everyday + // setting (Settings ▸ Editor), there for every user. + expect(screen.getByText("Custom right-click menu")).toBeInTheDocument(); }); it("appears in production for a browser opted in with pf:admin=1 — without the ads switch", () => { diff --git a/apps/web/src/__tests__/library-panel-perf.test.jsx b/apps/web/src/__tests__/library-panel-perf.test.jsx index 60351dd..7d18efa 100644 --- a/apps/web/src/__tests__/library-panel-perf.test.jsx +++ b/apps/web/src/__tests__/library-panel-perf.test.jsx @@ -58,7 +58,7 @@ function makeDocs(n, bump = null) { })); } -function mount(n) { +function mount(n, props = {}) { useFolderStore.setState({ folders: [], docParents: {}, docOrder: {}, expanded: {}, lastCreatedFolderId: null }); const docs = makeDocs(n); useDocumentStore.setState({ documents: docs, activeDocId: docs[n - 1].id }); @@ -73,6 +73,7 @@ function mount(n) { tab="files" onTabChange={noop} onClose={noop} width={320} onResize={noop} onSelect={noop} onNew={noop} onDelete={noop} onRename={noop} onDuplicate={noop} headings={[]} activeIndex={null} onNavigateHeading={noop} + {...props} /> @@ -146,16 +147,19 @@ describe("A082 — one shared row menu per list, mounted on open", () => { }); it("opens the shared menu for the row whose ⋮ was pressed", async () => { - const { container, findByText } = mount(30); + const onDuplicate = vi.fn(); + const { container, findByText } = mount(30, { onDuplicate }); const trigger = container.querySelector('[data-key="d:doc-12"] [aria-label="Options for Note 12"]'); expect(trigger).not.toBeNull(); await act(async () => { fireEvent.click(trigger); }); - // The body is the shared one, rendered for THIS row: Rename opens its box. - const rename = await findByText("Rename"); - await act(async () => { fireEvent.click(rename); }); - const input = container.querySelector('[data-key="d:doc-12"] input'); - expect(input).not.toBeNull(); - expect(input.value).toBe("Note 12"); + // The body is the shared one, rendered for THIS row. (Driven through + // Duplicate, not Rename: in jsdom a Base UI menu hands focus back to its + // trigger synchronously, which blurs — and commits — a rename box opened + // from it, depending on which item the menu had highlighted. Rename from + // the ⋮ menu is checked in a real browser.) + const duplicate = await findByText("Duplicate"); + await act(async () => { fireEvent.click(duplicate); }); + expect(onDuplicate).toHaveBeenCalledWith("doc-12"); }); it("defers the search filter so typing never waits for the whole library", () => { diff --git a/apps/web/src/__tests__/list-heading-trap.test.js b/apps/web/src/__tests__/list-heading-trap.test.js new file mode 100644 index 0000000..3282afb --- /dev/null +++ b/apps/web/src/__tests__/list-heading-trap.test.js @@ -0,0 +1,99 @@ +/** + * "In an ordered list, when you press Enter and start a new empty item, it + * shifts to heading" (PL-51). + * + * A setext underline under a list item's text — `1. foo` then ` -`, which is + * what Enter, Backspace and `-` (or Tab on a new `- ` item) leave in the + * markdown pane while a nested item is being started — makes that text an + * `

` inside the item (CommonMark, and marked). A ProseMirror list item must + * START with a paragraph, so the rich pane hoisted the heading out of the list + * and left an empty item behind; the next rich-pane edit then wrote `## foo` + * and the list was gone. The md→rich projection escapes that underline. + * + * Its partner: an EMPTY item made by Enter in the rich pane was dropped from + * the markdown (a blank node never reaches turndown's list rule). + */ +import { describe, expect, it } from "vitest"; +import { escapeListSetextUnderlines } from "../engine/preprocessor"; +import { engineFor, htmlRoundTrip, throughRichPane } from "./converter-harness"; + +const B = "\\"; +const rich = (md) => throughRichPane(engineFor().mdToHtml(md)).json; +const topTypes = (json) => json.content.map((n) => n.type); + +describe("a heading underline typed under a list item", () => { + it.each([ + ["Enter, Backspace, - under an ordered item", "Steps\n\n1. foo\n -"], + ["…with the space after it", "1. foo\n - "], + ["Tab on a new bullet", "- foo\n - "], + ["a second item", "1. foo\n2. bar\n -"], + ["three dashes", "1. foo\n ---"], + ["an = underline", "1. foo\n ="], + ["a lazy === under the item", "1. foo\n==="], + ["a nested item", "- a\n - foo\n -"], + ["a task item", "- [ ] foo\n -"], + ])("%s: the item keeps its text, and no heading appears", (_name, md) => { + const json = rich(md); + expect(JSON.stringify(json)).not.toContain('"heading"'); + const list = json.content.find((n) => /List$/.test(n.type)); + expect(list, JSON.stringify(json)).toBeTruthy(); + expect(JSON.stringify(list)).toContain('"foo"'); + }); + + it("is a nested list again as soon as the item has text", () => { + const json = rich("1. foo\n - bar"); + // (The schema keeps an empty paragraph after a list at the end.) + expect(topTypes(json).filter((t) => t !== "paragraph")).toEqual(["orderedList"]); + expect(json.content[0].content[0].content.map((n) => n.type)).toEqual(["paragraph", "bulletList"]); + }); + + it("leaves real headings, thematic breaks and sibling items alone", () => { + expect(escapeListSetextUnderlines("Title\n-----\n\ntext")).toBe("Title\n-----\n\ntext"); + expect(escapeListSetextUnderlines("1. foo\n---")).toBe("1. foo\n---"); // a break after the list + expect(escapeListSetextUnderlines("- foo\n-\n- bar")).toBe("- foo\n-\n- bar"); // an empty sibling item + expect(escapeListSetextUnderlines("- a\n - foo\n -")).toBe("- a\n - foo\n -"); + expect(escapeListSetextUnderlines("1. foo\n\n bar\n -")).toBe("1. foo\n\n bar\n -"); // not the item's first block + expect(escapeListSetextUnderlines("```\n1. foo\n -\n```")).toBe("```\n1. foo\n -\n```"); // code + // Under a heading or a quote it is a thematic break, not an underline. + expect(escapeListSetextUnderlines("- # Title\n ---")).toBe("- # Title\n ---"); + expect(escapeListSetextUnderlines("- > quoted\n ---")).toBe("- > quoted\n ---"); + expect(escapeListSetextUnderlines("1. foo\n > bar\n ---")).toBe("1. foo\n > bar\n ---"); + }); + + it("escapes in place: same lines, only the underline changes", () => { + expect(escapeListSetextUnderlines("1. foo\n -")).toBe(`1. foo\n ${B}-`); + expect(escapeListSetextUnderlines("1. foo\n===")).toBe(`1. foo\n${B}===`); + expect(escapeListSetextUnderlines("x\n\n- foo\n - \n\ny").split("\n")).toHaveLength(6); + }); + + it("only the projection sees it: the markdown is unchanged", () => { + // mdToHtml is the only caller (preprocessMarkdown); the markdown pane's + // text never goes through it on the way back. + const engine = engineFor(); + expect(engine.mdToHtml("1. foo\n -")).not.toContain(" { + const md = (html) => htmlRoundTrip(html).md1; + + it("reaches the markdown as its bare marker, so the item is not lost", () => { + expect(md("
  1. one

  2. two

")).toBe("1. one\n2. two\n3."); + expect(md("
  • a

")).toBe("- a\n-"); + }); + + it("mid-list keeps the list tight and the numbers in order", () => { + expect(md("
  1. one

  2. three

")).toBe("1. one\n2.\n3. three"); + }); + + it("and reads back as the same empty item", () => { + const r = htmlRoundTrip("
  1. one

  2. three

"); + expect(r.json2.content[0].type).toBe("orderedList"); + expect(r.json2.content[0].content).toHaveLength(3); + expect(r.md2).toBe(r.md1); + }); + + it("first in a NESTED list it is still left out — `1. foo⏎ -` would be a heading underline", () => { + expect(md("
  1. foo

")).toBe("1. foo"); + }); +}); diff --git a/apps/web/src/__tests__/live-share.test.js b/apps/web/src/__tests__/live-share.test.js new file mode 100644 index 0000000..a86ccd3 --- /dev/null +++ b/apps/web/src/__tests__/live-share.test.js @@ -0,0 +1,171 @@ +// Live shares and read-only shares on the device side (PL-51): the flags a +// shared document carries (store/documents/live-share-meta.ts), the copy a +// live share publishes (store/documents/live-share.ts), and a follower's copy +// being kept up to date (app/publish refreshFollowed) — against a real +// (fake-indexeddb) library and a stubbed server. +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { refreshFollowed } from "../app/publish"; +import { createDocument, toSummary } from "../store/documents/document"; +import { buildLiveDocument, isLockedShare, LIVE_SHARE_KEY, liveShareOf, ownCopyPatch, READ_ONLY_SHARE_KEY } from "../store/documents/live-share"; +import { snapshotStore } from "../store/history/snapshot-store"; +import { decodeSharePayload, encodeSharePayload } from "../store/importExport/share-link"; +import { IndexedDBStorageAdapter } from "../store/persistence/idb-storage-adapter"; + +const para = (text) => ({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] }); +const textOf = (rec) => rec?.canonical?.content?.[0]?.content?.[0]?.text; +const PUB = "AbCdEfGhIjKlMnOpQrStUv"; // 22 characters, like the server's ids + +describe("a shared document's flags", () => { + it("read-only shares and followed live copies open read-only; an ended one does not", () => { + expect(isLockedShare({})).toBe(false); + expect(isLockedShare({ [READ_ONLY_SHARE_KEY]: true })).toBe(true); + expect(isLockedShare({ [LIVE_SHARE_KEY]: { id: PUB, revision: "r1" } })).toBe(true); + expect(isLockedShare({ [LIVE_SHARE_KEY]: { id: PUB, revision: "r1", ended: true } })).toBe(false); + // A field block that happens to use the name is not a live share. + expect(liveShareOf({ [LIVE_SHARE_KEY]: "yes" })).toBeNull(); + expect(isLockedShare({ [READ_ONLY_SHARE_KEY]: "true" })).toBe(false); + }); + + it("'make it my own' clears both", () => { + const after = { [LIVE_SHARE_KEY]: { id: PUB, revision: "r1" }, [READ_ONLY_SHARE_KEY]: true, cardColor: "blue", ...ownCopyPatch() }; + expect(isLockedShare(after)).toBe(false); + expect(after.cardColor).toBe("blue"); + }); + + it("reach the library's summary only when present", () => { + const plain = toSummary(createDocument({ id: "p", canonical: para("x") })); + expect("liveShare" in plain).toBe(false); + expect("readOnlyShare" in plain).toBe(false); + const live = toSummary(createDocument({ id: "l", canonical: para("x"), metadata: { [LIVE_SHARE_KEY]: { id: PUB, revision: "r1", unseen: true } } })); + expect(live.liveShare).toEqual({ id: PUB, unseen: true, ended: false }); + expect(toSummary(createDocument({ id: "r", canonical: para("x"), metadata: { [READ_ONLY_SHARE_KEY]: true } })).readOnlyShare).toBe(true); + }); +}); + +describe("share links carry how they open", () => { + it("a layout and read-only survive the link; anything else does not", () => { + const enc = encodeSharePayload({ v: 1, kind: "file", name: "n", docs: [{ path: [], title: "t", markdown: "x", metadata: { evil: true } }], view: "md", readOnly: true }); + const dec = decodeSharePayload(enc); + expect(dec).toMatchObject({ view: "md", readOnly: true }); + // Metadata is the app's to set (a live share), never a link's. + expect(dec.docs[0].metadata).toBeUndefined(); + expect(decodeSharePayload(encodeSharePayload({ v: 1, kind: "file", name: "n", docs: [{ path: [], title: "t", markdown: "x" }], view: "nope" })).view).toBeUndefined(); + }); +}); + +describe("the copy a live share publishes", () => { + it("is the document and its images — not the owner's id, metadata or import history", async () => { + const hash = "a".repeat(64); + const canonical = { type: "doc", content: [{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Plan" }] }, { type: "image", attrs: { src: `pf-asset:${hash}` } }] }; + const rec = { ...createDocument({ id: "owner-doc", canonical, metadata: { cardColor: "red", secret: 1 } }), updated: "2026-10-19T10:00:00.000Z" }; + const live = await buildLiveDocument(rec, async (h) => (h === hash ? { mime: "image/png", bytes: new Uint8Array([1, 2, 3]) } : null)); + expect(live.revision).toBe("2026-10-19T10:00:00.000Z"); + expect(live.title).toBe("Plan"); + expect(live.document._format).toBe("perssonify-document"); + expect(live.document.canonical).toEqual(canonical); + expect(live.document.id).toBeUndefined(); + expect(live.document.metadata).toEqual({}); + expect(live.document.origin).toBeUndefined(); + expect(live.document.ingestReport).toBeUndefined(); + expect(Object.keys(live.document.assets)).toEqual([hash]); + }); +}); + +describe("following a live share", () => { + let storage; + let replies; + const reply = (status, body) => new Response(body === undefined ? "" : JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + const publication = (revision, text) => ({ + v: 1, id: PUB, title: "Handbook", revision, + document: { _format: "perssonify-document", title: "Handbook", canonical: para(text) }, + }); + + beforeEach(async () => { + snapshotStore._resetForTests?.(); + storage = new IndexedDBStorageAdapter({ dbName: `live-follow-${Math.random()}`, snapshots: false }); + const doc = createDocument({ id: "copy", title: "Handbook", canonical: para("v1"), metadata: { [LIVE_SHARE_KEY]: { id: PUB, revision: "2026-10-19T10:00:00.000Z" } } }); + await storage.save({ ...doc, updated: "2026-10-19T10:00:00.000Z", _preserveUpdated: true }); + replies = []; + vi.stubGlobal("fetch", vi.fn(async (url) => { + const next = replies.shift(); + if (!next) throw new Error(`unexpected request ${url}`); + next.urls?.push(String(url)); + return next.res(); + })); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("asks with the revision it has, and changes nothing while the owner has not", async () => { + const urls = []; + replies.push({ urls, res: () => reply(200, { unchanged: true, revision: "2026-10-19T10:00:00.000Z" }) }); + expect(await refreshFollowed(storage)).toBe(0); + expect(urls[0]).toBe(`/api/published/${PUB}?since=${encodeURIComponent("2026-10-19T10:00:00.000Z")}`); + expect(textOf(await storage.get("copy"))).toBe("v1"); + }); + + it("takes the owner's new version, marks it updated, and keeps the old one in Version history", async () => { + replies.push({ res: () => reply(200, publication("2026-10-19T11:00:00.000Z", "v2 from the owner")) }); + expect(await refreshFollowed(storage)).toBe(1); + const rec = await storage.get("copy"); + expect(textOf(rec)).toBe("v2 from the owner"); + // The owner's revision, so every device following it writes the same one. + expect(rec.updated).toBe("2026-10-19T11:00:00.000Z"); + expect(liveShareOf(rec.metadata)).toMatchObject({ id: PUB, revision: "2026-10-19T11:00:00.000Z", unseen: true, ended: false }); + expect((await storage.list()).find((s) => s.id === "copy").liveShare).toEqual({ id: PUB, unseen: true, ended: false }); + const versions = await snapshotStore.listForDoc("copy"); + expect(versions.some((v) => v.reason === "live-update")).toBe(true); + }); + + // An OUTCOME check: what someone else sent never reaches the library with a + // dangerous link. Two layers give that — applyPublication sanitises what it + // is sent, and every read from storage sanitises again (createDocument) — so + // removing either one alone does not turn this red; removing both does. + it("never keeps a dangerous link it is sent", async () => { + const hostile = publication("2026-10-19T12:00:00.000Z", "x"); + hostile.document.canonical = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "ok", marks: [{ type: "link", attrs: { href: "javascript:alert(1)" } }] }] }] }; + replies.push({ res: () => reply(200, hostile) }); + await refreshFollowed(storage); + expect(JSON.stringify((await storage.get("copy")).canonical)).not.toContain("javascript:"); + }); + + it("when the owner stops sharing, it stops following and becomes the user's own", async () => { + replies.push({ res: () => reply(404, { error: "not_published" }) }); + expect(await refreshFollowed(storage)).toBe(1); + const rec = await storage.get("copy"); + expect(textOf(rec)).toBe("v1"); + expect(liveShareOf(rec.metadata)).toMatchObject({ ended: true }); + expect(isLockedShare(rec.metadata)).toBe(false); + // An ended copy is not asked about again. + expect(await refreshFollowed(storage)).toBe(0); + }); + + it("a 404 that is not the share's own answer (a deploy, a proxy) does not end it", async () => { + replies.push({ res: () => new Response("Not Found", { status: 404 }) }); + replies.push({ res: () => reply(404, { error: "not_found" }) }); + expect(await refreshFollowed(storage)).toBe(0); + expect(await refreshFollowed(storage)).toBe(0); + expect(liveShareOf((await storage.get("copy")).metadata)).toMatchObject({ ended: false }); + }); + + it("leaves the note alone when the user stopped following it while the update was on its way", async () => { + replies.push({ + res: async () => { + // "Stop following" (a real revision) lands while the server answers. + const rec = await storage.get("copy"); + await storage.save({ ...rec, metadata: { ...rec.metadata, ...ownCopyPatch() }, updated: "2026-10-19T10:30:00.000Z", _preserveUpdated: true }); + return reply(200, publication("2026-10-19T11:00:00.000Z", "v2 from the owner")); + }, + }); + expect(await refreshFollowed(storage)).toBe(0); + const rec = await storage.get("copy"); + expect(textOf(rec)).toBe("v1"); + expect(liveShareOf(rec.metadata)).toBeNull(); + }); + + it("a failure (offline) changes nothing and is tried again next time", async () => { + replies.push({ res: () => { throw new TypeError("Failed to fetch"); } }); + expect(await refreshFollowed(storage)).toBe(0); + expect(liveShareOf((await storage.get("copy")).metadata)).toMatchObject({ revision: "2026-10-19T10:00:00.000Z", ended: false }); + }); +}); diff --git a/apps/web/src/__tests__/native-context-menu.test.jsx b/apps/web/src/__tests__/native-context-menu.test.jsx new file mode 100644 index 0000000..d297b57 --- /dev/null +++ b/apps/web/src/__tests__/native-context-menu.test.jsx @@ -0,0 +1,53 @@ +/** + * PL-51: the custom right-click menu took over the browser's own, and with it + * the browser's spelling suggestions. Shift + right-click now always reaches + * the browser's menu (the editor and the library), the custom menu says so, + * and Settings ▸ Editor can turn the custom menu off for good. + */ +import { render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorContextMenu } from "../ui/chrome/editorContextMenu"; +import { useUIStore } from "../store/preferences/uiStore"; + +const api = () => ({ dispatch: vi.fn(), getHeadings: () => [], insertHeadingLink: vi.fn(), clipboard: vi.fn(), promptInsert: vi.fn() }); + +/** Right-click `el`, and say whether the page cancelled the browser's menu. */ +function rightClick(el, init = {}) { + const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2, clientX: 10, clientY: 10, ...init }); + el.dispatchEvent(ev); + return { browserMenu: !ev.defaultPrevented }; +} + +describe("the editor's right-click menu and the browser's own", () => { + let enabled; + beforeEach(() => { enabled = useUIStore.getState().contextMenuEnabled; }); + afterEach(() => useUIStore.setState({ contextMenuEnabled: enabled })); + + it("a plain right-click opens the custom menu — which says how to reach spelling suggestions", async () => { + useUIStore.setState({ contextMenuEnabled: true }); + const view = render(

doc

); + expect(rightClick(view.getByText("doc")).browserMenu).toBe(false); + expect(await view.findByText(/Shift \+ right-click opens the browser's menu/)).toBeTruthy(); + }); + + it("Shift + right-click is left to the browser, and the custom menu stays shut", () => { + useUIStore.setState({ contextMenuEnabled: true }); + const view = render(

doc

); + expect(rightClick(view.getByText("doc"), { shiftKey: true }).browserMenu).toBe(true); + expect(view.queryByRole("menu")).toBeNull(); + }); + + it("Shift+F10 (the keyboard's menu key) still opens the app's menu", () => { + useUIStore.setState({ contextMenuEnabled: true }); + const view = render(

doc

); + // A keyboard-opened menu: no mouse button (Chromium also gives it an empty pointerType). + expect(rightClick(view.getByText("doc"), { shiftKey: true, button: 0 }).browserMenu).toBe(false); + }); + + it("with the custom menu switched off, every right-click is the browser's", () => { + useUIStore.setState({ contextMenuEnabled: false }); + const view = render(

doc

); + expect(rightClick(view.getByText("doc")).browserMenu).toBe(true); + expect(view.queryByRole("menu")).toBeNull(); + }); +}); diff --git a/apps/web/src/__tests__/pkg-cjs-require.test.js b/apps/web/src/__tests__/pkg-cjs-require.test.js index 97f4d22..27c6d5e 100644 --- a/apps/web/src/__tests__/pkg-cjs-require.test.js +++ b/apps/web/src/__tests__/pkg-cjs-require.test.js @@ -148,5 +148,7 @@ describe("the built CommonJS entries load in plain Node (checked when a current expect(names.length, `${entry}.js exports`).toBeGreaterThan(0); expect(result[sub].keys, `dist/${entry}.cjs vs dist/${entry}.js`).toEqual(names); } - }); + // Its own budget: a second Node process loads every built entry — a few + // seconds alone, past the 5 s default with the whole suite beside it. + }, 60_000); }); diff --git a/apps/web/src/__tests__/share-dialog.test.jsx b/apps/web/src/__tests__/share-dialog.test.jsx new file mode 100644 index 0000000..af53730 --- /dev/null +++ b/apps/web/src/__tests__/share-dialog.test.jsx @@ -0,0 +1,97 @@ +/** + * PL-51 — share types. The Share dialog chooses the layout a link opens in + * (Split by default, Markdown, HTML) and what the recipient gets: an editable + * copy, a read-only copy, or — for one document, where the host offers it — a + * LIVE read-only copy that follows the owner's edits. + */ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ShareDialog } from "../ui/share/shareDialog"; + +const doc = { kind: "doc", id: "d1", name: "Plan" }; +const toast = () => ({ show: vi.fn() }); + +function setup(props = {}) { + const onShareDoc = vi.fn(async () => true); + const onShareFolder = vi.fn(async () => true); + const onClose = vi.fn(); + const t = toast(); + const view = render( + , + ); + return { view, onShareDoc, onShareFolder, onClose, toast: t }; +} + +describe("the Share dialog", () => { + let written; + beforeEach(() => { + written = []; + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (s) => void written.push(s) } }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("opens on Split and an editable copy, and copies that link", async () => { + const { onShareDoc, onClose } = setup(); + expect(screen.getByRole("button", { name: "Split" }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByRole("radio", { name: /Editable copy/ }).getAttribute("aria-checked")).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: /Copy link/ })); + await waitFor(() => expect(onShareDoc).toHaveBeenCalledWith("d1", { view: "split", readOnly: false })); + expect(onClose).toHaveBeenCalled(); + }); + + it("a read-only copy that opens in Markdown", async () => { + const { onShareDoc } = setup(); + fireEvent.click(screen.getByRole("button", { name: "Markdown" })); + fireEvent.click(screen.getByRole("radio", { name: /Read-only copy/ })); + fireEvent.click(screen.getByRole("button", { name: /Copy link/ })); + await waitFor(() => expect(onShareDoc).toHaveBeenCalledWith("d1", { view: "md", readOnly: true })); + }); + + it("offers no live option where the host has none", () => { + setup(); + expect(screen.queryByRole("radio", { name: /Live/ })).toBeNull(); + }); + + it("publishes a live copy, opening in HTML, and copies its link", async () => { + const liveShares = { publish: vi.fn(async (id, view) => `https://x/#live=ID&view=${view}`), unpublish: vi.fn(), linkFor: vi.fn(() => null) }; + const { onShareDoc, onClose } = setup({ liveShares }); + fireEvent.click(screen.getByRole("button", { name: "HTML" })); + fireEvent.click(screen.getByRole("radio", { name: /Live, read-only/ })); + fireEvent.click(screen.getByRole("button", { name: /Copy live link/ })); + await waitFor(() => expect(liveShares.publish).toHaveBeenCalledWith("d1", "html")); + await waitFor(() => expect(written).toEqual(["https://x/#live=ID&view=html"])); + expect(onShareDoc).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it("says why when it cannot publish, and stays open", async () => { + const liveShares = { publish: vi.fn(async () => { throw new Error("Too large"); }), unpublish: vi.fn(), linkFor: () => null }; + const { onClose, toast: t } = setup({ liveShares }); + fireEvent.click(screen.getByRole("radio", { name: /Live, read-only/ })); + fireEvent.click(screen.getByRole("button", { name: /Copy live link/ })); + await waitFor(() => expect(t.show).toHaveBeenCalledWith("Too large", "error")); + expect(onClose).not.toHaveBeenCalled(); + expect(written).toEqual([]); + }); + + it("a document shared live already opens on it, with its link and a way to stop", async () => { + const liveShares = { publish: vi.fn(), unpublish: vi.fn(async () => {}), linkFor: vi.fn((id, view) => `https://x/#live=ID${view ? `&view=${view}` : ""}`) }; + setup({ liveShares }); + expect(screen.getByRole("radio", { name: /Live, read-only/ }).getAttribute("aria-checked")).toBe("true"); + expect(screen.getByLabelText("Live link").value).toBe("https://x/#live=ID&view=split"); + fireEvent.click(screen.getByRole("button", { name: "Stop sharing live" })); + await waitFor(() => expect(liveShares.unpublish).toHaveBeenCalledWith("d1")); + await waitFor(() => expect(screen.getByRole("radio", { name: /Editable copy/ }).getAttribute("aria-checked")).toBe("true")); + }); + + it("a folder can be shared as copies, not live", async () => { + const liveShares = { publish: vi.fn(), unpublish: vi.fn(), linkFor: vi.fn(() => null) }; + const { onShareFolder } = setup({ liveShares, target: { kind: "folder", id: "f1", name: "Work" } }); + const live = screen.getByRole("radio", { name: /Live, read-only/ }); + expect(live.disabled).toBe(true); + expect(live.textContent).toMatch(/for a single document/); + fireEvent.click(screen.getByRole("radio", { name: /Read-only copy/ })); + fireEvent.click(screen.getByRole("button", { name: /Copy link/ })); + await waitFor(() => expect(onShareFolder).toHaveBeenCalledWith("f1", { view: "split", readOnly: true })); + }); +}); diff --git a/apps/web/src/app/cloud/api.ts b/apps/web/src/app/cloud/api.ts index 89d8291..ceb6928 100644 --- a/apps/web/src/app/cloud/api.ts +++ b/apps/web/src/app/cloud/api.ts @@ -10,11 +10,30 @@ export interface ServerDocument { size: number; } +/** A document the account publishes as a live share (app/publish), and the revision its copy holds. */ +export interface PublishedEntry { + docId: string; + id: string; + revision: string; +} + export interface ServerState { documents: ServerDocument[]; library: { tree: SyncedTree; updated: string } | null; + /** The account's live shares (absent from an older API). */ + published?: PublishedEntry[]; + /** A digest of the above, for asking "anything new?" next time (`state(since)`). */ + version?: string; } +/** `state(since)` when the account still matches `since`: nothing to read. */ +export interface UnchangedState { + unchanged: true; + version: string; +} + +export const isUnchanged = (s: ServerState | UnchangedState): s is UnchangedState => "unchanged" in s && s.unchanged === true; + export type WriteResult = { ok: true } | { ok: false; current: { updated: string; deleted: boolean } | null }; export interface PresignedTransfer { @@ -30,7 +49,9 @@ export interface PresignedTransfer { * (cloud-sync.test.js) and runs against the real one here. */ export interface CloudApi { - state(): Promise; + /** The account's state — or, given the `version` of the state last read and + * nothing has changed since, `{ unchanged }` (a few bytes instead of the list). */ + state(since?: string | null): Promise; /** The document, or null when the account has none (or only a tombstone). */ getDocument(id: string): Promise; putDocument(id: string, base: string | null, record: DocumentRecord): Promise; @@ -92,7 +113,8 @@ const BASE = "/api/sync"; /** The API refuses writes without it: a cross-site page cannot set it. */ const WRITE_HEADERS = { "X-Requested-With": "markdown-studio" }; -async function gzip(text: string): Promise<{ body: BodyInit; encoded: boolean }> { +/** A request body, gzipped where the browser can (CompressionStream). */ +export async function gzip(text: string): Promise<{ body: BodyInit; encoded: boolean }> { if (typeof CompressionStream === "undefined") return { body: text, encoded: false }; const stream = new Blob([text]).stream().pipeThrough(new CompressionStream("gzip")); return { body: await new Response(stream).arrayBuffer(), encoded: true }; @@ -136,8 +158,8 @@ async function putBytes(t: PresignedTransfer, bytes: Uint8Array): Promise } export const httpCloudApi: CloudApi = { - async state() { - return ok(await call("/state"), "sync state"); + async state(since) { + return ok(await call(since ? `/state?since=${encodeURIComponent(since)}` : "/state"), "sync state"); }, async getDocument(id) { const res = await call(`/documents/${encodeURIComponent(id)}`); diff --git a/apps/web/src/app/cloud/engine.ts b/apps/web/src/app/cloud/engine.ts index a3e2b53..64ba16e 100644 --- a/apps/web/src/app/cloud/engine.ts +++ b/apps/web/src/app/cloud/engine.ts @@ -4,7 +4,8 @@ import type { DocumentRecord } from "@/store/documents/document"; import type { SnapshotReason } from "@/store/history/snapshot-store"; import type { StorageAdapter } from "@/store/persistence/storage-adapter"; import type { TabSyncEvent } from "@/store/persistence/tab-sync"; -import type { CloudApi, PresignedTransfer, ServerState } from "./api"; +import { buildLiveDocument, type LiveDocument } from "@/store/documents/live-share"; +import { isUnchanged, type CloudApi, type PresignedTransfer, type PublishedEntry, type ServerState } from "./api"; import { EMPTY_TREE, mergeTrees, syncedPart, treeSignature, type SyncedTree } from "./folder-merge"; import { planSync, type SyncAction, type SyncedRevision } from "./plan"; @@ -51,6 +52,20 @@ export interface EngineDeps { announce: (events: TabSyncEvent[]) => void; kv: KeyValue; now?: () => string; + /** + * Live shares (app/publish). The account lists the documents it publishes + * and the revision each live copy holds; the engine publishes again any of + * them that is newer here. (Deleting a note stops its share on the server, + * in the deletion itself.) + */ + publish?: { + seen(entries: PublishedEntry[]): void; + /** Update an EXISTING share — `id` is the one it must still have; the + * server refuses a share that was stopped meanwhile. */ + put(docId: string, live: LiveDocument, id: string): Promise; + /** Why a share is not keeping up (null: it is). */ + problem?(docId: string, problem: "too-large" | null): void; + }; } interface PersistedState { @@ -88,6 +103,12 @@ const ORDER: Record = { pull: 0, deleteLocal: 0, con export class SyncEngine { private readonly now: () => string; + /** The account's state as last read, with its `version`: while the account + * says it is unchanged, a cycle plans from this instead of reading it again. */ + private lastState: ServerState | null = null; + /** docId → the revision that could not be published, so it is not rebuilt + * (every image read, encoded and compressed) on every cycle until it moves. */ + private readonly publishFailed = new Map(); constructor(private readonly d: EngineDeps) { this.now = d.now ?? (() => new Date().toISOString()); } @@ -122,6 +143,38 @@ export class SyncEngine { private savePending(p: Record) { this.d.kv.set(this.pendingKey, JSON.stringify(p)); } + /** + * Drop the deletions this cycle has finished with from the STORED list. A + * cycle used to write back the copy it loaded when it started, so a note + * deleted while it ran (recordLocalDelete, from the channel) was erased from + * the list — and the next cycle, finding it missing here and present in the + * account, downloaded it again. + */ + private settlePending(done: ReadonlySet) { + if (!done.size) return; + const stored = this.loadPending(); + let changed = false; + for (const id of done) { + if (id in stored) { + delete stored[id]; + changed = true; + } + } + if (changed) this.savePending(stored); + } + + /** The account's state, read again only when it has changed since the last cycle. */ + private async serverState(): Promise { + const since = this.lastState?.version ?? null; + let reply = await this.d.api.state(since); + if (isUnchanged(reply)) { + if (this.lastState) return this.lastState; + reply = await this.d.api.state(); // nothing to fall back on: read it all + if (isUnchanged(reply)) throw new Error("the account answered unchanged to a full read"); + } + this.lastState = reply.version ? reply : null; + return reply; + } /** * The editor deleted a document in this browser: delete it in the account at @@ -139,12 +192,13 @@ export class SyncEngine { reset(): void { this.save(freshState()); this.savePending({}); + this.lastState = null; } async cycle(progress?: (done: number, total: number) => void): Promise { const state = this.load(); const pending = this.loadPending(); - const server: ServerState = await this.d.api.state(); + const server = await this.serverState(); const result: CycleResult = { pulled: 0, pushed: 0, deletedHere: 0, deletedThere: 0, conflicts: 0, again: false, skipped: [] }; const local = new Map(); @@ -160,15 +214,17 @@ export class SyncEngine { let done = 0; progress?.(0, actions.length); for (const a of actions) { - await this.apply(a, state, pending, result, uploaded, retry); + const settled = new Set(); + await this.apply(a, state, settled, result, uploaded, retry, local.get(a.id)?.updated ?? null); state.uploaded = [...uploaded]; state.assetRetry = [...retry]; this.save(state); - this.savePending(pending); + this.settlePending(settled); progress?.(++done, actions.length); } await this.syncTree(state, server.library, result); + await this.syncPublished(server.published ?? [], local); if (retry.size) await this.downloadAssets([...retry], uploaded, retry); state.uploaded = [...uploaded]; state.assetRetry = [...retry]; @@ -176,13 +232,20 @@ export class SyncEngine { return result; } + /** + * `planned` is the local revision the plan was made from (null: none here). + * Anything that replaces or removes the local copy first checks it is still + * that one: the user may have saved a newer one — in this tab or another — + * while the account was being read, and that save must not be written over. + */ private async apply( a: SyncAction, state: PersistedState, - pending: Record, + settled: Set, r: CycleResult, uploaded: Set, retry: Set, + planned: string | null, ): Promise { switch (a.kind) { case "markSynced": @@ -190,25 +253,28 @@ export class SyncEngine { return; case "forget": delete state.docs[a.id]; - delete pending[a.id]; + settled.add(a.id); return; - case "pull": - if (await this.pull(a.id, state, uploaded, retry, false)) r.pulled++; - delete pending[a.id]; + case "pull": { + const outcome = await this.pull(a.id, state, uploaded, retry, false, planned); + if (outcome === "pulled") r.pulled++; + if (outcome === "changed") r.again = true; + else settled.add(a.id); return; + } case "push": if (await this.push(a.id, a.base, state, uploaded)) r.pushed++; else r.again = true; return; case "deleteLocal": - await this.deleteLocal(a.id, state); - r.deletedHere++; + if (await this.deleteLocal(a.id, state, planned)) r.deletedHere++; + else r.again = true; return; case "deleteRemote": { const res = await this.d.api.deleteDocument(a.id, a.base, a.at); if (res.ok) { delete state.docs[a.id]; - delete pending[a.id]; + settled.add(a.id); r.deletedThere++; } else r.again = true; return; @@ -223,12 +289,14 @@ export class SyncEngine { if (await this.push(a.id, a.server.updated, state, uploaded)) r.pushed++; else r.again = true; } else if (a.server.deleted) { - await this.deleteLocal(a.id, state); - r.deletedHere++; + if (await this.deleteLocal(a.id, state, planned)) r.deletedHere++; + else r.again = true; } else { const mine = await this.readLocal(a.id); if (mine) await this.d.snapshots.record(a.id, mine.title, mine.canonical, "cloud-sync"); - if (await this.pull(a.id, state, uploaded, retry, true)) r.pulled++; + const outcome = await this.pull(a.id, state, uploaded, retry, true, planned); + if (outcome === "pulled") r.pulled++; + if (outcome === "changed") r.again = true; } return; } @@ -243,10 +311,34 @@ export class SyncEngine { } } - /** The account's version replaces (or creates) the local one. False when the account has none any more. */ - private async pull(id: string, state: PersistedState, uploaded: Set, retry: Set, snapshotTaken: boolean): Promise { + /** + * Is the local copy still the revision the plan was made from? An unreadable + * one counts as unchanged — the account's copy is what repairs it, so refusing + * here would leave it broken (and the cycle re-running for ever). + */ + private async stillPlanned(id: string, planned: string | null): Promise { + try { + return ((await this.d.storage.get(id))?.updated ?? null) === planned; + } catch { + return true; + } + } + + /** + * The account's version replaces (or creates) the local one. "gone" when the + * account has none any more; "changed" when the local copy is no longer the + * one the plan saw (`planned`) — it is left alone and the next cycle decides. + */ + private async pull( + id: string, + state: PersistedState, + uploaded: Set, + retry: Set, + snapshotTaken: boolean, + planned: string | null, + ): Promise<"pulled" | "gone" | "changed"> { const rec = await this.d.api.getDocument(id); - if (!rec) return false; // deleted meanwhile: the next cycle sees the tombstone + if (!rec) return "gone"; // deleted meanwhile: the next cycle sees the tombstone if (rec.id !== id || typeof rec.updated !== "string" || (rec.canonical as { type?: unknown } | undefined)?.type !== "doc") { throw new Error(`the account returned a malformed document for ${id}`); } @@ -256,10 +348,14 @@ export class SyncEngine { } // Images first, so the editor finds them when it shows the note. await this.downloadAssets([...collectAssetRefs(rec.canonical)], uploaded, retry); + // Reading the account and its images took a while: if a newer local save + // landed meanwhile, writing the account's copy now would silently undo it + // (the editor ignores the announcement, its own revision being newer). + if (!(await this.stillPlanned(id, planned))) return "changed"; await this.d.storage.save({ ...rec, _preserveUpdated: true } as DocumentRecord); state.docs[id] = { local: rec.updated, server: rec.updated }; this.d.announce([{ type: "doc", id, updated: rec.updated }]); - return true; + return "pulled"; } /** Upload the local version. False when the account changed meanwhile (the next cycle decides again). */ @@ -273,12 +369,15 @@ export class SyncEngine { return true; } - private async deleteLocal(id: string, state: PersistedState): Promise { + /** The account deleted it: delete it here. False when it was edited here since the plan (kept). */ + private async deleteLocal(id: string, state: PersistedState, planned: string | null): Promise { + if (!(await this.stillPlanned(id, planned))) return false; const existing = await this.readLocal(id); if (existing) await this.d.snapshots.record(id, existing.title, existing.canonical, "pre-delete"); await this.d.storage.delete(id); delete state.docs[id]; this.d.announce([{ type: "deleted", id }]); + return true; } private async uploadAssets(rec: DocumentRecord, uploaded: Set): Promise { @@ -326,6 +425,38 @@ export class SyncEngine { } } + /** + * Keep this account's live shares current: publish again each one whose + * document here is at a newer revision than its live copy (it was edited — + * on this device or another). Update only, under the share's id, so a cycle + * that read the list before "Stop sharing live" cannot revive it. A failure + * never fails the sync: an offline one is tried next cycle; one too large to + * publish is not tried again until the document changes, and is reported. + */ + private async syncPublished(entries: PublishedEntry[], local: ReadonlyMap): Promise { + const pub = this.d.publish; + if (!pub) return; + pub.seen(entries); + for (const e of entries) { + // The listing read at the start of the cycle rules most out without a + // read; a document pulled this cycle is read to be sure. + if (local.get(e.docId)?.updated === e.revision) continue; + let rec: DocumentRecord | null = null; + try { + rec = await this.readLocal(e.docId); + if (!rec || rec.updated === e.revision || this.publishFailed.get(e.docId) === rec.updated) continue; + await pub.put(e.docId, await buildLiveDocument(rec, (hash) => this.d.assets.get(hash)), e.id); + if (this.publishFailed.delete(e.docId)) pub.problem?.(e.docId, null); + } catch (err) { + if (rec && err instanceof Error && err.name === "PublishTooLargeError") { + this.publishFailed.set(e.docId, rec.updated); + pub.problem?.(e.docId, "too-large"); + } + /* otherwise offline or refused: next cycle */ + } + } + } + private async syncTree(state: PersistedState, remote: ServerState["library"], r: CycleResult): Promise { const prefs = await this.d.storage.getPrefs(); const rawLocal = prefs["folderTree"]; diff --git a/apps/web/src/app/cloud/index.ts b/apps/web/src/app/cloud/index.ts index d929d2e..1f702d4 100644 --- a/apps/web/src/app/cloud/index.ts +++ b/apps/web/src/app/cloud/index.ts @@ -8,20 +8,27 @@ import { recordFullBackup } from "@/store/persistence/storage-durability"; import { parseTabSyncMessage, tabSyncChannel, tabSyncChannelName, type TabSyncEvent } from "@/store/persistence/tab-sync"; import { liveBackupSources } from "../../utils/emergency-backup"; import { safeStorage } from "../../utils/safe-storage"; -import { CloudError, httpAccountArchive, httpCloudApi } from "./api"; +import { CloudError, httpAccountArchive, httpCloudApi, isUnchanged } from "./api"; import { backedUpToday, backUpToAccount, type BackupDeps, type BackupOutcome } from "./backup"; import { SyncEngine } from "./engine"; import { untouchedStarterIds, withoutNotes } from "./starters"; import { accountVersionsProvider } from "./versions"; +import { notePublishProblem, rememberPublished } from "../publish"; +import { publishDocument } from "../publish/api"; /** * Cloud sync in the standalone app: one SyncEngine per signed-in session, * driven by what happens in the library. * * - When: right away, a moment after any change in this browser's library - * (the editor's own tab-sync announcements), when the tab regains focus or - * the network returns, and every 30 s. - * - One tab at a time (a Web Lock), so two tabs never upload the same note. + * (the editor's own tab-sync announcements), when the page is shown again, + * regains focus or the network returns, and on a poll that follows use: + * every 5 s for a minute after anything moved, every 15 s while the page is + * on screen, every 5 minutes behind other windows. A poll that finds the + * account unchanged costs one tiny request. + * - One tab at a time (a Web Lock), so two tabs never upload the same note. A + * tab that finds another syncing tries again shortly, so the change that + * woke it is not left for the next poll. * - One account per browser library: the first account to sync this browser's * library owns it; another account signing in here does not sync — its * notes would otherwise be mixed with the first person's in either account. @@ -41,8 +48,20 @@ const setPhase = (phase: CloudPhase, synced = false) => useCloudStatus.setState((s) => ({ phase, lastSyncedAt: synced ? Date.now() : s.lastSyncedAt })); const OWNER_KEY = "pf:cloud-owner"; -const INTERVAL_MS = 30_000; -const SETTLE_MS = 1_500; +// How often the account is asked "anything new?". Cheap when nothing is: the +// engine sends the version of the state it holds and is told "unchanged" — a +// few bytes, not the whole list (engine.ts serverState). +/** For a minute after anything moved (a change here, or one pulled from another device). */ +const ACTIVE_MS = 5_000; +const ACTIVE_WINDOW_MS = 60_000; +/** While the page is on screen. */ +const VISIBLE_MS = 15_000; +/** In the background: no one is looking, and coming back syncs at once. */ +const HIDDEN_MS = 5 * 60_000; +/** A focus or visibility wake this soon after the last cycle is not worth one. */ +const WAKE_GAP_MS = 2_000; +/** After a change in the library, wait this long for more before syncing. */ +const SETTLE_MS = 750; const SOURCE = `cloud-sync:${Math.random().toString(36).slice(2)}`; /** The day's backup waits this long after sign-in, so it does not compete with opening the library. */ const BACKUP_DELAY_MS = 60_000; @@ -96,6 +115,8 @@ function makeEngine(userId: string, announce: (e: TabSyncEvent[]) => void): Sync assets: assetStore, announce, kv: safeStorage, + // Live shares: keep each one this account publishes current (app/publish). + publish: { seen: rememberPublished, put: publishDocument, problem: notePublishProblem }, }); } @@ -112,7 +133,7 @@ async function dropStarterCopies(storage: IndexedDBStorageAdapter, docs: Documen if (starters.size === 0) return docs.length; try { const state = await httpCloudApi.state(); - if (!state.documents.some((d) => !d.deleted)) return docs.length; // the account's first device: keep them + if (isUnchanged(state) || !state.documents.some((d) => !d.deleted)) return docs.length; // the account's first device: keep them for (const id of starters) await storage.delete(id); const prefs = await storage.getPrefs(); const active = prefs["lastActiveDocId"]; @@ -171,6 +192,10 @@ export function startCloudSync(userId: string): () => void { let timer: ReturnType | null = null; let running = false; let rerun = false; + /** When something last moved — a change here, or a cycle that moved a note. */ + let lastActivity = 0; + /** When the last cycle ended. */ + let lastRunAt = 0; const channel = openChannel((events, fromSync) => { if (fromSync) return; // another tab's sync: nothing new in the library @@ -184,23 +209,46 @@ export function startCloudSync(userId: string): () => void { setPhase("off"); } } + lastActivity = Date.now(); schedule(SETTLE_MS); }); const engine = makeEngine(userId, channel.post); const unregisterVersions = setAccountVersions(accountVersionsProvider(httpAccountArchive, snapshotStore)); + // The day's backup builds a ZIP of the whole library: it runs beside sync, + // under a lock of its own, never inside a sync cycle (which it used to hold — + // and every tab's sync with it — for as long as the ZIP took). let nextBackupAt = Date.now() + BACKUP_DELAY_MS; + let backingUp = false; const dailyBackup = async () => { - if (Date.now() < nextBackupAt || backedUpToday(userId, backupDeps)) return; + if (backingUp || Date.now() < nextBackupAt || backedUpToday(userId, backupDeps)) return; + backingUp = true; + const attempt = async () => { + try { + const outcome = await backUpToAccount(userId, backupDeps); + // Nothing to back up, or it cannot be: not again on every cycle. + if (outcome !== "stored" && outcome !== "exists") nextBackupAt = Date.now() + BACKUP_RETRY_MS; + } catch { + nextBackupAt = Date.now() + BACKUP_RETRY_MS; // never the sync's failure + } + }; try { - const outcome = await backUpToAccount(userId, backupDeps); - // Nothing to back up, or it cannot be: not again on every cycle. - if (outcome !== "stored" && outcome !== "exists") nextBackupAt = Date.now() + BACKUP_RETRY_MS; + const locks = typeof navigator !== "undefined" ? navigator.locks : undefined; + if (locks) await locks.request(`pf-cloud-backup:${userId}`, { ifAvailable: true }, async (lock) => (lock ? attempt() : undefined)); + else await attempt(); } catch { - nextBackupAt = Date.now() + BACKUP_RETRY_MS; // never the sync's failure + /* the lock itself failed: the next cycle tries again */ + } finally { + backingUp = false; } }; + /** How long until the next poll: see ACTIVE_MS, VISIBLE_MS, HIDDEN_MS. */ + const pollDelay = () => + typeof document !== "undefined" && document.visibilityState === "hidden" ? HIDDEN_MS + : Date.now() - lastActivity < ACTIVE_WINDOW_MS ? ACTIVE_MS + : VISIBLE_MS; + const run = async () => { if (stopped || paused) return; if (running) { @@ -208,24 +256,46 @@ export function startCloudSync(userId: string): () => void { return; } running = true; - setPhase("syncing"); + let busy = false; try { const work = async () => { + setPhase("syncing"); // once the lock is held: not while another tab syncs const r = await engine.cycle(); if (r.again) rerun = true; - await dailyBackup(); + if (r.pulled || r.pushed || r.deletedHere || r.deletedThere || r.conflicts) lastActivity = Date.now(); }; const locks = typeof navigator !== "undefined" ? navigator.locks : undefined; - if (locks) await locks.request(`pf-cloud-sync:${userId}`, { ifAvailable: true }, async (lock) => (lock ? work() : undefined)); - else await work(); - if (!stopped) setPhase("synced", true); + if (locks) { + await locks.request(`pf-cloud-sync:${userId}`, { ifAvailable: true }, async (lock) => { + if (lock) await work(); + else busy = true; + }); + } else await work(); + // Another tab was syncing: what woke this one may have come after that + // cycle read the library, so try again shortly — and do not claim + // "synced" for a cycle that did not run (it used to, and dropped the + // change that woke it until the next poll). + if (busy) rerun = true; + else if (!stopped) { + setPhase("synced", true); + void dailyBackup(); + } } catch (e) { if (!stopped) setPhase(e instanceof CloudError && e.offline ? "offline" : "error"); } finally { running = false; - if (rerun && !stopped) { - rerun = false; - schedule(SETTLE_MS); + lastRunAt = Date.now(); + if (!stopped) { + if (rerun) { + rerun = false; + // Another tab held the lock: try again soon if this page is in + // use, else at its ordinary poll — every hidden tab retrying at + // once would read the account's state once each per edit. + const hidden = typeof document !== "undefined" && document.visibilityState === "hidden"; + schedule(busy ? (hidden ? pollDelay() : SETTLE_MS * 2) : SETTLE_MS); + } else if (!timer) { + schedule(pollDelay()); + } } } }; @@ -239,18 +309,24 @@ export function startCloudSync(userId: string): () => void { }, ms); } - const onWake = () => schedule(0); - const interval = setInterval(() => void run(), INTERVAL_MS); + // Back on screen, focused again, or online again: sync now — unless a cycle + // has only just run (a click into the window fires focus and visibility). + const onWake = () => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + if (Date.now() - lastRunAt < WAKE_GAP_MS) return; + schedule(0); + }; window.addEventListener("focus", onWake); window.addEventListener("online", onWake); + document.addEventListener("visibilitychange", onWake); schedule(0); return () => { stopped = true; if (timer) clearTimeout(timer); - clearInterval(interval); window.removeEventListener("focus", onWake); window.removeEventListener("online", onWake); + document.removeEventListener("visibilitychange", onWake); channel.close(); unregisterVersions(); setPhase("off"); diff --git a/apps/web/src/app/publish/api.ts b/apps/web/src/app/publish/api.ts new file mode 100644 index 0000000..3306cce --- /dev/null +++ b/apps/web/src/app/publish/api.ts @@ -0,0 +1,105 @@ +import type { LiveDocument } from "@/store/documents/live-share"; +import { CloudError, gzip } from "../cloud/api"; + +/** + * The account server's side of live shares (apps/api modules/sync/routes: + * `PUT|DELETE /api/sync/published/:docId` for the owner, `GET + * /api/published/:id` for anyone with the link). + */ + +/** What a live share's link reads: the document, and the owner's revision of it. */ +export interface Publication { + v: 1; + id: string; + title: string; + revision: string; + /** A `perssonify-document` envelope (store/documents/live-share.ts). */ + document: Record; +} + +export type PublicationReply = + | { kind: "publication"; publication: Publication } + /** The follower already has this revision (`since`). */ + | { kind: "unchanged" } + /** Not (or no longer) published. */ + | { kind: "gone" }; + +/** + * The most a publish request may carry. The app runs as a Vercel Function, + * which refuses a request body over 4.5 MB — and a live copy carries its + * images inside. Refused here, with a message, rather than by the platform. + */ +export const MAX_PUBLISH_BODY = 4 * 1024 * 1024; + +const WRITE_HEADERS = { "X-Requested-With": "markdown-studio" }; + +/** A request gives up after this long: opening a live link waits on one. */ +const REQUEST_TIMEOUT_MS = 15_000; + +async function call(url: string, init: RequestInit = {}): Promise { + const signal = typeof AbortSignal !== "undefined" && "timeout" in AbortSignal ? AbortSignal.timeout(REQUEST_TIMEOUT_MS) : undefined; + try { + return await fetch(url, { credentials: "same-origin", cache: "no-store", signal, ...init }); + } catch (e) { + throw new CloudError(e instanceof Error ? e.message : "network error", 0, true); + } +} + +export class PublishTooLargeError extends Error { + constructor() { + super("too large to publish"); + this.name = "PublishTooLargeError"; + } +} + +/** + * Publish a document's live share, creating it if there is none, and return + * its public id. With `id` it is an UPDATE of that share only (the sync + * engine's republish): one stopped meanwhile is refused (409), not revived. + */ +export async function publishDocument(docId: string, live: LiveDocument, id?: string): Promise<{ id: string; revision: string }> { + const { body, encoded } = await gzip(JSON.stringify(id ? { ...live, id } : live)); + const size = typeof body === "string" ? new Blob([body]).size : (body as ArrayBuffer).byteLength; + if (size > MAX_PUBLISH_BODY) throw new PublishTooLargeError(); + const res = await call(`/api/sync/published/${encodeURIComponent(docId)}`, { + method: "PUT", + headers: { ...WRITE_HEADERS, "Content-Type": "application/json", ...(encoded ? { "Content-Encoding": "gzip" } : {}) }, + body, + }); + if (res.status === 413) throw new PublishTooLargeError(); + if (!res.ok) throw new CloudError(`publish failed (${res.status})`, res.status); + return (await res.json()) as { id: string; revision: string }; +} + +/** Stop publishing it. */ +export async function unpublishDocument(docId: string): Promise { + const res = await call(`/api/sync/published/${encodeURIComponent(docId)}`, { method: "DELETE", headers: WRITE_HEADERS }); + if (!res.ok) throw new CloudError(`unpublish failed (${res.status})`, res.status); +} + +const PUBLISHED_ID = /^[A-Za-z0-9_-]{22}$/; +export const isPublishedId = (id: unknown): id is string => typeof id === "string" && PUBLISHED_ID.test(id); + +/** Read a live share — as anyone with its link, signed in or not. */ +export async function readPublication(id: string, since?: string | null): Promise { + if (!isPublishedId(id)) return { kind: "gone" }; + const res = await call(`/api/published/${encodeURIComponent(id)}${since ? `?since=${encodeURIComponent(since)}` : ""}`, { credentials: "omit" }); + if (res.status === 404) { + // Only the route's own answer means the owner stopped sharing it. Any + // other 404 (a deploy without the route, a proxy) is an error to retry — + // ending every follower's copy on it would be permanent. + const err = ((await res.json().catch(() => null)) as { error?: unknown } | null)?.error; + if (err === "not_published") return { kind: "gone" }; + throw new CloudError("live share not found (unexpected)", 404); + } + if (!res.ok) throw new CloudError(`live share failed (${res.status})`, res.status); + const body = (await res.json()) as Partial & { unchanged?: boolean }; + if (body.unchanged) return { kind: "unchanged" }; + if ( + body.v !== 1 || typeof body.revision !== "string" || !body.document || typeof body.document !== "object" || + (body.document as { _format?: unknown })._format !== "perssonify-document" + ) { + throw new CloudError("the live share is malformed", 502); + } + return { kind: "publication", publication: { v: 1, id, title: typeof body.title === "string" ? body.title : "", revision: body.revision, document: body.document } }; +} diff --git a/apps/web/src/app/publish/index.ts b/apps/web/src/app/publish/index.ts new file mode 100644 index 0000000..8440dd1 --- /dev/null +++ b/apps/web/src/app/publish/index.ts @@ -0,0 +1,325 @@ +import { create } from "zustand"; +import type { LiveShares } from "@/editor/props"; +import { t } from "@/i18n"; +import { assetStore } from "@/store/assets/asset-store"; +import { prepareImportedDocAssets } from "@/store/assets/assets"; +import { sanitizeCanonicalNode } from "@/store/documents/canonical-sanitize"; +import type { DocumentRecord } from "@/store/documents/document"; +import { useDocumentStore } from "@/store/documents/documentStore"; +import { buildLiveDocument, LIVE_SHARE_KEY, liveShareOf, type LiveShareInfo } from "@/store/documents/live-share"; +import { snapshotStore } from "@/store/history/snapshot-store"; +import { isShareView, type SharePayload, type ShareView } from "@/store/importExport/share-link"; +import { IndexedDBStorageAdapter } from "@/store/persistence/idb-storage-adapter"; +import { tabSyncChannel, tabSyncChannelName, type TabSyncEvent } from "@/store/persistence/tab-sync"; +import type { PublishedEntry } from "../cloud/api"; +import { isPublishedId, publishDocument, PublishTooLargeError, readPublication, unpublishDocument, type Publication } from "./api"; + +/** + * Live shares in the standalone app — a document its owner publishes + * read-only, which the people who open its link FOLLOW: their copy is + * replaced by the owner's next version. + * + * - Owner: the Share dialog publishes it (`liveSharesApi`); afterwards every + * device of the owner's that syncs keeps it current (the sync engine + * republishes a document whose revision moved — app/cloud/engine.ts). + * - Follower: opening `/#live=` adds the document to the library — or + * opens it, when it is followed here already (`prepareLiveLink`) — and + * `startFollowing` checks every followed copy for a new version: on load, + * when the page is shown again, and every few minutes while it is on screen. + * A check that finds nothing new costs a few bytes (`?since=`). + * + * The copy here is an ordinary document carrying `liveShare` in its metadata + * (store/documents/live-share-meta.ts): the library marks it, the editor opens + * it read-only, and "Stop following" makes it the user's own. + */ + +// ── The owner's live shares ─────────────────────────────────────────────── + +interface PublishedState { + /** docId → its live share's public id. */ + byDoc: Record; + /** docId → why its live copy is not keeping up with the note (the sync + * engine could not publish its latest revision). */ + problems: Record; +} + +export const usePublished = create(() => ({ byDoc: {}, problems: {} })); + +/** The sync engine could (null) or could not publish a share's latest revision. */ +export function notePublishProblem(docId: string, problem: "too-large" | null): void { + usePublished.setState((s) => { + const problems = { ...s.problems }; + if (problem) problems[docId] = problem; + else delete problems[docId]; + return { problems }; + }); +} + +/** The account's list, as the sync engine reads it with the account's state. */ +export function rememberPublished(entries: PublishedEntry[]): void { + const byDoc: Record = {}; + for (const e of entries) byDoc[e.docId] = e.id; + const cur = usePublished.getState().byDoc; + const same = Object.keys(cur).length === entries.length && entries.every((e) => cur[e.docId] === e.id); + if (!same) usePublished.setState({ byDoc }); +} + +/** A live share's link: the app root, the id and the layout it opens in. */ +export function liveLink(id: string, view?: ShareView): string { + const root = typeof window !== "undefined" ? window.location.origin + "/" : "/"; + return `${root}#live=${id}${view ? `&view=${view}` : ""}`; +} + +/** What the editor's Share dialog uses for live shares (PerssonifyEditorProps.liveShares). */ +export const liveSharesApi: LiveShares = { + async publish(docId, view) { + const rec = await useDocumentStore.getState().getStorage().get(docId); + if (!rec) throw new Error(t("editor.documentNotFound")); + const live = await buildLiveDocument(rec, (hash) => assetStore.get(hash)); + let id: string; + try { + ({ id } = await publishDocument(docId, live)); + } catch (e) { + throw new Error(e instanceof PublishTooLargeError ? t("dialogs.shareLiveTooLarge") : t("dialogs.shareLiveFailed"), { cause: e }); + } + notePublishProblem(docId, null); + usePublished.setState((s) => ({ byDoc: { ...s.byDoc, [docId]: id } })); + return liveLink(id, view); + }, + async unpublish(docId) { + await unpublishDocument(docId); + usePublished.setState((s) => { + const byDoc = { ...s.byDoc }; + delete byDoc[docId]; + return { byDoc }; + }); + }, + linkFor(docId, view) { + const id = usePublished.getState().byDoc[docId]; + return id ? liveLink(id, view) : null; + }, + problemFor(docId) { + return usePublished.getState().problems[docId] === "too-large" ? t("dialogs.shareLiveStale") : null; + }, +}; + +// ── Following ───────────────────────────────────────────────────────────── + +/** `#live=&view=` — read during the first render, like `#share=`. */ +export function readLiveSeed(hash?: string): { id: string; view?: ShareView } | null { + const h = (hash ?? (typeof window !== "undefined" ? window.location.hash : "")).replace(/^#/, ""); + if (!h) return null; + const params = new URLSearchParams(h); + const id = params.get("live"); + if (!isPublishedId(id)) return null; + const view = params.get("view"); + return { id, ...(isShareView(view) ? { view } : {}) }; +} + +/** The library's own database — like another tab, as the sync engine does. */ +const openStorage = () => new IndexedDBStorageAdapter(); + +const SOURCE = `live-share:${Math.random().toString(36).slice(2)}`; + +/** Tell the editor (and other tabs) what changed — the tab-sync channel. */ +function announce(events: TabSyncEvent[]): void { + if (typeof BroadcastChannel === "undefined") return; + try { + const ch = new BroadcastChannel(tabSyncChannel() ?? tabSyncChannelName("idb", "perssonify-docs")); + ch.postMessage({ v: 1, from: SOURCE, events }); + ch.close(); + } catch { + /* no channel: the editor picks it up when the document is next opened */ + } +} + +const isNewer = (a: string, b: string) => (Date.parse(a) || 0) > (Date.parse(b) || 0); + +/** + * Put a new version of a followed document in place of the copy here. The + * copy it replaces goes to Version history. Content from someone else is + * cleaned exactly as an imported JSON document is (sanitizeCanonicalNode, the + * images through the import path). + */ +async function applyPublication(storage: IndexedDBStorageAdapter, seen: DocumentRecord, pub: Publication, live: LiveShareInfo): Promise { + const env = pub.document as { canonical?: unknown; assets?: unknown }; + // (Every read from storage sanitises again — createDocument — so this is the + // first of two layers, not the only one.) + const canonical = sanitizeCanonicalNode(env.canonical as DocumentRecord["canonical"]); + if (!canonical || (canonical as { type?: unknown }).type !== "doc") return false; + // The owner's copy was being fetched: read the note again, and leave it + // alone if it changed meanwhile — the user stopped following it, or typed + // into it — rather than writing the copy fetched before over that. + const rec = await stillFollowed(storage, seen, live); + if (!rec) return false; + try { + await snapshotStore.record(rec.id, rec.title, rec.canonical, "live-update"); + } catch { + /* history off or full: the update still lands */ + } + const now = new Date().toISOString(); + // The owner's revision, so every device following it writes the same one + // (and cloud sync sees nothing to reconcile) — unless the copy here is + // somehow newer, when a revision that went backwards would be ignored. + const updated = isNewer(pub.revision, rec.updated) ? pub.revision : now; + const next = { + ...rec, + canonical, + title: pub.title || rec.title, + userTitle: pub.title || rec.userTitle, + titlePinnedByUser: !!pub.title || rec.titlePinnedByUser, + updated, + metadata: { ...rec.metadata, [LIVE_SHARE_KEY]: { ...live, revision: pub.revision, unseen: true, ended: false, at: now } }, + _importAssets: env.assets, + _preserveUpdated: true, + } as DocumentRecord & { _importAssets?: unknown; _preserveUpdated: boolean }; + await prepareImportedDocAssets(next); + await storage.save(next); + announce([{ type: "doc", id: rec.id, updated }]); + return true; +} + +/** The owner stopped sharing it: no more updates, and it is the user's own now. */ +async function markEnded(storage: IndexedDBStorageAdapter, seen: DocumentRecord, live: LiveShareInfo): Promise { + const rec = await stillFollowed(storage, seen, live); + if (!rec) return false; + const next = { ...rec, metadata: { ...rec.metadata, [LIVE_SHARE_KEY]: { ...live, ended: true, unseen: true } }, _preserveUpdated: true }; + await storage.save(next as DocumentRecord); + announce([{ type: "doc", id: rec.id, updated: rec.updated, meta: true }]); + return true; +} + +/** + * The note as it is NOW, if it is still the one a check started from: same + * revision, still following the same share. Null when the user stopped + * following it, typed into it or deleted it while the share was being read — + * the check then leaves it alone rather than writing over that. + */ +async function stillFollowed(storage: IndexedDBStorageAdapter, seen: DocumentRecord, live: LiveShareInfo): Promise { + const rec = await storage.get(seen.id); + const current = rec ? liveShareOf(rec.metadata) : null; + return rec && rec.updated === seen.updated && current?.id === live.id ? rec : null; +} + +/** Check every followed copy here once. */ +export async function refreshFollowed(storage: IndexedDBStorageAdapter = openStorage()): Promise { + let changed = 0; + const followed = (await storage.list()).filter((s) => s.liveShare && !s.liveShare.ended); + for (const s of followed) { + try { + const rec = await storage.get(s.id); + const live = rec ? liveShareOf(rec.metadata) : null; + if (!rec || !live || live.ended) continue; + const reply = await readPublication(live.id, live.revision); + if (reply.kind === "unchanged") continue; + const done = reply.kind === "gone" ? await markEnded(storage, rec, live) : await applyPublication(storage, rec, reply.publication, live); + if (done) changed++; + } catch { + /* offline, or the server is down: the next check tries again */ + } + } + return changed; +} + +/** While the page is on screen, a followed document is checked this often. */ +const FOLLOW_INTERVAL_MS = 3 * 60_000; +/** A visibility or focus wake this soon after a check is not worth another. */ +const FOLLOW_WAKE_GAP_MS = 30_000; + +/** Keep followed documents up to date for as long as the app is open. Returns the stop function. */ +export function startFollowing(): () => void { + const storage = openStorage(); + let stopped = false; + let running = false; + let lastRun = 0; + let timer: ReturnType | null = null; + const run = async () => { + if (stopped || running) return; + running = true; + try { + // One tab at a time checks (a Web Lock): every open tab runs this loop, + // and two writing the same update would each keep a history copy. + const locks = typeof navigator !== "undefined" ? navigator.locks : undefined; + if (locks) await locks.request("pf-live-follow", { ifAvailable: true }, async (lock) => (lock ? refreshFollowed(storage) : undefined)); + else await refreshFollowed(storage); + } catch { + /* the next check tries again */ + } finally { + running = false; + lastRun = Date.now(); + schedule(FOLLOW_INTERVAL_MS); + } + }; + const schedule = (ms: number) => { + if (stopped) return; + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + if (document.visibilityState === "hidden") schedule(FOLLOW_INTERVAL_MS); + else void run(); + }, ms); + }; + const onWake = () => { + if (document.visibilityState === "hidden" || Date.now() - lastRun < FOLLOW_WAKE_GAP_MS) return; + schedule(0); + }; + document.addEventListener("visibilitychange", onWake); + window.addEventListener("focus", onWake); + window.addEventListener("online", onWake); + schedule(5_000); // let the editor open first + return () => { + stopped = true; + if (timer) clearTimeout(timer); + document.removeEventListener("visibilitychange", onWake); + window.removeEventListener("focus", onWake); + window.removeEventListener("online", onWake); + }; +} + +export type LiveLinkOutcome = + /** Not followed here yet: the editor imports it on boot (its `initialShare`). */ + | { kind: "import"; share: SharePayload } + /** Followed here already: brought up to date, and opened. */ + | { kind: "open"; docId: string } + /** No longer shared, or unreachable. */ + | { kind: "failed"; gone: boolean }; + +/** + * Before the editor opens on a `#live=` link: fetch the live share, and either + * hand it to the editor to import (a new follower) or bring the copy already + * followed here up to date and make it the document the editor opens. + */ +export async function prepareLiveLink(id: string, view?: ShareView): Promise { + let reply; + try { + reply = await readPublication(id); + } catch { + return { kind: "failed", gone: false }; + } + if (reply.kind !== "publication") return { kind: "failed", gone: true }; + const pub = reply.publication; + const storage = openStorage(); + try { + const existing = (await storage.list()).find((s) => s.liveShare?.id === id); + const rec = existing ? await storage.get(existing.id) : null; + const live = rec ? liveShareOf(rec.metadata) : null; + if (rec && live) { + if (live.revision !== pub.revision || live.ended) await applyPublication(storage, rec, pub, { ...live, ended: false }); + await storage.setPrefs({ lastActiveDocId: rec.id }); + return { kind: "open", docId: rec.id }; + } + } catch { + /* the library cannot be read here: import it as new */ + } + const info: LiveShareInfo = { id, revision: pub.revision, at: new Date().toISOString() }; + return { + kind: "import", + share: { + v: 1, + kind: "file", + name: pub.title, + docs: [{ path: [], title: pub.title, markdown: JSON.stringify(pub.document), format: "pmjson", metadata: { [LIVE_SHARE_KEY]: info } }], + ...(view ? { view } : {}), + }, + }; +} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..311fc86 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,94 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +/* + * A plain dialog — dismissed by Escape, by a click outside and by its ×. For + * a choice the user must make, use alert-dialog.tsx, which cannot be clicked + * away. The popup is capped to the viewport and lays out as header · body · + * footer: put long content in , which scrolls, so the header and + * the footer's buttons are always on screen. + */ + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogContent({ + className, + children, + closeLabel = "Close", + ...props +}: DialogPrimitive.Popup.Props & { + /** The × button's accessible name (pass the translated "Close"). */ + closeLabel?: string +}) { + return ( + + + + {children} + } + aria-label={closeLabel} + > + + + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +function DialogBody({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return +} + +function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } diff --git a/apps/web/src/constants.ts b/apps/web/src/constants.ts index b4a0d15..a8b3f7a 100644 --- a/apps/web/src/constants.ts +++ b/apps/web/src/constants.ts @@ -39,6 +39,15 @@ declare const __APP_VERSION__: string; export const VERSION = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "1.4.0"; +// The branch and commit the standalone app was built from (build/build-info.mjs, +// injected by vite.app.config.ts). Null in the library and the embed — and in +// a build made where git could not be asked. +declare const __APP_BUILD__: { branch: string | null; commit: string | null } | undefined; +export const BUILD_INFO: { branch: string | null; commit: string | null } = + typeof __APP_BUILD__ !== "undefined" && __APP_BUILD__ ? __APP_BUILD__ : { branch: null, commit: null }; +/** The branch production deploys from: a build from any other says which. */ +export const RELEASE_BRANCH = "main"; + export const MODES = { MD: "md", HTML: "html", diff --git a/apps/web/src/editor/props.ts b/apps/web/src/editor/props.ts index 6c0baf5..2137467 100644 --- a/apps/web/src/editor/props.ts +++ b/apps/web/src/editor/props.ts @@ -6,7 +6,20 @@ import type { DocumentRecord, DocumentSummary } from "../store/documents/documen import type { FidelityReport } from "../store/documents/fidelity"; import type { IDBStorageConfig, StorageMetrics } from "../store/persistence/idb-storage-adapter"; import type { GraphEdge, GraphNode } from "../store/links/link-index"; -import type { SharePayload } from "../store/importExport/share-link"; +import type { SharePayload, ShareView } from "../store/importExport/share-link"; + +/** What the host provides for live shares (`PerssonifyEditorProps.liveShares`). */ +export interface LiveShares { + /** Publish the document (or publish it again) and return its live link, + * opening in `view`. Rejects with a message to show when it cannot. */ + publish: (docId: string, view?: ShareView) => Promise; + /** Stop publishing it: the link stops working, followers keep their copy. */ + unpublish: (docId: string) => Promise; + /** The document's live link when it is published, else null. */ + linkFor: (docId: string, view?: ShareView) => string | null; + /** Why its live copy is behind the note (to show), else null. */ + problemFor?: (docId: string) => string | null; +} import type { StorageAdapter, StorageQuotaError } from "../store/persistence/storage-adapter"; /** The document graph the editor exports (wiki links between documents). */ @@ -188,6 +201,13 @@ export interface PerssonifyEditorProps { * Omitted by the library / iframe embed, where sharing works as before. */ shareDisabledReason?: string; + /** + * Standalone app only (it needs the account's server): LIVE shares. The + * Share dialog then offers a third kind of link — read-only, and kept + * up to date: people who open it follow the document, and get the owner's + * edits (app/publish). Omitted by the library / iframe embed. + */ + liveShares?: LiveShares; /** * Open read-only: editing disabled, formatting toolbar hidden, view mode diff --git a/apps/web/src/editor/useAutosave.ts b/apps/web/src/editor/useAutosave.ts index 15792b7..92f030c 100644 --- a/apps/web/src/editor/useAutosave.ts +++ b/apps/web/src/editor/useAutosave.ts @@ -221,6 +221,36 @@ export function useAutosave({ }; }, [saveStatus]); + // ── Save on leaving the page ────────────────────────────────────────────── + // Switching to another tab or window saves NOW rather than up to + // AUTOSAVE_MS later. The same document open in two tabs was the common way + // to hit "Changed in another tab — not saving": type in one tab, switch and + // type in the other within the autosave window, and the first tab's pending + // save was older than the second's. Saved on the way out, the second tab + // reloads it before the user starts typing there; it also reaches the + // account (and other devices) sooner. + useEffect(() => { + const flush = () => { + if (!initDoneRef.current) return; + const status = useDocumentStore.getState().saveStatus; + if (status !== "editing" && status !== "error") return; + if (autosaveTimerRef.current) { + clearTimeout(autosaveTimerRef.current); + autosaveTimerRef.current = null; + } + void autosaveRef.current(); + }; + const onVisibility = () => { + if (document.visibilityState === "hidden") flush(); + }; + window.addEventListener("blur", flush); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("blur", flush); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, []); + // ── Safety net before tab/window close ──────────────────────────────────── // Autosave keeps the unsaved window small (≤ AUTOSAVE_MS); if a save is still // pending, fire one last best-effort write and warn so the last edits aren't diff --git a/apps/web/src/editor/useCardHandlers.ts b/apps/web/src/editor/useCardHandlers.ts index 1c1ac46..bbeba5f 100644 --- a/apps/web/src/editor/useCardHandlers.ts +++ b/apps/web/src/editor/useCardHandlers.ts @@ -8,6 +8,7 @@ import { computeFidelity } from "../store/documents/fidelity"; import type { FolderTreeData } from "../store/documents/folders"; import { sanitizeForIngest } from "../store/importExport/ingest"; import { canonicalMisfit, isNewerSchema } from "../store/documents/schema-version"; +import { isLockedShare } from "../store/documents/live-share-meta"; import { snapshotStore } from "../store/history/snapshot-store"; import { t } from "../i18n"; import type { DocStore, Engine, Lifecycle, LinkIndexApi, SyncEngine, TiptapRef, Toast } from "./types"; @@ -142,6 +143,13 @@ export function useCardHandlers({ toast.show(t("editor.cardNewerVersion"), "warning"); return; } + // A shared document that is read-only here (a live copy being followed, a + // read-only share): the editor opens it read-only, and so must its card. + if (current && isLockedShare(current.metadata)) { + void snapshotStore.record(id, current.title || "Untitled", canonical, "manual").catch(() => {}); + toast.show(t("editor.cardSharedReadOnly"), "warning"); + return; + } // What this edit REPLACES — the live editor's copy when it holds the note. // Alignment cannot survive the markdown round trip, so carry it over onto // every block the edit left unchanged; and when the note holds anything the diff --git a/apps/web/src/editor/useEditorBoot.ts b/apps/web/src/editor/useEditorBoot.ts index 588c1ab..e08c0e6 100644 --- a/apps/web/src/editor/useEditorBoot.ts +++ b/apps/web/src/editor/useEditorBoot.ts @@ -2,6 +2,7 @@ import { type MutableRefObject, useEffect, useRef } from "react"; import { toast as toastSonner } from "sonner"; import { configureLocalStores, useDocumentStore } from "../store/documents/documentStore"; import { type FolderTreeData, useFolderStore } from "../store/documents/folders"; +import { READ_ONLY_SHARE_KEY } from "../store/documents/live-share-meta"; import { historyCache } from "../store/history/history-cache"; import type { IngestFormat } from "../store/importExport/ingest"; import { normalizeMarkdownFeatures } from "../store/preferences/settings"; @@ -337,11 +338,15 @@ export function useEditorBoot({ if (initialShare?.docs?.length) { try { await waitForEditor(3000); + // A read-only share: each copy opens read-only until the + // recipient makes it editable (store/documents/live-share-meta.ts). + const shared = initialShare.readOnly ? { [READ_ONLY_SHARE_KEY]: true } : undefined; const result = await importItems( initialShare.docs.map((d) => ({ raw: d.markdown, format: (d.format ?? "markdown") as IngestFormat, folderPath: d.path ?? [], + metadata: d.metadata || shared ? { ...shared, ...d.metadata } : undefined, })) ); if (result.firstDocId) await handleSwitchDoc(result.firstDocId); diff --git a/apps/web/src/editor/useExportActions.ts b/apps/web/src/editor/useExportActions.ts index 9ebbd1d..315e829 100644 --- a/apps/web/src/editor/useExportActions.ts +++ b/apps/web/src/editor/useExportActions.ts @@ -7,7 +7,7 @@ import { downloadBlob, exportAsHtml, exportAsJson, exportAsMarkdown, exportTimes import { printHtmlDocument } from "../store/importExport/print-export"; import { recordFullBackup } from "../store/persistence/storage-durability"; import { buildZipAsync, readDocuments } from "../store/importExport/zip-builder"; -import { buildShareUrl, SHARE_URL_SOFT_LIMIT, type ShareDoc } from "../store/importExport/share-link"; +import { buildShareUrl, SHARE_URL_SOFT_LIMIT, type ShareDoc, type ShareView } from "../store/importExport/share-link"; import { t } from "../i18n"; import { extForMime, replaceRefsInText } from "../store/assets/asset-refs"; import { @@ -16,6 +16,12 @@ import { import type { CmRef, DocStore, Engine, SyncEngine, TiptapRef, Toast } from "./types"; import type { useAutosave } from "./useAutosave"; +/** How a share link opens for its recipient (the Share dialog's choices). */ +export interface ShareLinkOptions { + view?: ShareView; + readOnly?: boolean; +} + /** The multi-document ZIP exporter, as the editor's `exportDocsRef` holds it. */ export type ExportDocs = (ids: string[], zipName: string, opts?: { canonical?: boolean }) => Promise | void; @@ -251,43 +257,48 @@ export function useExportActions({ // so pictures travel in the link and the recipient's import restores them to // the asset store — at the cost of a bigger link, which the length warning // covers. - const copyShareUrl = useCallback(async (url: string) => { + const copyShareUrl = useCallback(async (url: string): Promise => { const long = url.length > SHARE_URL_SOFT_LIMIT; try { await navigator.clipboard.writeText(url); toast.show(long ? t("editor.shareLinkCopiedLong") : t("editor.shareLinkCopied"), long ? "warning" : "success"); + return true; } catch { toast.show(t("editor.shareLinkCopyFailed"), "error"); + return false; } }, [toast]); - // Share ONE document as a link. - const handleShareDoc = useCallback(async (docId: string) => { + // Share ONE document as a link. Resolves true once the link is copied. + const handleShareDoc = useCallback(async (docId: string, opts: ShareLinkOptions = {}): Promise => { try { // Persist the open doc first so the link carries the latest edits. try { await autosaveRef.current(); } catch { /* fall back to stored copy */ } const rec = await useDocumentStore.getState().getStorage().get(docId); - if (!rec) { toast.show(t("editor.documentNotFound"), "error"); return; } + if (!rec) { toast.show(t("editor.documentNotFound"), "error"); return false; } const canonical = useDocumentStore.getState().hydratedDocId === docId ? getCanonical() : rec.canonical; const title = resolveDocTitle(rec, canonical).title || t("common.untitled"); const markdown = markdownForDoc(rec); - if (markdown == null) { toast.show(t("editor.shareFailed"), "error"); return; } + if (markdown == null) { toast.show(t("editor.shareFailed"), "error"); return false; } // Inline the doc's images as data URLs so they travel in the link. const inlined = await inlineAssetRefsInText(markdown); - await copyShareUrl(buildShareUrl({ v: 1, kind: "file", name: title, docs: [{ path: [], title, markdown: inlined.text }] })); + const copied = await copyShareUrl(buildShareUrl({ + v: 1, kind: "file", name: title, docs: [{ path: [], title, markdown: inlined.text }], ...shareOptions(opts), + })); const note = missingImagesNote(inlined.missing.length); if (note) toast.show(note, "warning"); - } catch { toast.show(t("editor.shareFailed"), "error"); } + return copied; + } catch { toast.show(t("editor.shareFailed"), "error"); return false; } }, [getCanonical, markdownForDoc, copyShareUrl, toast, autosaveRef]); // Share a whole FOLDER (its subtree) as one link, preserving folder structure. - const handleShareFolder = useCallback(async (folderId: string) => { + const handleShareFolder = useCallback(async (folderId: string, opts: ShareLinkOptions = {}): Promise => { try { try { await autosaveRef.current(); } catch { /* fall back to stored copy */ } const { folders, docParents } = useFolderStore.getState(); const root = folders.find((f) => f.id === folderId); const entries = collectFolderExport(folderId, folders, docParents); - if (entries.length === 0) { toast.show(t("editor.folderHasNoDocuments"), "warning"); return; } + if (entries.length === 0) { toast.show(t("editor.folderHasNoDocuments"), "warning"); return false; } const { docs: records } = await readDocuments(useDocumentStore.getState().getStorage(), entries.map((e) => e.docId)); const docs: ShareDoc[] = []; let missingImages = 0; @@ -302,16 +313,22 @@ export function useExportActions({ const title = resolveDocTitle(rec, rec.canonical).title || t("common.untitled"); docs.push({ path: dirSegments, title, markdown: inlined.text }); } - if (docs.length === 0) { toast.show(t("editor.shareFailed"), "error"); return; } - await copyShareUrl(buildShareUrl({ v: 1, kind: "folder", name: root?.name || t("library.folder"), docs })); + if (docs.length === 0) { toast.show(t("editor.shareFailed"), "error"); return false; } + const copied = await copyShareUrl(buildShareUrl({ v: 1, kind: "folder", name: root?.name || t("library.folder"), docs, ...shareOptions(opts) })); const note = missingImagesNote(missingImages); if (note) toast.show(note, "warning"); - } catch { toast.show(t("editor.shareFailed"), "error"); } + return copied; + } catch { toast.show(t("editor.shareFailed"), "error"); return false; } }, [markdownForDoc, copyShareUrl, toast, autosaveRef]); return { handleExportFolder, handleExportDoc, handleExportDocs, handleShareDoc, handleShareFolder }; } +/** The payload fields a share's options set — only the ones chosen. */ +function shareOptions(opts: ShareLinkOptions): { view?: ShareView; readOnly?: boolean } { + return { ...(opts.view ? { view: opts.view } : {}), ...(opts.readOnly ? { readOnly: true } : {}) }; +} + /** File ▸ Export ▸ All documents: the whole library as one ZIP. */ export function exportAllDocuments(exportDocsRef: MutableRefObject) { const ids = useDocumentStore.getState().documents.map((d) => d.id); diff --git a/apps/web/src/editor/useImportPipeline.ts b/apps/web/src/editor/useImportPipeline.ts index 490cc90..d76d16a 100644 --- a/apps/web/src/editor/useImportPipeline.ts +++ b/apps/web/src/editor/useImportPipeline.ts @@ -109,7 +109,7 @@ export function useImportPipeline({ // folder path), creating folders as needed. Shared by file-drop import and // URL import so both get identical folder handling and canonical capture. const importItems = useCallback(async ( - items: { raw: string; format: IngestFormat; folderPath: string[] }[], + items: { raw: string; format: IngestFormat; folderPath: string[]; metadata?: Record }[], opts: { assets?: ArchiveAsset[] } = {} ): Promise<{ importedCount: number; failedCount: number; firstDocId: string | null }> => { let importedCount = 0; @@ -163,6 +163,8 @@ export function useImportPipeline({ for (const item of items) { const doc = createDoc(item.raw, item.format, "file-import", { mdToHtml: engine?.mdToHtml }); if (!doc) continue; + // A shared document's flags (read-only, following a live share). + if (item.metadata) doc.metadata = { ...doc.metadata, ...item.metadata }; if (!firstDocId) { firstDocId = doc.id; firstDoc = doc as DocumentRecord; } const folderId = item.folderPath.length ? ensureFolder(item.folderPath) : null; diff --git a/apps/web/src/editor/useSharedDocLock.ts b/apps/web/src/editor/useSharedDocLock.ts new file mode 100644 index 0000000..5401050 --- /dev/null +++ b/apps/web/src/editor/useSharedDocLock.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef } from "react"; +import { isLockedShare, LIVE_SHARE_KEY, liveShareOf } from "../store/documents/live-share-meta"; +import { useDocumentStore } from "../store/documents/documentStore"; +import { useUIStore } from "../store/preferences/uiStore"; + +/** + * A shared document that is read-only here (store/documents/live-share-meta.ts) + * opens read-only: a read-only share link, or a copy following a live share, + * whose next update would replace anything typed into it. + * + * Read-only is the editor's SESSION flag (never the persisted preference), + * switched on when such a document is opened and back to what it was when a + * document that is not is opened — the way the newer-version lock does it + * (useDocumentLifecycle). Only the transitions act: the user can still turn + * read-only off from the View menu, and typing does not switch it back on. + * The way to make the document editable for good is the notice above the + * status bar (ui/notices/sharedDocNotice). + * + * Opening a live copy that has been updated also clears its "updated" mark in + * the library. + */ +export function useSharedDocLock() { + const forcedRef = useRef<{ prev: boolean } | null>(null); + const lastKeyRef = useRef(null); + const seenRef = useRef(null); + + useEffect(() => { + const apply = (s: ReturnType) => { + const doc = s.activeDoc && s.hydratedDocId === s.activeDoc.id ? s.activeDoc : null; + const locked = !!doc && isLockedShare(doc.metadata); + const key = doc ? `${doc.id}:${locked}` : null; + if (key !== lastKeyRef.current) { + lastKeyRef.current = key; + const ui = useUIStore.getState(); + if (locked) { + if (!forcedRef.current) forcedRef.current = { prev: ui.readOnly }; + if (!ui.readOnly) useUIStore.setState({ readOnly: true }); + } else if (forcedRef.current) { + useUIStore.setState({ readOnly: forcedRef.current.prev }); + forcedRef.current = null; + } + } + const live = doc ? liveShareOf(doc.metadata) : null; + if (doc && live?.unseen) { + const seen = `${doc.id}:${live.revision}`; + if (seenRef.current !== seen) { + seenRef.current = seen; + void s.patchDocById(doc.id, { metadata: { [LIVE_SHARE_KEY]: { ...live, unseen: false } }, touchUpdated: false }).catch(() => {}); + } + } + }; + apply(useDocumentStore.getState()); + return useDocumentStore.subscribe(apply); + }, []); +} diff --git a/apps/web/src/engine/preprocessor.ts b/apps/web/src/engine/preprocessor.ts index 13fa9a9..c867090 100644 --- a/apps/web/src/engine/preprocessor.ts +++ b/apps/web/src/engine/preprocessor.ts @@ -389,6 +389,74 @@ function applyCurlyFormatting(md: string): string { .split(ESC_CLOSE).join("\\}"); } +/** A list item's first line: indent, marker, the spaces after it, and text. */ +const LIST_ITEM_LINE = /^( *)([-*+]|\d{1,9}[.)])( +)\S/; +/** A setext heading underline: a run of `-` or of `=`, nothing else. */ +const UNDERLINE_LINE = /^( *)(-+|=+)[ \t]*$/; +/** Text that opens a block other than a paragraph — a heading, a quote, a + * fence, HTML or a table row. Under one of those an underline is not a + * heading underline (`- # Title` then ` ---` is a thematic break). */ +const NOT_PARAGRAPH = /^(?:#{1,6}(?:[ \t]|$)|>|```|~~~|<|\|)/; + +/** + * Escape a setext underline that would turn a list item's first paragraph + * into a heading. CommonMark (and marked) read `1. foo` followed by ` -` as + * an item holding an `

`, but a ProseMirror list item must START with a + * paragraph: the rich pane hoisted the heading out of the list and left an + * empty item behind — "Enter on a list item turned it into a heading". In an + * editor that line is never a heading underline; it is the half-typed start of + * a nested item (Enter, Backspace, `-` under `1. foo`; Tab on a new `- `), or + * a `===` typed under an item. Escaped, it reads as the text it is until the + * nested item has content (` - bar` is a list again). Only the md→rich + * projection sees the escape — the markdown itself is never changed — and the + * line count is kept, so nothing downstream shifts. + * + * `-` is escaped only from the item's content column (up to 3 more spaces): + * further left it is a sibling item or a thematic break, which is right. `=` + * cannot start anything, and marked also reads it as an underline LAZILY, + * further left, so any indent short of code is escaped. + */ +export function escapeListSetextUnderlines(md: string): string { + if (!/^ *(?:-+|=+)[ \t]*$/m.test(md)) return md; + const lines = md.split("\n"); + // Fenced code is content: leave every line inside a fence alone. + const fenced = new Set(); + const ranges = findFencedCodeRanges(md); + for (let l = 0, start = 0, r = 0; r < ranges.length && l < lines.length; l++) { + while (r < ranges.length && ranges[r]![1] <= start) r++; + if (r < ranges.length && start >= ranges[r]![0]) fenced.add(l); + start += lines[l]!.length + 1; + } + let changed = false; + for (let i = 1; i < lines.length; i++) { + const u = UNDERLINE_LINE.exec(lines[i]!); + if (!u || fenced.has(i)) continue; + // Walk up the paragraph above (no blank line) to the item it opens. + for (let j = i - 1; j >= 0; j--) { + const above = lines[j]!; + if (fenced.has(j) || !above.trim() || UNDERLINE_LINE.test(above)) break; + const item = LIST_ITEM_LINE.exec(above); + if (!item) { + if (NOT_PARAGRAPH.test(above.trimStart())) break; + continue; + } + // The item opens with something that is not a paragraph. + if (NOT_PARAGRAPH.test(above.slice(item[0].length - 1))) break; + const spaces = item[3]!.length; + // More than four spaces after the marker: the content starts one past it. + const contentCol = item[1]!.length + item[2]!.length + (spaces > 4 ? 1 : spaces); + const indent = u[1]!.length; + const isDash = u[2]![0] === "-"; + if (indent < contentCol + 4 && (!isDash || indent >= contentCol)) { + lines[i] = `${u[1]}\\${lines[i]!.slice(indent)}`; + changed = true; + } + break; + } + } + return changed ? lines.join("\n") : md; +} + export function preprocessMarkdown( md: string, features: PreprocessFeatures = {} @@ -429,6 +497,10 @@ export function preprocessMarkdown( ); } + // 0'. A setext underline under a list item's text is escaped, so the rich + // pane does not hoist that text out of the list as a heading. + md = escapeListSetextUnderlines(md); + // 0a. Field blocks: :::fields ... ::: → HTML if (fieldBlocks) { md = md.replace( diff --git a/apps/web/src/engine/turndown-rules.ts b/apps/web/src/engine/turndown-rules.ts index 6f92462..121d7ad 100644 --- a/apps/web/src/engine/turndown-rules.ts +++ b/apps/web/src/engine/turndown-rules.ts @@ -38,6 +38,40 @@ export const BLOCK_ELEMENTS = new Set([ const isBlockElement = (el: Element): boolean => BLOCK_ELEMENTS.has(el.nodeName) || el.nodeName === "DETAILS"; +/** A list item's marker: `- `, or its number — from the list's `start`. */ +function listMarker(el: Element): string { + const parent = el.parentNode; + if (!parent || (parent as Element).nodeName !== "OL") return "- "; + // Honor the list's `start` attribute so a list beginning at e.g. 5 + // round-trips as `5.` instead of resetting to `1.`. + const startAttr = (parent as Element).getAttribute("start"); + const start = startAttr ? parseInt(startAttr, 10) : 1; + const base = Number.isFinite(start) ? start : 1; + const idx = Array.from((parent as Element).children).indexOf(el); + return base + idx + ". "; +} + +/** + * Turndown's `blankReplacement` (pass it to the constructor: turndown copies + * the option into its rules when it is built). Its default — a block becomes a + * blank line, anything else nothing — except for an EMPTY list item, the one + * Enter has just made in the rich pane. A blank node never reaches a rule, so + * that item was dropped: the markdown pane did not show it, and one made + * mid-list printed as `1. foo⏎⏎3. bar` (a loose list, a number skipped). It is + * written as its bare marker wherever that reads back as an empty item — after + * a sibling item (CommonMark's `- foo⏎-⏎- bar`), or first in a list that is not + * nested (a blank line precedes it). First in a NESTED list it is still + * dropped: `1. foo⏎ -` is a heading underline. (An empty TASK item holds its + * checkbox, so it is never blank and its own rule writes it.) + */ +export function blankReplacement(_content: string, node: Node & { isBlock?: boolean }): string { + const el = node as Element; + if (el.nodeName === "LI" && (el.previousElementSibling?.nodeName === "LI" || !el.parentElement?.closest("li"))) { + return listMarker(el).trimEnd() + "\n"; + } + return node.isBlock ? "\n\n" : ""; +} + /** Boundary markers for a converted run (see convertRun); never part of the output. */ const SEAM_OPEN = "\uE000"; const SEAM_CLOSE = "\uE001"; @@ -659,17 +693,7 @@ export function addBaseRules(td: TurndownInstance): TurndownInstance { }, replacement: function (content: string, node?: Node): string { const el = node as Element; - const parent = el.parentNode; - let prefix = "- "; - if (parent && (parent as Element).nodeName === "OL") { - // Honor the list's `start` attribute so a list beginning at e.g. 5 - // round-trips as `5.` instead of resetting to `1.`. - const startAttr = (parent as Element).getAttribute("start"); - const start = startAttr ? parseInt(startAttr, 10) : 1; - const base = Number.isFinite(start) ? start : 1; - const idx = Array.from((parent as Element).children).indexOf(el); - prefix = base + idx + ". "; - } + const prefix = listMarker(el); listItemContent.set(el, content); return layoutListItem(el, content, prefix, prefix.length); }, diff --git a/apps/web/src/engine/useMarkdownEngine.ts b/apps/web/src/engine/useMarkdownEngine.ts index 0d6e8fe..d2d8325 100644 --- a/apps/web/src/engine/useMarkdownEngine.ts +++ b/apps/web/src/engine/useMarkdownEngine.ts @@ -8,7 +8,7 @@ import TurndownService from 'turndown'; import { preprocessMarkdown, normalizeMarkdown, type PreprocessFeatures } from './preprocessor'; import { createRichProjector, type RichPath, type RichPlan, type RichPrebuild } from './incremental-rich'; import { cleanHtmlForTurndown } from './html-cleaner'; -import { addBaseRules, addInlineStyleRule, installEscape, turndownLinear, type EscapeOptions } from './turndown-rules'; +import { addBaseRules, addInlineStyleRule, blankReplacement, installEscape, turndownLinear, type EscapeOptions } from './turndown-rules'; import { holdsBreakMark, portableBreaksActive, stripBreakMarks, withoutPortableBreaks } from './portable-breaks'; import { createIncrementalConverter } from './incremental-md'; import { buildMarked, resetFootnoteNumbering } from './build-marked'; @@ -252,8 +252,8 @@ export function createMarkdownEngine( // `hr: "---"`: turndown's default `* * *` rewrote every thematic break the // user typed on the first rich-pane edit. Its rule always emits the break // between blank lines, so `---` cannot read as a setext underline or open - // front matter. - const options = { headingStyle: "atx", codeBlockStyle: "fenced", emDelimiter: "*", hr: "---" } as const; + // front matter. `blankReplacement` keeps an empty list item (turndown-rules.ts). + const options = { headingStyle: "atx", codeBlockStyle: "fenced", emDelimiter: "*", hr: "---", blankReplacement } as const; // Escape exactly what the CURRENT settings give meaning to (turndown-rules.ts // escapeMarkdownText): `$` only while maths is on, `~~` only while diff --git a/apps/web/src/i18n/en/account.ts b/apps/web/src/i18n/en/account.ts index ea52b40..565b3d0 100644 --- a/apps/web/src/i18n/en/account.ts +++ b/apps/web/src/i18n/en/account.ts @@ -46,6 +46,9 @@ export const account = { "account.documentsNote": "Your documents and their images are saved in this browser and synced to your account.", "account.signingOut": "Signing out…", "account.bringingDocuments": "Bringing your documents from your account…", + "account.openingLiveShare": "Opening the shared document…", + "account.liveShareGone": "That live document is no longer shared.", + "account.liveShareUnreachable": "Couldn't open that live document. Check your connection and open the link again.", "account.syncSynced": "Synced with your account", "account.syncSyncing": "Syncing…", "account.syncOffline": "Offline — your changes will sync when you’re back online", diff --git a/apps/web/src/i18n/en/context.ts b/apps/web/src/i18n/en/context.ts index d3176ba..257cc43 100644 --- a/apps/web/src/i18n/en/context.ts +++ b/apps/web/src/i18n/en/context.ts @@ -37,4 +37,5 @@ export const context = { "context.diagramMermaid": "Diagram (Mermaid)", "context.musicScoreAbc": "Music score (ABC)", "context.horizontalRule": "Horizontal rule", + "context.browserMenuHint": "Spelling suggestions: Shift + right-click opens the browser's menu.", } satisfies Record<`context.${string}`, Message>; diff --git a/apps/web/src/i18n/en/dialogs.ts b/apps/web/src/i18n/en/dialogs.ts index c9e21c5..6fe04f6 100644 --- a/apps/web/src/i18n/en/dialogs.ts +++ b/apps/web/src/i18n/en/dialogs.ts @@ -98,6 +98,7 @@ export const dialogs = { "dialogs.reasonPreLinkUpdate": "Before links to a renamed heading were updated", "dialogs.reasonTabConflict": "Set aside when two tabs changed it", "dialogs.reasonCloudSync": "Set aside when syncing with your account", + "dialogs.reasonLiveUpdate": "Replaced by the owner's update to a live share", "dialogs.reasonAccountVersion": "Brought back from your account", "dialogs.reasonManual": "Manual snapshot", "dialogs.inYourAccount": "In your account", @@ -156,4 +157,30 @@ export const dialogs = { "dialogs.describeTheImage": "Describe the image", "dialogs.altTextPlaceholder": "e.g. Bar chart of sign-ups by month", "dialogs.altTextDescription": "Screen readers read this aloud, and it shows if the image cannot load. Leave it empty only if the image is purely decorative.", + + // ShareDialog + "dialogs.shareTitle": "Share “{name}”", + "dialogs.shareDescription": "Anyone with the link can open it.", + "dialogs.shareOpensIn": "Opens in", + "dialogs.shareViewSplit": "Split", + "dialogs.shareViewMarkdown": "Markdown", + "dialogs.shareViewHtml": "HTML", + "dialogs.shareAccess": "Access", + "dialogs.shareCopyTitle": "Editable copy", + "dialogs.shareCopyBody": "They get their own copy of it to edit. The document travels inside the link.", + "dialogs.shareReadOnlyTitle": "Read-only copy", + "dialogs.shareReadOnlyBody": "Their copy opens read-only. They can still choose to make it editable.", + "dialogs.shareLiveTitle": "Live, read-only", + "dialogs.shareLiveBody": "Stays up to date: when you edit it, everyone who opened the link gets your changes, and their library marks it as updated.", + "dialogs.shareLiveFolder": "Live sharing is for a single document.", + "dialogs.shareLiveOn": "Shared live. The link below keeps working until you stop it.", + "dialogs.shareLiveLink": "Live link", + "dialogs.shareLiveStop": "Stop sharing live", + "dialogs.shareLiveStopped": "Live sharing stopped. People who followed it keep their last copy.", + "dialogs.shareLiveFailed": "Couldn't share it live. Check your connection and that you're signed in, then try again.", + "dialogs.shareLiveTooLarge": "It's too large to share live — usually because of its images. Share it as a copy instead.", + "dialogs.shareLiveStale": "Your latest edits are not live yet: the note has grown too large to publish (usually its images). People following it still see the last version that fit.", + "dialogs.shareCopyLink": "Copy link", + "dialogs.shareCopyLiveLink": "Copy live link", + "dialogs.shareLiveLinkCopied": "Live link copied to clipboard.", } satisfies Record<`dialogs.${string}`, Message>; diff --git a/apps/web/src/i18n/en/editor.ts b/apps/web/src/i18n/en/editor.ts index a28dc8f..3ba11bb 100644 --- a/apps/web/src/i18n/en/editor.ts +++ b/apps/web/src/i18n/en/editor.ts @@ -41,6 +41,7 @@ export const editor = { "editor.couldNotCreateNote": "Could not create the note", "editor.editorNotReadyNoteNotSaved": "Editor not ready — note not saved", "editor.cardNewerVersion": "This note was made by a newer version of the app and can't be edited as a card — your typing is kept in Version history.", + "editor.cardSharedReadOnly": "This note was shared with you read-only — open it and make it editable first. Your typing is kept in Version history.", "editor.cardConflict": "This note was changed in another tab — your card edit was not saved over it. It is kept in Version history.", "editor.cardSaveFailed": "The note could not be saved — your card edit is kept in Version history.", "editor.cardAlignmentLost": "Alignment on the paragraphs you changed can't be kept in a card edit — the previous version is in Version history.", diff --git a/apps/web/src/i18n/en/library.ts b/apps/web/src/i18n/en/library.ts index 67f8de4..9482da0 100644 --- a/apps/web/src/i18n/en/library.ts +++ b/apps/web/src/i18n/en/library.ts @@ -32,6 +32,17 @@ export const library = { "library.newFolder": "New folder", "library.cancelSelection": "Cancel selection", "library.newDocument": "New document", + "library.sharedLive": "Live — gets its owner's edits", + "library.sharedLiveUpdated": "Live — updated since you last opened it", + "library.sharedLiveEnded": "No longer shared live — your own copy now", + "library.sharedReadOnly": "Shared with you read-only", + "library.sortBy": "Sort by", + "library.sortByCurrent": "Sort by: {order}", + "library.sortUpdated": "Last edited", + "library.sortName": "Name", + "library.sortCreated": "Date created", + "library.sortManual": "Custom order", + "library.sortHint": "Last edited keeps folders A–Z. Custom order never re-sorts: drag items to arrange them, and new ones go at the end.", "library.searchPlaceholder": "Search titles & content…", "library.searchDocuments": "Search documents", "library.noResults": "No results", @@ -45,7 +56,7 @@ export const library = { "library.moreOptions": "More options", "library.optionsFor": "Options for {name}", "library.duplicate": "Duplicate", - "library.shareLink": "Copy share link", + "library.shareLink": "Share…", "library.folder": "Folder", "library.exportMarkdown": "Markdown (.md)", "library.exportHtml": "HTML (.html)", diff --git a/apps/web/src/i18n/en/menu.ts b/apps/web/src/i18n/en/menu.ts index d46e435..40b3500 100644 --- a/apps/web/src/i18n/en/menu.ts +++ b/apps/web/src/i18n/en/menu.ts @@ -13,7 +13,7 @@ export const menu = { /** The phone ☰ button's name. */ "menu.menu": "Menu", "menu.share": "Share", - "menu.shareTitle": "Copy a share link to this note", + "menu.shareTitle": "Share this note: choose how it opens, then copy the link", "menu.newDocument": "New document", "menu.openFile": "Open file…", "menu.filesArchive": "File(s) / archive…", diff --git a/apps/web/src/i18n/en/notices.ts b/apps/web/src/i18n/en/notices.ts index f17ef22..88d3716 100644 --- a/apps/web/src/i18n/en/notices.ts +++ b/apps/web/src/i18n/en/notices.ts @@ -122,4 +122,21 @@ export const notices = { "notices.tabDeletedBody": "It isn't saved here any more. Keep it as a new document, or close it — Version history keeps a copy.", "notices.tabKeepAsNew": "Keep as a new document", "notices.tabClose": "Close", + + // SharedDocNotice — a shared document that is read-only here + "notices.liveLabel": "Live shared document", + "notices.liveHeadline": "Live copy — it updates when its owner edits it.", + "notices.liveBody": "It is read-only here, because the next update would replace anything you type. Stop following to keep it as your own document.", + "notices.liveUpdatedAt": "Updated {when}.", + "notices.liveStop": "Stop following", + "notices.liveStopConfirmTitle": "Stop following this document?", + "notices.liveStopConfirmBody": "It stays in your library as your own document, and you can edit it — but it will not get the owner's changes any more.", + "notices.liveEndedLabel": "No longer shared", + "notices.liveEndedHeadline": "Its owner stopped sharing this document.", + "notices.liveEndedBody": "It will not change any more. This copy is yours to keep and edit.", + "notices.liveEndedDismiss": "Got it", + "notices.readOnlyShareLabel": "Shared read-only", + "notices.readOnlyShareHeadline": "Shared with you read-only.", + "notices.readOnlyShareBody": "This is your own copy: make it editable whenever you like.", + "notices.makeEditable": "Make editable", } satisfies Record<`notices.${string}`, Message>; diff --git a/apps/web/src/i18n/en/settings.ts b/apps/web/src/i18n/en/settings.ts index 20319e4..acdba93 100644 --- a/apps/web/src/i18n/en/settings.ts +++ b/apps/web/src/i18n/en/settings.ts @@ -59,8 +59,8 @@ export const settings = { "settings.spellcheck": "Spellcheck", "settings.browserSpellcheckSquigglesInBoth": "Browser spellcheck squiggles in both panes.", "settings.admin": "Admin", - "settings.enableRightClickMenu": "Enable right-click menu", - "settings.customEditorContextMenuOff": "Custom editor context menu; off falls back to the browser menu.", + "settings.customRightClickMenu": "Custom right-click menu", + "settings.customRightClickMenuHint": "Formatting and insert commands on right-click in the editor and library. Off: the browser's own menu, with its spelling suggestions. Shift + right-click opens the browser's menu either way.", "settings.diagnosticsConsole": "Diagnostics console", "settings.addsAReadOnlyConsole": "Adds a read-only console tab to the bottom tray (lifecycle, storage, links).", "settings.showAds": "Show ads", diff --git a/apps/web/src/i18n/en/status.ts b/apps/web/src/i18n/en/status.ts index b831e6c..98a597b 100644 --- a/apps/web/src/i18n/en/status.ts +++ b/apps/web/src/i18n/en/status.ts @@ -62,6 +62,9 @@ export const status = { "status.totalCharacters": "Total characters", "status.charCountShort": { other: "{count}c" }, "status.keyboardShortcuts": "Keyboard shortcuts", + "status.versionOnly": "{app} {version}", + "status.versionCommit": "{app} {version}, commit {commit}", + "status.versionBuilt": "{app} {version} — built from {branch} at commit {commit}", "status.memoryAndUndoHistory": "Memory and undo history", "status.memoryTitle": "Undo-history memory (estimated): {used} of {budget} — click to manage", diff --git a/apps/web/src/i18n/en/tour.ts b/apps/web/src/i18n/en/tour.ts index d2716b7..95f0ad6 100644 --- a/apps/web/src/i18n/en/tour.ts +++ b/apps/web/src/i18n/en/tour.ts @@ -6,7 +6,7 @@ export const tour = { "tour.menuBarBodyPhone": "Every command lives behind this menu button — File, Edit, Insert, View, Format, Tools and Help.", "tour.menuBarBody": "Every command lives here — File, Edit, Insert, View, Format, Tools and Help. When in doubt, start in a menu.", "tour.shareTitle": "Share a note", - "tour.shareBody": "Copy a link to the open note and send it to anyone. Opening the link adds a copy to their library. A whole folder shares from its ⋮ menu.", + "tour.shareBody": "Send the open note to anyone: choose how it opens and whether it's an editable copy, read-only, or live — a live link keeps their copy up to date as you edit. A whole folder shares from its ⋮ menu.", "tour.shareBodySignIn": "Copy a link to the open note and send it to anyone. Sharing needs an account, so sign in first; until then the button stays greyed out.", "tour.signInTitle": "Sign in when you want", "tour.signInBody": "You can write without an account; your notes are saved in this browser. Sign in with Google to sync them across your devices, keep a daily backup and share links.", diff --git a/apps/web/src/store/documents/document.ts b/apps/web/src/store/documents/document.ts index fa6c656..f986c89 100644 --- a/apps/web/src/store/documents/document.ts +++ b/apps/web/src/store/documents/document.ts @@ -12,6 +12,7 @@ import { canonicalJsonText } from "./canonical-json"; import { sanitizeCanonicalNode } from "./canonical-sanitize"; import type { ResidualItem } from "../importExport/ingest"; import { CURRENT_SCHEMA_VERSION } from "./schema-version"; +import { isReadOnlyShare, liveShareOf } from "./live-share-meta"; // --- Types --- @@ -90,6 +91,10 @@ export interface DocumentSummary { tags: string[]; hasResiduals: boolean; originFormat: string; + /** A copy following a live share (live-share-meta.ts) — present only then. */ + liveShare?: { id: string; unseen: boolean; ended: boolean }; + /** Opened from a read-only share link — present only then. */ + readOnlyShare?: true; } let _counter = 0; @@ -519,7 +524,7 @@ export function deserializeFromStorage( */ export function toSummary(doc: DocumentRecord): DocumentSummary { const counts = computeCounts(doc.canonical); - return { + const summary: DocumentSummary = { id: doc.id, title: doc.title, updated: doc.updated, @@ -529,6 +534,11 @@ export function toSummary(doc: DocumentRecord): DocumentSummary { hasResiduals: doc.residuals.length > 0, originFormat: doc.origin?.format ?? "unknown", }; + // Only shared documents carry these, so every other summary is unchanged. + const live = liveShareOf(doc.metadata); + if (live) summary.liveShare = { id: live.id, unseen: !!live.unseen, ended: !!live.ended }; + if (isReadOnlyShare(doc.metadata)) summary.readOnlyShare = true; + return summary; } /** diff --git a/apps/web/src/store/documents/folders.ts b/apps/web/src/store/documents/folders.ts index 569a96f..edf0f72 100644 --- a/apps/web/src/store/documents/folders.ts +++ b/apps/web/src/store/documents/folders.ts @@ -71,16 +71,41 @@ export interface BuiltTree { rootDocs: DocumentSummary[]; } +/** + * How the library orders each sibling group (a per-browser preference, + * `pf:library-sort`): + * - `updated` (the default): dragged items keep their place; the rest are + * folders A–Z, then documents newest-edited first. + * - `name`: folders and documents A–Z. + * - `created`: folders and documents newest first. + * - `manual`: nothing is sorted — dragged items keep their place and the rest + * stay in the order they were made, so a new item is added at the end. + * `name` and `created` ignore drag order (it is kept, not discarded). + */ +export type LibrarySort = "updated" | "name" | "created" | "manual"; +export const LIBRARY_SORTS: readonly LibrarySort[] = ["updated", "name", "created", "manual"]; + +const nameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +/** When a folder was made: the timestamp in its id (`generateFolderId`), or + * NaN for an id from elsewhere — the caller falls back to list order. */ +function folderCreatedAt(id: string): number { + const m = /^fld-([0-9a-z]+)-/.exec(id); + return m ? parseInt(m[1]!, 36) : NaN; +} + /** * Build a nested tree from flat documents + folders + docParents. Documents - * whose parent folder is missing fall back to the root. Folders are sorted by - * name; documents by most-recently-updated. + * whose parent folder is missing fall back to the root. Each sibling group is + * ordered by `sort` (see {@link LibrarySort}); the default keeps the original + * order — folders by name, documents by most-recently-updated, drag order first. */ export function buildFolderTree( documents: DocumentSummary[], folders: FolderRecord[], docParents: Record, - docOrder: Record = {} + docOrder: Record = {}, + sort: LibrarySort = "updated" ): BuiltTree { const folderById = new Map(folders.map((f) => [f.id, f])); const childFolders = new Map(); @@ -94,31 +119,59 @@ export function buildFolderTree( const key = parent && folderById.has(parent) ? parent : null; (docsByFolder.get(key) ?? docsByFolder.set(key, []).get(key)!).push(d); } - // Folders: manual `order` first (ascending), then un-ordered by name. - const byOrderName = (a: FolderRecord, b: FolderRecord) => { + // Creation order of folders: the time in the id, else the position in the + // list (createFolder appends, so the list is creation order too). + const listIndex = new Map(folders.map((f, i) => [f.id, i])); + const folderAge = (a: FolderRecord, b: FolderRecord) => { + const at = folderCreatedAt(a.id); + const bt = folderCreatedAt(b.id); + if (!Number.isNaN(at) && !Number.isNaN(bt) && at !== bt) return at - bt; + return listIndex.get(a.id)! - listIndex.get(b.id)!; + }; + const byFolderName = (a: FolderRecord, b: FolderRecord) => nameCollator.compare(a.name, b.name) || folderAge(a, b); + // Folders: manual `order` first (ascending), then un-ordered by name — or, in + // `manual`, in the order they were made. + const byOrderThen = (rest: (a: FolderRecord, b: FolderRecord) => number) => (a: FolderRecord, b: FolderRecord) => { const ao = a.order ?? Number.MAX_SAFE_INTEGER; const bo = b.order ?? Number.MAX_SAFE_INTEGER; - return ao !== bo ? ao - bo : a.name.localeCompare(b.name); + return ao !== bo ? ao - bo : rest(a, b); }; + const folderCompare = + sort === "name" ? byFolderName + : sort === "created" ? (a: FolderRecord, b: FolderRecord) => folderAge(b, a) + : sort === "manual" ? byOrderThen(folderAge) + : byOrderThen((a, b) => a.name.localeCompare(b.name)); + + const time = (iso: string) => new Date(iso).getTime() || 0; const byUpdated = (a: DocumentSummary, b: DocumentSummary) => - new Date(b.updated).getTime() - new Date(a.updated).getTime(); + time(b.updated) - time(a.updated); + const byCreatedNewest = (a: DocumentSummary, b: DocumentSummary) => + time(b.created) - time(a.created) || byUpdated(a, b); + const byCreatedOldest = (a: DocumentSummary, b: DocumentSummary) => + time(a.created) - time(b.created) || a.id.localeCompare(b.id); + const byTitle = (a: DocumentSummary, b: DocumentSummary) => + nameCollator.compare(a.title, b.title) || byUpdated(a, b); // Docs: those listed in docOrder[key] first (in that order), then the rest by // most-recently-updated — so a manually reordered folder is stable while newly - // created docs still surface near the top. + // created docs still surface near the top. `manual` puts the rest in the + // order they were made instead, so nothing moves when a document is edited. const orderDocs = (folderId: string | null, docs: DocumentSummary[]): DocumentSummary[] => { + if (sort === "name") return docs.slice().sort(byTitle); + if (sort === "created") return docs.slice().sort(byCreatedNewest); + const rest = sort === "manual" ? byCreatedOldest : byUpdated; const explicit = docOrder[docOrderKey(folderId)]; - if (!explicit || explicit.length === 0) return docs.slice().sort(byUpdated); + if (!explicit || explicit.length === 0) return docs.slice().sort(rest); const pos = new Map(explicit.map((id, i) => [id, i])); const listed = docs.filter((d) => pos.has(d.id)).sort((a, b) => pos.get(a.id)! - pos.get(b.id)!); - const rest = docs.filter((d) => !pos.has(d.id)).sort(byUpdated); - return [...listed, ...rest]; + const unlisted = docs.filter((d) => !pos.has(d.id)).sort(rest); + return [...listed, ...unlisted]; }; const build = (parentId: string | null): FolderTreeNode[] => (childFolders.get(parentId) ?? []) .slice() - .sort(byOrderName) + .sort(folderCompare) .map((folder) => ({ folder, children: build(folder.id), diff --git a/apps/web/src/store/documents/live-share-meta.ts b/apps/web/src/store/documents/live-share-meta.ts new file mode 100644 index 0000000..3d80eed --- /dev/null +++ b/apps/web/src/store/documents/live-share-meta.ts @@ -0,0 +1,51 @@ +/** + * Shared documents that are read-only here — kept in the document's metadata + * so they travel with it (cloud sync, backups): + * + * - `readOnlyShare: true` — opened from a share link its sender made + * read-only. It opens read-only until the user chooses to edit it. + * - `liveShare` — a copy FOLLOWING a live share (app/publish): its owner's + * edits replace it when they publish, so it is read-only while followed. + * `ended` once the owner stopped sharing it: then it is the user's own copy. + * + * Read-only is the editor's session flag while such a document is open + * (editor/useSharedDocLock.ts) — the stored document is never locked against + * the user, who can always make it their own (ui/notices/sharedDocNotice). + */ +export const LIVE_SHARE_KEY = "liveShare"; +export const READ_ONLY_SHARE_KEY = "readOnlyShare"; + +export interface LiveShareInfo { + /** The live share's public id — the `#live=` of its link. */ + id: string; + /** The owner's revision this copy holds. */ + revision: string; + /** Updated since it was last opened here (the library marks it). */ + unseen?: boolean; + /** The owner stopped sharing it: no more updates, and it is editable. */ + ended?: boolean; + /** When the copy here last changed to a new revision (ISO). */ + at?: string; +} + +type Metadata = Record | undefined | null; + +export function liveShareOf(metadata: Metadata): LiveShareInfo | null { + const v = metadata?.[LIVE_SHARE_KEY]; + if (!v || typeof v !== "object") return null; + const l = v as Partial; + if (typeof l.id !== "string" || typeof l.revision !== "string") return null; + return { id: l.id, revision: l.revision, unseen: l.unseen === true, ended: l.ended === true, at: typeof l.at === "string" ? l.at : undefined }; +} + +export const isReadOnlyShare = (metadata: Metadata): boolean => metadata?.[READ_ONLY_SHARE_KEY] === true; + +/** Opens read-only: a read-only share, or a live copy still being followed. */ +export function isLockedShare(metadata: Metadata): boolean { + if (isReadOnlyShare(metadata)) return true; + const live = liveShareOf(metadata); + return !!live && !live.ended; +} + +/** The metadata patch that makes a shared document the user's own (editable, no longer followed). */ +export const ownCopyPatch = (): Record => ({ [LIVE_SHARE_KEY]: null, [READ_ONLY_SHARE_KEY]: null }); diff --git a/apps/web/src/store/documents/live-share.ts b/apps/web/src/store/documents/live-share.ts new file mode 100644 index 0000000..e0f5b0d --- /dev/null +++ b/apps/web/src/store/documents/live-share.ts @@ -0,0 +1,49 @@ +/** + * The copy of a document that a live share publishes (app/publish), built + * here so the share dialog's first publish and the sync engine's updates make + * exactly the same thing. The flags a shared document carries are in + * live-share-meta.ts. + */ +import { collectAssetRefs } from "../assets/asset-refs"; +import { encodeEnvelopeAssets, type AssetPayload } from "../assets/assets"; +import { toExportEnvelope } from "../importExport/import-export"; +import { resolveDocTitle, type DocumentRecord } from "./document"; + +export * from "./live-share-meta"; + +/** What a live share holds: the document as the editor exports it, its images inline. */ +export interface LiveDocument { + /** The owner's revision (`updated`) it was made from. */ + revision: string; + title: string; + /** A `perssonify-document` envelope — without the owner's id, metadata or import history. */ + document: Record; +} + +/** + * The copy of a document a live share publishes. What is the owner's own and + * not the reader's business stays out: the document id (each follower makes + * its own), its metadata (card colour, field values, this module's flags), + * the import report and residuals. Images go inside, as in a JSON export. + */ +export async function buildLiveDocument( + rec: DocumentRecord, + getAsset: (hash: string) => Promise, +): Promise { + const title = resolveDocTitle(rec, rec.canonical).title || ""; + const assets = new Map(); + for (const hash of collectAssetRefs(rec.canonical)) { + try { + const a = await getAsset(hash); + if (a) assets.set(hash, a); + } catch { + /* an image this browser cannot read stays missing for readers too */ + } + } + const { id: _id, ingestReport: _report, residuals: _residuals, origin: _origin, ...envelope } = toExportEnvelope( + { ...rec, metadata: {}, residuals: [] } as DocumentRecord, + title, + encodeEnvelopeAssets(assets), + ); + return { revision: rec.updated, title, document: envelope }; +} diff --git a/apps/web/src/store/history/snapshot-store.ts b/apps/web/src/store/history/snapshot-store.ts index fc12910..23a2dc1 100644 --- a/apps/web/src/store/history/snapshot-store.ts +++ b/apps/web/src/store/history/snapshot-store.ts @@ -43,6 +43,7 @@ export type SnapshotReason = | "pre-link-update" // before links to a renamed heading were retargeted (heading-renames.ts) | "tab-conflict" // the version set aside when two tabs changed a document (useTabSync.ts) | "cloud-sync" // the version set aside when the account had a newer one, or lost to it (app/cloud) + | "live-update" // a followed live share's copy, replaced by the owner's newer version (app/publish) | "account-version" // a past version brought back from the account (Version history ▸ In your account) | "manual"; // user asked to snapshot now diff --git a/apps/web/src/store/importExport/share-link.ts b/apps/web/src/store/importExport/share-link.ts index e403372..c75f891 100644 --- a/apps/web/src/store/importExport/share-link.ts +++ b/apps/web/src/store/importExport/share-link.ts @@ -28,8 +28,14 @@ export interface ShareDoc { markdown: string; /** Ingest format of `markdown`. Defaults to "markdown" when absent. */ format?: IngestFormat; + /** Merged into the imported document's metadata. Never read from a link + * (decodeSharePayload drops it): only the app sets it, for a live share. */ + metadata?: Record; } +/** The layout a share opens in: the app's MD / HTML (rich) / Split modes. */ +export type ShareView = "md" | "html" | "split"; + export interface SharePayload { /** Envelope version, so an older client can refuse a newer shape cleanly. */ v: 1; @@ -38,8 +44,16 @@ export interface SharePayload { /** A human label for the share (doc title or folder name) — used in notices. */ name: string; docs: ShareDoc[]; + /** The layout the recipient opens it in (this visit only). Absent: their own. */ + view?: ShareView; + /** The recipient's copies open read-only (store/documents/live-share-meta.ts), + * until they make them editable. Older builds ignore both — the link still + * opens, as an editable copy in the recipient's own layout. */ + readOnly?: boolean; } +export const isShareView = (v: unknown): v is ShareView => v === "md" || v === "html" || v === "split"; + /** The fragment that marks a share link: `…/#share=`. */ export const SHARE_HASH_KEY = "share"; @@ -94,6 +108,8 @@ export function decodeSharePayload(encoded: string): SharePayload | null { kind: p.kind === "folder" ? "folder" : "file", name: typeof p.name === "string" ? p.name : "", docs, + ...(isShareView(p.view) ? { view: p.view } : {}), + ...(p.readOnly === true ? { readOnly: true } : {}), }; } catch { return null; diff --git a/apps/web/src/store/preferences/uiStore.ts b/apps/web/src/store/preferences/uiStore.ts index 59f4de1..ad6a933 100644 --- a/apps/web/src/store/preferences/uiStore.ts +++ b/apps/web/src/store/preferences/uiStore.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { safeStorage } from "@/utils/safe-storage"; import { adminSettingsAvailable } from "@/utils/build-gates"; import { MODES } from "@/constants"; +import { LIBRARY_SORTS, type LibrarySort } from "@/store/documents/folders"; import { DEFAULT_MARKDOWN_FEATURES, type MarkdownFeatureSettings, @@ -62,6 +63,10 @@ interface UIState { // src/ui/workspace/workspacePanel/index.tsx. workspaceTab: "files" | "outline"; + // How the library orders folders and documents (persisted `pf:library-sort`, + // per browser). See LibrarySort in src/store/documents/folders.ts. + librarySort: LibrarySort; + // Actions setMode: (mode: string) => void; setShowFindReplace: (value: boolean | ((prev: boolean) => boolean)) => void; @@ -85,6 +90,7 @@ interface UIState { setFocusMode: (value: boolean) => void; setDocNavWidth: (value: number) => void; setWorkspaceTab: (value: "files" | "outline") => void; + setLibrarySort: (value: LibrarySort) => void; /** * Seed the initial syncMode value from outside (replaces the old * localStorage.getItem call). Called by the editor during mount when @@ -181,11 +187,15 @@ export const useUIStore = create((set) => ({ safeStorage.get("pf:show-ads") === "true", readOnly: safeStorage.get("pf:read-only") === "true", - // Admin-only switches: when the Admin section is hidden (production without - // the `pf:admin` opt-in) a value stored before it was hidden must not stick — - // the user would have no control left to change it back. Defaults apply. + // An everyday switch (Settings ▸ Editor): with it off, a right-click opens + // the browser's own menu and its spelling suggestions. It used to be + // admin-only, and while hidden it read its default; it is honoured in every + // build now that every user has the control. contextMenuEnabled: - !adminSettingsAvailable() || safeStorage.get("pf:context-menu") !== "false", + safeStorage.get("pf:context-menu") !== "false", + // Admin-only: when the Admin section is hidden (production without the + // `pf:admin` opt-in) a value stored before it was hidden must not stick — + // the user would have no control left to change it back. Defaults apply. consoleEnabled: adminSettingsAvailable() && safeStorage.get("pf:console") === "true", keyboardShortcutsEnabled: @@ -211,6 +221,9 @@ export const useUIStore = create((set) => ({ (((v) => (v === "outline" ? "outline" : v === "files" ? "files" : v === "map" ? "outline" : null))( safeStorage.get("pf:workspace-tab") )) || "files", + librarySort: ((v) => (LIBRARY_SORTS as readonly string[]).includes(v ?? "") ? (v as LibrarySort) : "updated")( + safeStorage.get("pf:library-sort") + ), setInitialSyncMode: (mode) => set({ syncMode: mode }), setMarkdownFeatures: (value) => @@ -295,4 +308,8 @@ export const useUIStore = create((set) => ({ safeStorage.set("pf:workspace-tab", value); set({ workspaceTab: value }); }, + setLibrarySort: (value) => { + safeStorage.set("pf:library-sort", value); + set({ librarySort: value }); + }, })); diff --git a/apps/web/src/ui/chrome/editorContextMenu/index.tsx b/apps/web/src/ui/chrome/editorContextMenu/index.tsx index 1828028..f05e2f8 100644 --- a/apps/web/src/ui/chrome/editorContextMenu/index.tsx +++ b/apps/web/src/ui/chrome/editorContextMenu/index.tsx @@ -12,6 +12,7 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { selectMenuBody, type MenuPrimitives } from "../selectMenuContent"; +import { passShiftContextMenuToBrowser } from "../../shared/utils/native-context-menu"; import { useUIStore } from "@/store/preferences/uiStore"; import { Bold, CaseSensitive, Clipboard, Code, Copy, Eraser, Hash, Heading1, Heading2, Heading3, Highlighter, @@ -91,13 +92,15 @@ export function EditorContextMenu({ api, children }: { api: EditorContextMenuApi const t = useT(); const d = api.dispatch; const enabled = useUIStore((s) => s.contextMenuEnabled); - // Admin toggle: when disabled, fall back to the browser's native menu — render - // the same pane container without the custom ContextMenu wrapper. + // Settings ▸ Editor ▸ Custom right-click menu: when off, fall back to the + // browser's native menu (and its spelling suggestions) — render the same pane + // container without the custom ContextMenu wrapper. if (!enabled) return
{children}
; return ( } + onContextMenuCapture={passShiftContextMenuToBrowser} > {children} @@ -179,6 +182,15 @@ export function EditorContextMenu({ api, children }: { api: EditorContextMenuApi d("horizontalRule")}> {t("context.horizontalRule")} + + + {/* 7 — How to reach the browser's own menu: its spelling suggestions + are not something this menu can offer. */} + + + {t("context.browserMenuHint")} + + ); diff --git a/apps/web/src/ui/chrome/keyboardShortcutsDialog/index.tsx b/apps/web/src/ui/chrome/keyboardShortcutsDialog/index.tsx index 7772a0e..240fd3b 100644 --- a/apps/web/src/ui/chrome/keyboardShortcutsDialog/index.tsx +++ b/apps/web/src/ui/chrome/keyboardShortcutsDialog/index.tsx @@ -1,12 +1,14 @@ +import { Button } from "@/components/ui/button"; import { - AlertDialog, - AlertDialogAction, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { displayKeys, SHORTCUT_GROUPS } from "@/data/shortcuts"; import { useT } from "@/i18n"; @@ -25,6 +27,16 @@ const GROUPS = (): { title: string; note?: string; items: [string, string][] }[] })); +/** + * A plain dialog (components/ui/dialog.tsx), not an alert dialog. It was an + * AlertDialog: that primitive's size variant outranks a `max-w-*` passed in (a + * data-attribute selector beats a bare class), so five groups of shortcuts + * were squeezed into a 24rem column that ran off the top and bottom of the + * screen, taking the only Close button with it — and an alert dialog cannot + * be dismissed by a click outside, while the page behind it is scroll-locked. + * Now the list scrolls inside a box capped to the viewport, Close and × stay + * on screen, and a click outside closes it. + */ export function KeyboardShortcutsDialog({ open, onClose, @@ -34,41 +46,41 @@ export function KeyboardShortcutsDialog({ }) { const t = useT(); return ( - { if (!o) onClose(); }}> - - - {t("dialogs.keyboardShortcuts")} - - {t("dialogs.everydayCommandsShownForThis")} - - -
- {GROUPS().map((g) => ( -
-
- {g.title} + { if (!o) onClose(); }}> + + + {t("dialogs.keyboardShortcuts")} + {t("dialogs.everydayCommandsShownForThis")} + + +
+ {GROUPS().map((g) => ( +
+
+ {g.title} +
+ {/* The group's scope caveat, from the same single source. */} + {g.note &&

{g.note}

} +
+ {g.items.map(([keys, label]) => ( +
+
{label}
+
+ + {keys} + +
+
+ ))} +
- {/* The group's scope caveat, from the same single source. */} - {g.note &&

{g.note}

} -
- {g.items.map(([keys, label]) => ( -
-
{label}
-
- - {keys} - -
-
- ))} -
-
- ))} -
- - {t("common.close")} - - - + ))} +
+ + + }>{t("common.close")} + + + ); } diff --git a/apps/web/src/ui/chrome/statusBar/index.tsx b/apps/web/src/ui/chrome/statusBar/index.tsx index 6793040..7bd3026 100644 --- a/apps/web/src/ui/chrome/statusBar/index.tsx +++ b/apps/web/src/ui/chrome/statusBar/index.tsx @@ -21,6 +21,7 @@ import { formatEstBytes, historyCache } from "@/store/history/history-cache"; import { useTabSyncStore } from "@/store/persistence/tab-sync"; import { useUIStore } from "@/store/preferences/uiStore"; import { KeyboardShortcutsDialog } from "../keyboardShortcutsDialog"; +import { VersionBadge } from "../versionBadge"; import { ResidualsDialog } from "@/ui/importExport/residualsDialog"; import { INITIAL_ANNOUNCER, nextAnnouncement } from "./save-announcer"; import { t, useT } from "@/i18n"; @@ -368,6 +369,10 @@ function RawStatusBar({ onOpenMemory, onToggleLinks, linksActive, onToggleTray, )}
+ + {/* The version (and, on a preview build, its branch) — last, and only + where there is room: it is reference, not status. */} +

setShowShortcuts(false)} /> `v${VERSION}`; + +/** A build from a branch other than the release branch (a preview): its branch. */ +export const previewBranch = (): string | null => + BUILD_INFO.branch && BUILD_INFO.branch !== RELEASE_BRANCH ? BUILD_INFO.branch : null; + +/** The whole of it, for a tooltip: version, then branch and commit when known. */ +export function buildDescription(): string { + const { branch, commit } = BUILD_INFO; + if (branch && commit) return t("status.versionBuilt", { app: APP_NAME, version: VERSION, branch, commit }); + if (commit) return t("status.versionCommit", { app: APP_NAME, version: VERSION, commit }); + return t("status.versionOnly", { app: APP_NAME, version: VERSION }); +} + +/** + * The app's version, in the header and the status bar: `v1.78.0` (SemVer, + * from package.json), and — on a build from any branch but the release + * branch — that branch beside it, so a preview is never mistaken for + * production. The commit is in the tooltip. + */ +export function VersionBadge({ className, showBranch = true }: { className?: string; showBranch?: boolean }) { + const branch = showBranch ? previewBranch() : null; + const description = buildDescription(); + return ( + + {versionText()} + {branch && ( + + {branch} + + )} + + ); +} diff --git a/apps/web/src/ui/history/versionHistorySheet/index.tsx b/apps/web/src/ui/history/versionHistorySheet/index.tsx index 65c1983..cfaedf1 100644 --- a/apps/web/src/ui/history/versionHistorySheet/index.tsx +++ b/apps/web/src/ui/history/versionHistorySheet/index.tsx @@ -44,6 +44,7 @@ const REASON_LABEL: Record = { get "pre-link-update"() { return t("dialogs.reasonPreLinkUpdate"); }, get "tab-conflict"() { return t("dialogs.reasonTabConflict"); }, get "cloud-sync"() { return t("dialogs.reasonCloudSync"); }, + get "live-update"() { return t("dialogs.reasonLiveUpdate"); }, get "account-version"() { return t("dialogs.reasonAccountVersion"); }, get manual() { return t("dialogs.reasonManual"); }, }; diff --git a/apps/web/src/ui/notices/durabilityNotices/index.tsx b/apps/web/src/ui/notices/durabilityNotices/index.tsx index de3860c..df1d79a 100644 --- a/apps/web/src/ui/notices/durabilityNotices/index.tsx +++ b/apps/web/src/ui/notices/durabilityNotices/index.tsx @@ -3,6 +3,7 @@ import { cn } from "@/lib/utils"; import { AlertTriangle, ArchiveRestore, DownloadCloud, ExternalLink, History, Smartphone } from "lucide-react"; import React, { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { NoticeDismiss as Dismiss, NoticeStrip as Strip, noticeButton as btn } from "../noticeStrip"; +import { SharedDocNotice } from "../sharedDocNotice"; import { TabSyncNotice } from "../tabSyncNotice"; import { APP_DOMAIN, APP_NAME, APP_URL } from "@/constants"; import { useDocumentStore } from "@/store/documents/documentStore"; @@ -51,13 +52,15 @@ export interface DurabilityNoticesProps { /** * The strip area above the status bar: another tab changing or deleting the - * open document (TabSyncNotice — it needs a choice, so it comes first), then - * at most one storage-durability notice. + * open document (TabSyncNotice — it needs a choice, so it comes first), why a + * document shared with the user is read-only (SharedDocNotice), then at most + * one storage-durability notice. */ function DurabilityNoticesInner(props: DurabilityNoticesProps) { return ( <> + ); diff --git a/apps/web/src/ui/notices/noticeStrip/index.tsx b/apps/web/src/ui/notices/noticeStrip/index.tsx index 7650a7d..6b630ce 100644 --- a/apps/web/src/ui/notices/noticeStrip/index.tsx +++ b/apps/web/src/ui/notices/noticeStrip/index.tsx @@ -37,7 +37,7 @@ export function NoticeStrip({ /** The notice's e2e hook — data, so it stays English whatever the interface language. */ hook: string; /** Which data attribute carries `hook`. */ - hookAttr?: "data-durability-notice" | "data-tab-sync-notice"; + hookAttr?: "data-durability-notice" | "data-tab-sync-notice" | "data-shared-doc-notice"; children: ReactNode; actions?: ReactNode; }) { diff --git a/apps/web/src/ui/notices/sharedDocNotice/index.tsx b/apps/web/src/ui/notices/sharedDocNotice/index.tsx new file mode 100644 index 0000000..83b6b66 --- /dev/null +++ b/apps/web/src/ui/notices/sharedDocNotice/index.tsx @@ -0,0 +1,105 @@ +import { Button } from "@/components/ui/button"; +import { Lock, Pencil, Radio, RadioTower } from "lucide-react"; +import { useState } from "react"; +import { useDocumentStore } from "@/store/documents/documentStore"; +import { isReadOnlyShare, liveShareOf, ownCopyPatch } from "@/store/documents/live-share-meta"; +import { timeAgo } from "@/ui/workspace/utils/relative-time"; +import { useT } from "@/i18n"; +import { NoticeStrip, noticeButton } from "../noticeStrip"; + +/** + * The open document was shared with the user and opens read-only + * (store/documents/live-share-meta.ts, editor/useSharedDocLock.ts): say why, and + * offer the way to make it their own. A live copy says it is followed — its + * owner's edits replace it — or that its owner stopped sharing it. + */ +export function SharedDocNotice() { + const t = useT(); + const doc = useDocumentStore((s) => (s.activeDoc && s.hydratedDocId === s.activeDoc.id ? s.activeDoc : null)); + const [busy, setBusy] = useState(false); + // "Stop following" asks first, in the strip itself (a two-step button): it + // cannot be undone from here, and the strip lives outside any dialog host. + const [confirming, setConfirming] = useState(null); + if (!doc) return null; + const live = liveShareOf(doc.metadata); + const readOnlyShare = isReadOnlyShare(doc.metadata); + if (!live && !readOnlyShare) return null; + + const makeOwn = async () => { + setBusy(true); + try { + // A real revision (not a metadata-only patch): it must reach the + // account, or another device still following the note would push the + // owner's next update back over this choice. + await useDocumentStore.getState().patchDocById(doc.id, { metadata: ownCopyPatch() }); + } finally { + setBusy(false); + setConfirming(null); + } + }; + + if (live && !live.ended) { + const asking = confirming === doc.id; + return ( + + + + + ) : ( + + ) + } + > + {asking ? ( + <> + {t("notices.liveStopConfirmTitle")}{" "} + {t("notices.liveStopConfirmBody")} + + ) : ( + <> + {t("notices.liveHeadline")}{" "} + + {live.at ? `${t("notices.liveUpdatedAt", { when: timeAgo(live.at) })} ` : ""} + {t("notices.liveBody")} + + + )} + + ); + } + + const ended = !!live?.ended; + return ( + void makeOwn()}> + {ended ? t("notices.liveEndedDismiss") : <> {t("notices.makeEditable")}} + + } + > + {ended ? t("notices.liveEndedHeadline") : t("notices.readOnlyShareHeadline")}{" "} + {ended ? t("notices.liveEndedBody") : t("notices.readOnlyShareBody")} + + ); +} diff --git a/apps/web/src/ui/pages/aboutPage/index.tsx b/apps/web/src/ui/pages/aboutPage/index.tsx index f575956..90b6517 100644 --- a/apps/web/src/ui/pages/aboutPage/index.tsx +++ b/apps/web/src/ui/pages/aboutPage/index.tsx @@ -1,5 +1,5 @@ import { FileText, Heart, Shield, WifiOff } from "lucide-react"; -import { APP_BRAND, APP_DOMAIN, APP_NAME, APP_URL, PERSSONIFY_URL, VERSION, VIRALLENS_URL } from "@/constants"; +import { APP_BRAND, APP_DOMAIN, APP_NAME, APP_URL, BUILD_INFO, PERSSONIFY_URL, VERSION, VIRALLENS_URL } from "@/constants"; import { CREDITS, LICENSE_SUMMARY, THIRD_PARTY_NOTICES_PATH } from "@/data/credits"; import { PageChrome } from "../pageChrome"; import { CopyDiagnosticsButton } from "@/ui/diagnostics/diagnosticsActions"; @@ -48,6 +48,12 @@ export function AboutPage({ onClose }: AboutPageProps) {

{t.rich("pages.aboutVersion", { version: VERSION, b: (s) => {s}, brand: APP_BRAND })}

+ {/* Which build this is: the branch and commit it was made from. */} + {BUILD_INFO.commit && ( +

+ {BUILD_INFO.branch ? `${BUILD_INFO.branch} @ ${BUILD_INFO.commit}` : BUILD_INFO.commit} +

+ )}

{t.rich("pages.madeWith", { heart: , diff --git a/apps/web/src/ui/settings/settingsSheet/index.tsx b/apps/web/src/ui/settings/settingsSheet/index.tsx index 0b3f664..c62c9e2 100644 --- a/apps/web/src/ui/settings/settingsSheet/index.tsx +++ b/apps/web/src/ui/settings/settingsSheet/index.tsx @@ -250,6 +250,13 @@ function SettingsSheetInner({

+
+
+ +

{t("settings.customRightClickMenuHint")}

+
+ +
@@ -375,13 +382,6 @@ function SettingsSheetInner({

{t("settings.admin")}

-
-
- -

{t("settings.customEditorContextMenuOff")}

-
- -
diff --git a/apps/web/src/ui/share/shareDialog/index.tsx b/apps/web/src/ui/share/shareDialog/index.tsx new file mode 100644 index 0000000..9bd5626 --- /dev/null +++ b/apps/web/src/ui/share/shareDialog/index.tsx @@ -0,0 +1,215 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import type { LiveShares } from "@/editor/props"; +import type { ShareLinkOptions } from "@/editor/useExportActions"; +import type { Toast } from "@/editor/types"; +import type { ShareView } from "@/store/importExport/share-link"; +import { useT } from "@/i18n"; +import { Copy, Lock, Pencil, RadioTower } from "lucide-react"; +import { type ElementType, useEffect, useState } from "react"; + +export interface ShareTarget { + kind: "doc" | "folder"; + id: string; + name: string; +} + +type Access = "copy" | "readOnly" | "live"; + +/** + * Share a document or a folder: choose the layout it opens in (Split, the + * default; Markdown; HTML) and what the recipient gets — + * + * - an editable copy (the content travels inside the link: no server), + * - a read-only copy (the same, opening read-only until they make it theirs), + * - a LIVE read-only copy (a document only, when the host offers it): the + * link names a copy kept on the account's server, people who open it follow + * it, and the owner's edits reach them (app/publish). + * + * The link is copied when the user asks; nothing is published before that. + */ +export function ShareDialog({ + target, + onClose, + onShareDoc, + onShareFolder, + liveShares, + toast, +}: { + target: ShareTarget | null; + onClose: () => void; + onShareDoc: (docId: string, opts: ShareLinkOptions) => Promise; + onShareFolder: (folderId: string, opts: ShareLinkOptions) => Promise; + liveShares?: LiveShares; + toast: Toast; +}) { + const t = useT(); + const [view, setView] = useState("split"); + const [access, setAccess] = useState("copy"); + const [busy, setBusy] = useState(false); + const [liveLink, setLiveLink] = useState(null); + const liveAvailable = !!liveShares && target?.kind === "doc"; + + // Each opening starts from the defaults — unless the document is already + // shared live, which is then what the dialog shows. + useEffect(() => { + if (!target) return; + const link = target.kind === "doc" ? liveShares?.linkFor(target.id) ?? null : null; + setLiveLink(link); + setView("split"); + setAccess(link ? "live" : "copy"); + setBusy(false); + }, [target, liveShares]); + + const shownLink = liveLink && target?.kind === "doc" ? liveShares?.linkFor(target.id, view) ?? liveLink : liveLink; + // The live copy has fallen behind the note (it grew too large to publish). + const problem = liveLink && target?.kind === "doc" ? liveShares?.problemFor?.(target.id) ?? null : null; + + const copy = async () => { + if (!target || busy) return; + setBusy(true); + try { + if (access === "live" && liveShares && target.kind === "doc") { + let link: string; + try { + link = await liveShares.publish(target.id, view); + } catch (e) { + toast.show(e instanceof Error && e.message ? e.message : t("dialogs.shareLiveFailed"), "error"); + return; + } + setLiveLink(link); + try { + await navigator.clipboard.writeText(link); + toast.show(t("dialogs.shareLiveLinkCopied"), "success"); + onClose(); + } catch { + toast.show(t("editor.shareLinkCopyFailed"), "error"); // the link stays on screen to copy by hand + } + return; + } + const opts: ShareLinkOptions = { view, readOnly: access === "readOnly" }; + const copied = target.kind === "doc" ? await onShareDoc(target.id, opts) : await onShareFolder(target.id, opts); + if (copied) onClose(); + } finally { + setBusy(false); + } + }; + + const stopLive = async () => { + if (!target || !liveShares || busy) return; + setBusy(true); + try { + await liveShares.unpublish(target.id); + setLiveLink(null); + setAccess("copy"); + toast.show(t("dialogs.shareLiveStopped"), "success"); + } catch { + toast.show(t("dialogs.shareLiveFailed"), "error"); + } finally { + setBusy(false); + } + }; + + const views: { v: ShareView; label: string }[] = [ + { v: "split", label: t("dialogs.shareViewSplit") }, + { v: "md", label: t("dialogs.shareViewMarkdown") }, + { v: "html", label: t("dialogs.shareViewHtml") }, + ]; + const options: { a: Access; icon: ElementType; title: string; body: string; disabled?: boolean; note?: string }[] = [ + { a: "copy", icon: Pencil, title: t("dialogs.shareCopyTitle"), body: t("dialogs.shareCopyBody") }, + { a: "readOnly", icon: Lock, title: t("dialogs.shareReadOnlyTitle"), body: t("dialogs.shareReadOnlyBody") }, + ...(liveShares + ? [{ + a: "live" as const, icon: RadioTower, title: t("dialogs.shareLiveTitle"), body: t("dialogs.shareLiveBody"), + disabled: !liveAvailable, note: liveAvailable ? undefined : t("dialogs.shareLiveFolder"), + }] + : []), + ]; + + return ( + { if (!o) onClose(); }}> + + + {t("dialogs.shareTitle", { name: target?.name ?? "" })} + {t("dialogs.shareDescription")} + + +
+
{t("dialogs.shareOpensIn")}
+
+ {views.map((o) => ( + + ))} +
+
+ +
+
{t("dialogs.shareAccess")}
+
+ {options.map(({ a, icon: Icon, title, body, disabled, note }) => ( + + ))} +
+
+ + {access === "live" && shownLink && ( +
+

{t("dialogs.shareLiveOn")}

+ {problem &&

{problem}

} + e.currentTarget.select()} /> + +
+ )} +
+ + }>{t("common.cancel")} + + +
+
+ ); +} diff --git a/apps/web/src/ui/shared/utils/native-context-menu.ts b/apps/web/src/ui/shared/utils/native-context-menu.ts new file mode 100644 index 0000000..7a8f7a1 --- /dev/null +++ b/apps/web/src/ui/shared/utils/native-context-menu.ts @@ -0,0 +1,27 @@ +import type { MouseEvent } from "react"; + +/** + * Shift + right-click opens the BROWSER's menu where the app has its own — the + * only route to the browser's spelling suggestions, which a page can neither + * read nor recreate (Google Docs uses the same gesture). Pass as + * `onContextMenuCapture` on a custom menu's trigger. + * + * Base UI's trigger cancels the native menu twice: its own `onContextMenu`, and + * a `document` listener for any target inside the trigger. Stopping the event + * in the CAPTURE phase keeps it from reaching either (React dispatches capture + * handlers from the root's capture listener, so the native event never gets to + * the target or bubbles), and the default — the browser's menu — is left alone. + * + * Only for the MOUSE: Shift+F10 also arrives as a `contextmenu` with + * `shiftKey` set, and a keyboard user asking for the app's menu must get it. + * Chromium reports a keyboard-opened menu as a PointerEvent with an empty + * `pointerType`; where the event is a plain MouseEvent, a mouse's is button 2. + * (Firefox shows its own menu on Shift + right-click without firing the event + * at all.) + */ +export function passShiftContextMenuToBrowser(event: MouseEvent) { + if (!event.shiftKey) return; + const native = event.nativeEvent as globalThis.MouseEvent & { pointerType?: string }; + const fromKeyboard = typeof native.pointerType === "string" ? native.pointerType === "" : native.button !== 2; + if (!fromKeyboard) event.stopPropagation(); +} diff --git a/apps/web/src/ui/workspace/folderTree/index.tsx b/apps/web/src/ui/workspace/folderTree/index.tsx index 6facc9c..94fa6d0 100644 --- a/apps/web/src/ui/workspace/folderTree/index.tsx +++ b/apps/web/src/ui/workspace/folderTree/index.tsx @@ -68,6 +68,8 @@ import { } from "./folder-dnd"; import { timeAgo } from "../utils/relative-time"; import { useFocusRowAfterRender, useWindowedList } from "../hooks/use-windowed-list"; +import { passShiftContextMenuToBrowser } from "@/ui/shared/utils/native-context-menu"; +import { SharedDocBadge, sharedStateLabel, sharedStateOf, type SharedState } from "../sharedBadge"; import { useT } from "@/i18n"; export type ExportDocFormat = "md" | "html" | "docx" | "json"; @@ -86,6 +88,8 @@ interface FolderTreeProps { onShareDoc?: (docId: string) => void; onShareFolder?: (folderId: string) => void; onNewDocInFolder?: (folderId: string) => void; + /** Create a document at the top level (the right-click menu's New document). */ + onNewDoc?: () => void; /** Confirm before deleting a folder. Resolves true to proceed. When omitted, * deletion proceeds without confirmation (kept optional for tests). */ confirmDeleteFolder?: (name: string, itemCount: number) => Promise; @@ -363,6 +367,46 @@ function ExportDocSubmenu({ ); } +/** + * "New document" + "New folder" in `folderId` (null: the top level) — the + * library's right-click menu offers them wherever it opens: on blank space, on + * a document (beside it, in its folder) and, as New subfolder, on a folder. + */ +function CreateItems({ + F, + folderId, + onNewDoc, + onNewDocInFolder, +}: { + F: MenuFam; + folderId: string | null; + onNewDoc?: () => void; + onNewDocInFolder?: (folderId: string) => void; +}) { + const t = useT(); + const createFolder = useFolderStore((s) => s.createFolder); + const newDoc = folderId ? (onNewDocInFolder && (() => onNewDocInFolder(folderId))) : onNewDoc; + return ( + <> + {newDoc && ( + + + {t("library.newDocument")} + + )} + createFolder("New Folder", folderId, { startNaming: true })}> + + {t("library.newFolder")} + + + ); +} + +/** The right-click menu for the library's blank space: create at the top level. */ +export function LibraryBackgroundMenuItems({ onNewDoc }: { onNewDoc?: () => void }) { + return ; +} + /** Document row menu items, rendered into either primitive family. */ function DocMenuBody({ F, @@ -373,6 +417,8 @@ function DocMenuBody({ onDelete, onExportDoc, onShareDoc, + onNewDoc, + onNewDocInFolder, }: { F: MenuFam; docId: string; @@ -382,6 +428,8 @@ function DocMenuBody({ onDelete: (id: string) => void; onExportDoc?: (docId: string, format: ExportDocFormat) => void; onShareDoc?: (docId: string) => void; + onNewDoc?: () => void; + onNewDocInFolder?: (folderId: string) => void; }) { const t = useT(); const shareGate = useShareGate(); @@ -389,6 +437,8 @@ function DocMenuBody({ const createFolder = useFolderStore((s) => s.createFolder); return ( <> + + {t("common.rename")} @@ -458,6 +508,7 @@ function FolderMenuBody({ {t("library.newSubfolder")} + {t("common.rename")} @@ -687,6 +738,8 @@ interface DocRowProps { editing: boolean; /** Minute counter: re-renders the relative time once a minute. */ tick: number; + /** Shared with the user: live (and whether updated), or read-only. */ + shared?: SharedState; handlers: RowHandlers; } @@ -698,7 +751,7 @@ interface DocRowProps { */ const DocRow = React.memo(function DocRow({ rowKey, id, title, updated, wordCount, folderId, depth, posinset, setsize, - isActive, tabbable, editing, handlers, + isActive, tabbable, editing, shared, handlers, }: DocRowProps) { const t = useT(); const rowRef = useRef(null); @@ -741,7 +794,7 @@ const DocRow = React.memo(function DocRow({ (CLAUDE.md: keep the two library lists in sync). */ aria-selected={isActive} aria-current={isActive ? "true" : undefined} - aria-label={shown} + aria-label={shared ? `${shown}, ${sharedStateLabel(shared)}` : shown} tabIndex={tabbable ? 0 : -1} draggable={!editing} onDragStart={(e) => handlers.dragStart(e, "doc", id)} @@ -787,11 +840,14 @@ const DocRow = React.memo(function DocRow({ onClick={() => handlers.select(id)} className="flex min-w-0 flex-1 flex-col items-start text-left" > - - {shown} + + + {shown} + + {shared && } {/* A095: 10px metadata at the full --muted-foreground; the 75% alpha it used to carry measured 3.0:1. */} @@ -837,11 +893,12 @@ export function FolderTree(props: FolderTreeProps) { const lastCreatedFolderId = useFolderStore((s) => s.lastCreatedFolderId); const consumeLastCreatedFolder = useFolderStore((s) => s.consumeLastCreatedFolder); const contextMenuEnabled = useUIStore((s) => s.contextMenuEnabled); + const librarySort = useUIStore((s) => s.librarySort); const tick = useMinuteTick(); const tree = useMemo( - () => buildFolderTree(props.documents, folders, docParents, docOrder), - [props.documents, folders, docParents, docOrder] + () => buildFolderTree(props.documents, folders, docParents, docOrder, librarySort), + [props.documents, folders, docParents, docOrder, librarySort] ); const rows = useMemo(() => flattenTree(tree, expanded), [tree, expanded]); @@ -925,6 +982,14 @@ export function FolderTree(props: FolderTreeProps) { e.preventDefault(); e.stopPropagation(); const s = store(); + // Sorted by name or by date, a dragged order would not show: arranging + // by hand switches the library to its custom order. The group's order + // is written from what is on screen (its siblings, in display order), + // so the item lands where it was dropped and the rest of the group stays. + const arrangeByHand = () => { + const ui = useUIStore.getState(); + if (ui.librarySort === "name" || ui.librarySort === "created") ui.setLibrarySort("manual"); + }; if (row.kind === "folder") { if (p.kind === "doc") { // Docs always drop INTO the folder (reordering happens among doc rows). @@ -938,6 +1003,7 @@ export function FolderTree(props: FolderTreeProps) { // Reorder before/after this folder within its sibling group. s.moveFolder(p.id, row.folder.parentId); s.setFolderOrder(spliceOrder(row.siblingFolderIds, p.id, row.id, zone === "after" ? "after" : "before")); + arrangeByHand(); } return; } @@ -946,6 +1012,7 @@ export function FolderTree(props: FolderTreeProps) { else if (action.kind === "reorderDoc") { s.moveDoc(p.id, action.folderId); // ensure same folder, then position s.setDocOrder(docOrderKey(action.folderId), action.order); + arrangeByHand(); } }, menuOpened: (key) => setMenuKey(key), @@ -1016,7 +1083,8 @@ export function FolderTree(props: FolderTreeProps) { }; const menuBody = (payload: RowMenuPayload | undefined, F: MenuFam) => { - if (!payload) return null; + // Right-clicked between rows: the blank-space menu. + if (!payload) return ; if (payload.kind === "doc") { return ( ); } @@ -1105,6 +1175,7 @@ export function FolderTree(props: FolderTreeProps) { tabbable={tabbable} editing={editing} tick={tick} + shared={sharedStateOf(row.doc)} handlers={handlers} /> )} @@ -1130,12 +1201,13 @@ export function FolderTree(props: FolderTreeProps) { { if (!open) setCtxKey(null); }}> } + onContextMenuCapture={passShiftContextMenuToBrowser} onContextMenu={recordCtxTarget} onTouchStart={recordCtxTarget} > {rowElements} - {menuBody(ctxPayload, CONTEXT_FAM)} + {menuBody(ctxPayload, CONTEXT_FAM)} ) : (
{rowElements}
@@ -1147,7 +1219,7 @@ export function FolderTree(props: FolderTreeProps) { { if (!open) setMenuKey(null); }}> {({ payload }) => payload ? ( - + {menuBody(payload as RowMenuPayload, DROPDOWN_FAM)} ) : null diff --git a/apps/web/src/ui/workspace/sharedBadge/index.tsx b/apps/web/src/ui/workspace/sharedBadge/index.tsx new file mode 100644 index 0000000..55c416b --- /dev/null +++ b/apps/web/src/ui/workspace/sharedBadge/index.tsx @@ -0,0 +1,43 @@ +import { Lock, Radio, RadioTower } from "lucide-react"; +import type { DocumentSummary } from "@/store/documents/document"; +import { cn } from "@/lib/utils"; +import { t } from "@/i18n"; + +/** How a document shared with the user shows in the library (store/documents/live-share-meta.ts). */ +export type SharedState = "live" | "live-updated" | "live-ended" | "read-only"; + +/** A primitive, so the memoized rows can take it as a prop. */ +export function sharedStateOf(doc: Pick): SharedState | undefined { + if (doc.liveShare) return doc.liveShare.ended ? "live-ended" : doc.liveShare.unseen ? "live-updated" : "live"; + return doc.readOnlyShare ? "read-only" : undefined; +} + +export function sharedStateLabel(state: SharedState): string { + switch (state) { + case "live": return t("library.sharedLive"); + case "live-updated": return t("library.sharedLiveUpdated"); + case "live-ended": return t("library.sharedLiveEnded"); + case "read-only": return t("library.sharedReadOnly"); + } +} + +/** + * The mark beside a shared document's title in both library lists: a live + * copy (with a dot once the owner's update has arrived and until it is + * opened), one whose owner stopped sharing it, or a read-only copy. + */ +export function SharedDocBadge({ state }: { state: SharedState }) { + const label = sharedStateLabel(state); + const Icon = state === "read-only" ? Lock : state === "live-ended" ? Radio : RadioTower; + return ( + + + {state === "live-updated" && ( + + )} + + ); +} diff --git a/apps/web/src/ui/workspace/workspacePanel/index.tsx b/apps/web/src/ui/workspace/workspacePanel/index.tsx index d6c49cd..d02792e 100644 --- a/apps/web/src/ui/workspace/workspacePanel/index.tsx +++ b/apps/web/src/ui/workspace/workspacePanel/index.tsx @@ -3,21 +3,27 @@ import { ButtonGroup } from "@/components/ui/button-group"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@/components/ui/context-menu"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { gatedShareProps, ShareHelpIcon, ShareHint, useShareGate } from "@/editor/share-gate"; import { useDocumentStore } from "@/store/documents/documentStore"; -import { useFolderStore } from "@/store/documents/folders"; +import { LIBRARY_SORTS, type LibrarySort, useFolderStore } from "@/store/documents/folders"; import type { OutlineHeading } from "@/store/documents/doc-outline"; import { + ArrowDownUpIcon, CheckSquareIcon, ClockIcon, CopyIcon, @@ -44,12 +50,14 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import type { DocumentSummary } from "@/store/documents/document"; import { AdUnit } from "@/ui/shared/adUnit"; import { useDialog } from "@/ui/dialogs/dialogProvider"; -import { FolderTree } from "../folderTree"; +import { FolderTree, LibraryBackgroundMenuItems } from "../folderTree"; +import { SharedDocBadge, sharedStateOf } from "../sharedBadge"; import { timeAgo } from "../utils/relative-time"; import { TocList } from "../docNavPanel"; import { DIVIDER_BASE_CLASS, DIVIDER_HIT_AREA, useDividerDrag } from "@/ui/shared/hooks/use-divider-drag"; import { useFocusRowAfterRender, useWindowedList } from "../hooks/use-windowed-list"; -import { DOCNAV_WIDTH_MIN, docNavWidthMax } from "@/store/preferences/uiStore"; +import { DOCNAV_WIDTH_MIN, docNavWidthMax, useUIStore } from "@/store/preferences/uiStore"; +import { passShiftContextMenuToBrowser } from "@/ui/shared/utils/native-context-menu"; import { t, useT } from "@/i18n"; /** Keyboard resize step for the panel separator. */ @@ -384,6 +392,7 @@ function FilesTab({ const documents = useDocumentStore((s) => s.documents); const activeDocId = useDocumentStore((s) => s.activeDocId); const createFolder = useFolderStore((s) => s.createFolder); + const contextMenuEnabled = useUIStore((s) => s.contextMenuEnabled); const { confirm } = useDialog(); const [filter, setFilter] = useState(""); @@ -583,6 +592,7 @@ function FilesTab({ {t("library.selectForBulkActions")} )} + {!selectMode && } {!selectMode && (
- {/* List */} + {/* List. A right-click anywhere in it that no row claims — blank space + below the tree, the empty state, the flat list — offers New document + and New folder (the tree's own menu handles its rows and stops the + event, so the two menus never both open). */} +
{filtered.length === 0 && ( @@ -638,6 +652,7 @@ function FilesTab({ onShareDoc={onShareDoc} onShareFolder={onShareFolder} onNewDocInFolder={onNewDocInFolder} + onNewDoc={onNew} confirmDeleteFolder={confirmDeleteFolder} scrollRef={listScrollRef} /> @@ -667,6 +682,7 @@ function FilesTab({
+
{/* Footer count. A095: 10px at the full muted token (the 60% alpha it carried measured 2.3:1). */} @@ -681,6 +697,70 @@ function FilesTab({ ); } +/** The library's blank-space right-click menu around `children` (one element, + * the list's scroller, which the trigger renders as). Off: the browser's menu. */ +function LibraryContextMenu({ + enabled, + onNewDoc, + children, +}: { + enabled: boolean; + onNewDoc: () => void; + children: React.ReactElement; +}) { + if (!enabled) return children; + return ( + + + + + + + ); +} + +const SORT_LABEL_KEYS: Record = { + updated: "library.sortUpdated", + name: "library.sortName", + created: "library.sortCreated", + manual: "library.sortManual", +}; + +/** How the library is ordered — Last edited (the default), Name, Date created + * or Custom order (see LibrarySort). */ +function LibrarySortMenu() { + const t = useT(); + const sort = useUIStore((s) => s.librarySort); + const setSort = useUIStore((s) => s.setLibrarySort); + const label = t("library.sortByCurrent", { order: t(SORT_LABEL_KEYS[sort]) }); + return ( + + + + + + } /> + } /> + {label} + + + + {t("library.sortBy")} + setSort(v as LibrarySort)}> + {LIBRARY_SORTS.map((s) => ( + {t(SORT_LABEL_KEYS[s])} + ))} + + + +

{t("library.sortHint")}

+
+
+ ); +} + interface FlatDocListProps { docs: DocumentSummary[]; activeDocId: string | null; @@ -872,7 +952,10 @@ function FlatDocList({ )} - {doc.title || t("common.untitled")} + + {doc.title || t("common.untitled")} + {(() => { const shared = sharedStateOf(doc); return shared ? : null; })()} + {timeAgo(doc.updated)} {doc.wordCount && doc.wordCount > 0 && ( diff --git a/apps/web/vite.app.config.ts b/apps/web/vite.app.config.ts index 51f97a4..9da0a8c 100644 --- a/apps/web/vite.app.config.ts +++ b/apps/web/vite.app.config.ts @@ -5,12 +5,17 @@ import path from 'path'; import { defineConfig } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; import { manualChunks } from './vite.chunks'; +import { buildInfo } from './build/build-info.mjs'; const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')); export default defineConfig({ define: { __APP_VERSION__: JSON.stringify(pkg.version), + // The branch and commit this build came from, shown beside the version + // (src/constants.ts BUILD_INFO). The app only: the library and the embed + // carry the version alone. + __APP_BUILD__: JSON.stringify(buildInfo()), }, plugins: [ react(), diff --git a/bun.lock b/bun.lock index dc10df3..1f5fc39 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ }, "apps/web": { "name": "@perssonify/editor", - "version": "1.77.0", + "version": "1.78.0", "dependencies": { "@zip.js/zip.js": "^2.8.34", "abcjs": "^6.6.4", diff --git a/packages/db/drizzle/0002_publish.sql b/packages/db/drizzle/0002_publish.sql new file mode 100644 index 0000000..0231a1f --- /dev/null +++ b/packages/db/drizzle/0002_publish.sql @@ -0,0 +1,13 @@ +CREATE TABLE "published_document" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "doc_id" text NOT NULL, + "title" text DEFAULT '' NOT NULL, + "revision" text NOT NULL, + "size" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "modified_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "published_document" ADD CONSTRAINT "published_document_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "published_document_user_doc" ON "published_document" USING btree ("user_id","doc_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0002_snapshot.json b/packages/db/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..a3a49d6 --- /dev/null +++ b/packages/db/drizzle/meta/0002_snapshot.json @@ -0,0 +1,683 @@ +{ + "id": "ecf05fab-1f0d-4f86-878c-a56581d480d5", + "prevId": "75770e89-68c0-41be-aaf8-bd40cca1e11b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.published_document": { + "name": "published_document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "doc_id": { + "name": "doc_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "published_document_user_doc": { + "name": "published_document_user_doc", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "doc_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "published_document_user_id_user_id_fk": { + "name": "published_document_user_id_user_id_fk", + "tableFrom": "published_document", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_asset": { + "name": "sync_asset", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_asset_user_id_user_id_fk": { + "name": "sync_asset_user_id_user_id_fk", + "tableFrom": "sync_asset", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_asset_user_id_hash_pk": { + "name": "sync_asset_user_id_hash_pk", + "columns": [ + "user_id", + "hash" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_document": { + "name": "sync_document", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "doc_id": { + "name": "doc_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "updated": { + "name": "updated", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_document_user_id_user_id_fk": { + "name": "sync_document_user_id_user_id_fk", + "tableFrom": "sync_document", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_document_user_id_doc_id_pk": { + "name": "sync_document_user_id_doc_id_pk", + "columns": [ + "user_id", + "doc_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_library": { + "name": "sync_library", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "folder_tree": { + "name": "folder_tree", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated": { + "name": "updated", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_library_user_id_user_id_fk": { + "name": "sync_library_user_id_user_id_fk", + "tableFrom": "sync_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 5c3fc2b..6f4bf03 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1791186822421, "tag": "0001_sync", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1792434631605, + "tag": "0002_publish", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index b901eaa..d790244 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,4 +1,5 @@ -// Every table the backend owns: accounts (Better Auth) and the cloud index of -// each user's library (content in S3). +// Every table the backend owns: accounts (Better Auth), the cloud index of +// each user's library (content in S3) and the documents they publish. export * from "./auth.ts"; +export * from "./publish.ts"; export * from "./sync.ts"; diff --git a/packages/db/src/schema/publish.ts b/packages/db/src/schema/publish.ts new file mode 100644 index 0000000..e5147b7 --- /dev/null +++ b/packages/db/src/schema/publish.ts @@ -0,0 +1,30 @@ +// Live shares: a document its owner publishes read-only, which people who open +// the link follow — their copy updates when the owner edits. The CONTENT is in +// S3 (users//published/.json.gz); this is the index the public +// link is looked up in (apps/api/src/modules/sync/routes/index.ts). +import { integer, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; +import { user } from "./auth.ts"; + +/** + * One row per published document. `id` is the public, unguessable id in the + * link (the only way to read it — no listing exists); `revision` is the + * document revision (`updated`) the stored copy was made from, which followers + * compare against theirs. Unpublishing deletes the row and the copy. + */ +export const publishedDocument = pgTable( + "published_document", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + docId: text("doc_id").notNull(), + title: text("title").notNull().default(""), + revision: text("revision").notNull(), + /** Bytes stored in S3 (gzip). */ + size: integer("size").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + modifiedAt: timestamp("modified_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [uniqueIndex("published_document_user_doc").on(t.userId, t.docId)], +); diff --git a/packages/db/test/migrations.test.ts b/packages/db/test/migrations.test.ts index 1b6089e..b4c2e70 100644 --- a/packages/db/test/migrations.test.ts +++ b/packages/db/test/migrations.test.ts @@ -15,7 +15,7 @@ const migrationSql = readdirSync(MIGRATIONS_FOLDER) describe("the committed migrations cover the schema", () => { it("finds the schema's tables (the check is not vacuous)", () => { - expect(tables.map((t) => t.name).sort()).toEqual(["account", "session", "sync_asset", "sync_document", "sync_library", "user", "verification"]); + expect(tables.map((t) => t.name).sort()).toEqual(["account", "published_document", "session", "sync_asset", "sync_document", "sync_library", "user", "verification"]); }); // A schema edit without `bun run db:generate` ships a table or column the