From bd10429a6a356adba3d2f88ac8e52c474b4e7bea Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 26 Aug 2026 01:50:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(sharing):=20=E4=BF=AE=E5=A4=8D=E6=96=B0?= =?UTF-8?q?=E7=AC=94=E8=AE=B0=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/share-routes.test.mjs | 105 +++++++++++++++++++ apps/api/src/share-routes.ts | 26 ++++- apps/web/src/components/EditorPane.tsx | 26 +++-- apps/web/src/components/MemoListPane.tsx | 2 +- apps/web/src/components/PublicSharePage.tsx | 6 +- apps/web/src/hooks/useWorkspaceQueuedSync.ts | 52 +++++---- apps/web/src/lib/local-mirror.test.mjs | 40 +++++++ apps/web/src/lib/local-mirror.ts | 18 ++++ packages/shared/src/i18n/en-US.ts | 1 + packages/shared/src/i18n/zh-CN.ts | 1 + packages/shared/src/note-links.test.mjs | 8 +- packages/shared/src/note-links.ts | 22 ++++ packages/shared/src/sharing.test.mjs | 18 ++++ packages/shared/src/sharing.ts | 30 ++++-- tests/note-sharing-menu.test.ts | 18 ++++ 15 files changed, 331 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/share-routes.test.mjs create mode 100644 tests/note-sharing-menu.test.ts diff --git a/apps/api/src/share-routes.test.mjs b/apps/api/src/share-routes.test.mjs new file mode 100644 index 00000000..abd00228 --- /dev/null +++ b/apps/api/src/share-routes.test.mjs @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { globSync, readFileSync } from "node:fs"; +import { Hono } from "hono"; +import { registerPublicShareRoutes } from "./share-routes.ts"; + +class SqliteD1PreparedStatement { + constructor(db, sql, bindings = []) { + this.db = db; + this.sql = sql; + this.bindings = bindings; + } + + bind(...bindings) { + return new SqliteD1PreparedStatement(this.db, this.sql, bindings); + } + + async all() { + return { results: this.db.query(this.sql).all(...this.bindings), success: true, meta: {} }; + } + + async first() { + return this.db.query(this.sql).get(...this.bindings) ?? null; + } + + async run() { + this.db.query(this.sql).run(...this.bindings); + return { success: true, meta: {} }; + } +} + +class SqliteD1Database { + constructor(db) { + this.db = db; + } + + prepare(sql) { + return new SqliteD1PreparedStatement(this.db, sql); + } + + async batch(statements) { + return this.db.transaction(() => statements.map((statement) => + this.db.query(statement.sql).run(...statement.bindings)))(); + } +} + +const sourceToken = "s".repeat(43); +const targetToken = "t".repeat(43); + +const createDatabaseEnvironment = () => { + const sqlite = new Database(":memory:"); + for (const migration of globSync("migrations/*.sql").sort()) { + sqlite.exec(readFileSync(migration, "utf8")); + } + sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)") + .run("ws_member", "Member workspace"); + sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)") + .run("nb_member", "ws_member", "Inbox"); + + const contentJson = JSON.stringify({ + type: "doc", + content: [{ + type: "paragraph", + content: [ + { type: "text", text: "Public", marks: [{ type: "link", attrs: { href: "#memo=memo_target" } }] }, + { type: "text", text: "Private", marks: [{ type: "link", attrs: { href: "#memo=memo_private" } }] }, + ], + }], + }); + for (const [id, title, content] of [ + ["memo_source", "Source", contentJson], + ["memo_target", "Target", JSON.stringify({ type: "doc", content: [] })], + ["memo_private", "Private", JSON.stringify({ type: "doc", content: [] })], + ]) { + sqlite.query("INSERT INTO memos (id, workspace_id, notebook_id, title) VALUES (?, ?, ?, ?)") + .run(id, "ws_member", "nb_member", title); + sqlite.query("INSERT INTO memo_contents (memo_id, content_json, content_markdown, content_hash) VALUES (?, ?, '', ?)") + .run(id, content, `${id}-hash`); + } + sqlite.query("INSERT INTO memo_shares (id, memo_id, workspace_id, token) VALUES (?, ?, ?, ?)") + .run("share_source", "memo_source", "ws_member", sourceToken); + sqlite.query("INSERT INTO memo_shares (id, memo_id, workspace_id, token) VALUES (?, ?, ?, ?)") + .run("share_target", "memo_target", "ws_member", targetToken); + + return { + sqlite, + environment: { storage: { db: new SqliteD1Database(sqlite), resources: {} } }, + }; +}; + +describe("public memo shares", () => { + test("returns share tokens only for referenced notes that are also public", async () => { + const { sqlite, environment } = createDatabaseEnvironment(); + const app = new Hono(); + registerPublicShareRoutes(app); + + const response = await app.request(`/api/public/shares/${sourceToken}`, {}, environment); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + share: { memoShareTokens: { memo_target: targetToken } }, + }); + sqlite.close(); + }); +}); diff --git a/apps/api/src/share-routes.ts b/apps/api/src/share-routes.ts index fcd52032..cf2f0835 100644 --- a/apps/api/src/share-routes.ts +++ b/apps/api/src/share-routes.ts @@ -1,4 +1,4 @@ -import type { MemoShare, PublicMemoShare, TiptapDoc } from "@edgeever/shared"; +import { collectMemoLinkIds, resolveMemoContentDoc, type MemoShare, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared"; import type { Hono } from "hono"; import type { AppEnv } from "./api-context"; import { audit } from "./audit"; @@ -13,12 +13,14 @@ const SHARE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; type MemoShareRow = { memo_id: string; token: string; created_at: string; updated_at: string }; type PublicMemoShareRow = { + workspace_id: string; title: string | null; content_json: string; content_markdown: string; tags_json: string; updated_at: string; }; +type ReferencedMemoShareRow = { memo_id: string; token: string }; type SharedResourceRow = { object_key: string; storage_config_id: string; @@ -55,7 +57,7 @@ export const registerPublicShareRoutes = (app: Hono) => { if (!token) return notFound(c, "Shared note not found"); const row = await c.env.storage.db.prepare( - `SELECT m.title, mc.content_json, mc.content_markdown, m.tags_json, m.updated_at + `SELECT ms.workspace_id, m.title, mc.content_json, mc.content_markdown, m.tags_json, m.updated_at FROM memo_shares ms INNER JOIN memos m ON m.id = ms.memo_id AND m.workspace_id = ms.workspace_id INNER JOIN memo_contents mc ON mc.memo_id = m.id @@ -64,12 +66,30 @@ export const registerPublicShareRoutes = (app: Hono) => { ).bind(token).first(); if (!row) return notFound(c, "Shared note not found"); + const contentJson = JSON.parse(row.content_json) as TiptapDoc; + const referencedMemoIds = collectMemoLinkIds(resolveMemoContentDoc(contentJson, row.content_markdown)); + const memoShareTokens: Record = {}; + for (let offset = 0; offset < referencedMemoIds.length; offset += 80) { + const batch = referencedMemoIds.slice(offset, offset + 80); + const placeholders = batch.map(() => "?").join(", "); + const linkedShares = await c.env.storage.db.prepare( + `SELECT ms.memo_id, ms.token + FROM memo_shares ms + INNER JOIN memos m ON m.id = ms.memo_id AND m.workspace_id = ms.workspace_id + WHERE ms.workspace_id = ? AND ms.memo_id IN (${placeholders}) AND m.is_deleted = 0` + ).bind(row.workspace_id, ...batch).all(); + for (const linkedShare of linkedShares.results) { + memoShareTokens[linkedShare.memo_id] = linkedShare.token; + } + } + const share: PublicMemoShare = { title: row.title, - contentJson: JSON.parse(row.content_json) as TiptapDoc, + contentJson, contentMarkdown: row.content_markdown, tags: parseJsonArray(row.tags_json), updatedAt: row.updated_at, + memoShareTokens, }; c.header("Cache-Control", "private, no-store"); c.header("X-Robots-Tag", "noindex, nofollow, noarchive"); diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 14036ae2..d5818dda 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -2691,6 +2691,13 @@ const RichEditorPane = ({ const syncedMemo = memoId ? result?.syncedMemos?.get(memoId) : null; advanceMemoSyncBase(syncedMemo); + // A newly created note keeps a local ID until its create request has + // been acknowledged and remapped. A different queue item completing + // must not make that note claim it is already synced. + if (!memoId || isLocalMemoId(memoId)) { + return; + } + if (memoId && (result?.conflicted ?? 0) > 0) { void localDb.syncQueue.get(getMemoUpdateQueueId(memoId)).then((item) => { if (!item || item.status !== "conflict" || memoRef.current?.id !== memoId) { @@ -2706,12 +2713,19 @@ const RichEditorPane = ({ return; } - setSaveState((current) => { - if (current !== "queued") { - return current; + // Sync runs are workspace-wide. Confirm that this memo has no successor + // request left in the outbox before changing its per-note status. + void localDb.syncQueue.where("memoId").equals(memoId).first().then((item) => { + if (item || memoRef.current?.id !== memoId || hasUnsavedChangesRef.current) { + return; } - setSaveConflictInfo(null); - return "saved"; + setSaveState((current) => { + if (current !== "queued") { + return current; + } + setSaveConflictInfo(null); + return "saved"; + }); }); }; @@ -4335,7 +4349,7 @@ const RichEditorPane = ({ onClick={() => setShareOpen(true)} > - {t(isMemoShared ? "sharing.manage" : "sharing.action")} + {t(isLocalMemoId(memo.id) ? "sharing.afterSync" : isMemoShared ? "sharing.manage" : "sharing.action")} )} requestContextDocumentAction("share")} > - {t("sharing.action")} + {t(isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync" : "sharing.action")} { const content = useMemo( - () => rewriteMemoResourcesForShare(resolveMemoContentDoc(share.contentJson, share.contentMarkdown), token), + () => rewriteMemoResourcesForShare( + resolveMemoContentDoc(share.contentJson, share.contentMarkdown), + token, + share.memoShareTokens, + ), [share, token], ); const editor = useEditor({ diff --git a/apps/web/src/hooks/useWorkspaceQueuedSync.ts b/apps/web/src/hooks/useWorkspaceQueuedSync.ts index cb81075e..75908849 100644 --- a/apps/web/src/hooks/useWorkspaceQueuedSync.ts +++ b/apps/web/src/hooks/useWorkspaceQueuedSync.ts @@ -1,11 +1,11 @@ -import { useCallback, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; +import { useCallback, useEffect, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; import type { QueryClient } from "@tanstack/react-query"; import type { MemoDetail, MemoTemplate, Notebook, Resource } from "@edgeever/shared"; import { isBrowserOffline } from "@/lib/network-status"; import { emptySyncQueueSummary, type SyncQueueSummary } from "@/lib/sync-queue"; import { notifyMemoIdRemapped, notifyMemoSyncAcknowledged } from "@/lib/sync-events"; -import { putLocalMemo, replaceLocalMemoId } from "@/lib/local-mirror"; -import { resolveCreatedMemoSelection, resolveSyncedMemoId } from "@/lib/workspace-refresh"; +import { observeLocalMemoIdMappings, putLocalMemo, replaceLocalMemoId } from "@/lib/local-mirror"; +import { resolveSyncedMemoId } from "@/lib/workspace-refresh"; type UseWorkspaceQueuedSyncOptions = { isOnline: boolean; @@ -31,6 +31,30 @@ export const useWorkspaceQueuedSync = ({ const [syncSummary, setSyncSummary] = useState(emptySyncQueueSummary); const [isSyncingQueuedChanges, setIsSyncingQueuedChanges] = useState(false); + const applyMemoIdMappings = useCallback((memoIdMappings: ReadonlyMap) => { + if (memoIdMappings.size === 0) return; + + notifyMemoIdRemapped(memoIdMappings); + const selectedMemoId = selectedMemoIdRef.current; + const pendingMemoId = pendingCreatedMemoIdRef.current; + const remappedSelectedMemoId = resolveSyncedMemoId(memoIdMappings, selectedMemoId); + const remappedPendingMemoId = resolveSyncedMemoId(memoIdMappings, pendingMemoId); + const nextSelectedMemoId = remappedSelectedMemoId !== selectedMemoId + ? remappedSelectedMemoId + : remappedPendingMemoId !== pendingMemoId + ? remappedPendingMemoId + : selectedMemoId; + + pendingCreatedMemoIdRef.current = remappedPendingMemoId; + setCreatedMemoEditId((current) => resolveSyncedMemoId(memoIdMappings, current)); + if (nextSelectedMemoId !== selectedMemoId) { + selectedMemoIdRef.current = nextSelectedMemoId; + setSelectedMemoId(nextSelectedMemoId); + } + }, [pendingCreatedMemoIdRef, selectedMemoIdRef, setCreatedMemoEditId, setSelectedMemoId]); + + useEffect(() => observeLocalMemoIdMappings(localDataScope, applyMemoIdMappings), [applyMemoIdMappings, localDataScope]); + const invalidateSyncQueries = useCallback(() => Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo"] }), @@ -49,12 +73,7 @@ export const useWorkspaceQueuedSync = ({ if (window.edgeeverDesktop?.isAvailable) { const { getDesktopSyncSummary, syncDesktopData } = await import("@/lib/desktop-sync"); const result = await syncDesktopData(); - if (result.memoIdMappings.size > 0) { - notifyMemoIdRemapped(result.memoIdMappings); - pendingCreatedMemoIdRef.current = resolveSyncedMemoId(result.memoIdMappings, pendingCreatedMemoIdRef.current); - setCreatedMemoEditId((current) => resolveSyncedMemoId(result.memoIdMappings, current)); - setSelectedMemoId((current) => resolveSyncedMemoId(result.memoIdMappings, current)); - } + applyMemoIdMappings(result.memoIdMappings); window.dispatchEvent(new CustomEvent("edgeever:sync-completed", { detail: result })); setSyncSummary(await getDesktopSyncSummary()); await invalidateSyncQueries(); @@ -73,18 +92,7 @@ export const useWorkspaceQueuedSync = ({ onSynced: async (memo, item) => { if (item.kind === "memo.create") { const remappedMemo = await replaceLocalMemoId(localDataScope, item.memoId, memo); - const remappedSelection = resolveCreatedMemoSelection( - selectedMemoIdRef.current, - pendingCreatedMemoIdRef.current, - item.memoId, - memo.id, - ); - if (remappedSelection === memo.id) { - selectedMemoIdRef.current = memo.id; - setSelectedMemoId(memo.id); - setCreatedMemoEditId(memo.id); - pendingCreatedMemoIdRef.current = memo.id; - } + applyMemoIdMappings(new Map([[item.memoId, memo.id]])); queryClient.setQueryData(["memo", memo.id, memo.isDeleted ? "trash" : "notebook"], { memo: remappedMemo }); } else { await putLocalMemo(localDataScope, memo); @@ -138,7 +146,7 @@ export const useWorkspaceQueuedSync = ({ } finally { setIsSyncingQueuedChanges(false); } - }, [invalidateSyncQueries, localDataScope, pendingCreatedMemoIdRef, queryClient, selectedMemoIdRef, setCreatedMemoEditId, setOnline, setSelectedMemoId]); + }, [applyMemoIdMappings, invalidateSyncQueries, localDataScope, queryClient, setOnline]); const discardConflictsNow = useCallback(async () => { if (!isOnline) return; diff --git a/apps/web/src/lib/local-mirror.test.mjs b/apps/web/src/lib/local-mirror.test.mjs index 9e40102b..bcd9f612 100644 --- a/apps/web/src/lib/local-mirror.test.mjs +++ b/apps/web/src/lib/local-mirror.test.mjs @@ -32,8 +32,11 @@ const { putLocalResource, createLocalResource, listLocalResources, + listLocalMemoIdMappings, + observeLocalMemoIdMappings, replaceLocalResources, remapLocalDraftMemoId, + replaceLocalMemoId, hasLocalSyncCursorRewound, syncLocalMirror, } = await import("./local-mirror.ts"); @@ -61,6 +64,43 @@ afterEach(async () => { }); describe("local mirror", () => { + test("records a durable mapping when a new memo receives its remote id", async () => { + const scope = createLocalDataScope("https://notes.example.org", "user-1"); + const localMemo = await createLocalMemo(scope, { notebookId: "inbox", title: "New note" }); + const remoteMemo = { + ...localMemo, + id: "memo-remote", + revision: 1, + contentHash: "remote-hash", + }; + + await replaceLocalMemoId(scope, localMemo.id, remoteMemo); + + expect(await listLocalMemoIdMappings(scope)).toEqual(new Map([[localMemo.id, remoteMemo.id]])); + }); + + test("observes a new memo id mapping so another live workspace can recover", async () => { + const scope = createLocalDataScope("https://notes.example.org", "user-1"); + const localMemo = await createLocalMemo(scope, { notebookId: "inbox", title: "New note" }); + const remoteMemo = { + ...localMemo, + id: "memo-remote", + revision: 1, + contentHash: "remote-hash", + }; + const observedMapping = new Promise((resolve) => { + const unsubscribe = observeLocalMemoIdMappings(scope, (mappings) => { + if (mappings.get(localMemo.id) !== remoteMemo.id) return; + unsubscribe(); + resolve(mappings); + }); + }); + + await replaceLocalMemoId(scope, localMemo.id, remoteMemo); + + expect(await observedMapping).toEqual(new Map([[localMemo.id, remoteMemo.id]])); + }); + test("clears cached data, drafts, and pending changes for a reset scope", async () => { const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); const otherScope = createLocalDataScope("https://notes.example.org", "user-2"); diff --git a/apps/web/src/lib/local-mirror.ts b/apps/web/src/lib/local-mirror.ts index b9c84596..0992ae18 100644 --- a/apps/web/src/lib/local-mirror.ts +++ b/apps/web/src/lib/local-mirror.ts @@ -1,4 +1,5 @@ import { createExcerpt, docToMarkdown, docToText, markdownToDoc, mergeMemoDocs, resolveMemoContentDoc, resolveMergedMemoTitle, type MemoDetail, type MemoRevision, type MemoSummary, type MemoTemplate, type Notebook, type ResourceListItem, type TagSummary, type TiptapDoc } from "@edgeever/shared"; +import { liveQuery } from "dexie"; import type { MemoFilterMode, MemoSortMode } from "@/lib/app-helpers"; import { api, type SyncChangesResponse } from "@/lib/api"; import { localDb, selectNewestLocalDraft, type LocalDraft, type LocalMemo, type LocalNotebook, type LocalResource, type LocalRevision } from "@/lib/local-db"; @@ -319,6 +320,23 @@ export const remapLocalDraftMemoId = async (temporaryId: string, remoteId: strin }); }; +export const listLocalMemoIdMappings = async (scope: string) => new Map( + (await localDb.idMappings.where("scope").equals(scope).toArray()) + .map(({ temporaryId, remoteId }) => [temporaryId, remoteId] as const), +); + +export const observeLocalMemoIdMappings = ( + scope: string, + onChange: (mappings: ReadonlyMap) => void, +) => { + const subscription = liveQuery(() => listLocalMemoIdMappings(scope)).subscribe({ + next: onChange, + error: () => onChange(new Map()), + }); + + return () => subscription.unsubscribe(); +}; + export const replaceLocalMemoId = async (scope: string, temporaryId: string, memo: MemoDetail) => { return localDb.transaction("rw", [localDb.memos, localDb.idMappings, localDb.drafts], async () => { const [temporaryMemo, temporaryDraft, remoteDraft] = await Promise.all([ diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index d443f01c..6743640c 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -1206,6 +1206,7 @@ export const enUS = { }, sharing: { action: "Share note", + afterSync: "Share note after sync", active: "Shared", manage: "Manage sharing", title: "Share note", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 6dacbb76..5c12af50 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1204,6 +1204,7 @@ export const zhCN = { }, sharing: { action: "分享笔记", + afterSync: "同步后可分享笔记", active: "正在分享", manage: "管理分享", title: "分享笔记", diff --git a/packages/shared/src/note-links.test.mjs b/packages/shared/src/note-links.test.mjs index 8edee99f..326c4699 100644 --- a/packages/shared/src/note-links.test.mjs +++ b/packages/shared/src/note-links.test.mjs @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createMemoLinkHref, parseMemoLinkHref } from "./note-links.ts"; +import { collectMemoLinkIds, createMemoLinkHref, parseMemoLinkHref } from "./note-links.ts"; import { docToMarkdown, markdownToDoc } from "./content.ts"; describe("stable memo links", () => { @@ -26,4 +26,10 @@ describe("stable memo links", () => { }); expect(docToMarkdown(doc)).toBe(markdown); }); + + test("collects unique referenced memo ids from link marks", () => { + const doc = markdownToDoc("[项目](#memo=memo_project) 和 [项目副本](#memo=memo_project) 及 [待办](#memo=memo_todo)"); + + expect(collectMemoLinkIds(doc)).toEqual(["memo_project", "memo_todo"]); + }); }); diff --git a/packages/shared/src/note-links.ts b/packages/shared/src/note-links.ts index 8fbe2a91..0284c6fb 100644 --- a/packages/shared/src/note-links.ts +++ b/packages/shared/src/note-links.ts @@ -1,3 +1,5 @@ +import type { TiptapDoc, TiptapNode, TiptapTextNode } from "./content"; + const MEMO_LINK_PREFIX = "#memo="; export const createMemoLinkHref = (memoId: string): string => `${MEMO_LINK_PREFIX}${encodeURIComponent(memoId)}`; @@ -18,3 +20,23 @@ export const parseMemoLinkHref = (href: unknown): string | null => { return null; } }; + +export const collectMemoLinkIds = (doc: TiptapDoc): string[] => { + const memoIds = new Set(); + const visit = (node: TiptapNode | TiptapTextNode) => { + if ("attrs" in node) { + const memoId = parseMemoLinkHref(node.attrs?.href); + if (memoId) memoIds.add(memoId); + } + if ("marks" in node) { + for (const mark of node.marks ?? []) { + const memoId = parseMemoLinkHref(mark.attrs?.href); + if (memoId) memoIds.add(memoId); + } + } + if ("content" in node) node.content?.forEach(visit); + }; + + doc.content.forEach(visit); + return [...memoIds]; +}; diff --git a/packages/shared/src/sharing.test.mjs b/packages/shared/src/sharing.test.mjs index c052dd76..f22dc7cd 100644 --- a/packages/shared/src/sharing.test.mjs +++ b/packages/shared/src/sharing.test.mjs @@ -35,4 +35,22 @@ describe("shared memo resources", () => { expect(rewritten.content[2].content[0].marks[0].attrs.href).toBe("https://example.com/file"); expect(source.content[0].attrs.src).toBe("/api/v1/resources/res_image/blob"); }); + + test("rewrites references to publicly shared notes without exposing private targets", () => { + const source = { + type: "doc", + content: [{ + type: "paragraph", + content: [ + { type: "text", text: "Public", marks: [{ type: "link", attrs: { href: "#memo=memo_public" } }] }, + { type: "text", text: "Private", marks: [{ type: "link", attrs: { href: "#memo=memo_private" } }] }, + ], + }], + }; + + const rewritten = rewriteMemoResourcesForShare(source, "source_token", { memo_public: "target_token" }); + + expect(rewritten.content[0].content[0].marks[0].attrs.href).toBe("/share/target_token"); + expect(rewritten.content[0].content[1].marks[0].attrs.href).toBe("#memo=memo_private"); + }); }); diff --git a/packages/shared/src/sharing.ts b/packages/shared/src/sharing.ts index e37cbf88..5df49e00 100644 --- a/packages/shared/src/sharing.ts +++ b/packages/shared/src/sharing.ts @@ -1,4 +1,5 @@ import type { TiptapDoc, TiptapNode, TiptapTextNode } from "./content"; +import { parseMemoLinkHref } from "./note-links"; export type MemoShare = { memoId: string; @@ -13,29 +14,42 @@ export type PublicMemoShare = { contentMarkdown: string; tags: string[]; updatedAt: string; + memoShareTokens: Record; }; const RESOURCE_URL_PATTERN = /^\/api\/v1\/resources\/([^/?#]+)\/blob(?:[?#].*)?$/; -const rewriteAttributeValue = (value: unknown, token: string) => { +const rewriteAttributeValue = ( + value: unknown, + token: string, + memoShareTokens: Readonly>, +) => { if (typeof value !== "string") return value; const match = value.match(RESOURCE_URL_PATTERN); - return match - ? `/api/public/shares/${encodeURIComponent(token)}/resources/${encodeURIComponent(match[1])}/blob` - : value; + if (match) { + return `/api/public/shares/${encodeURIComponent(token)}/resources/${encodeURIComponent(match[1])}/blob`; + } + + const memoId = parseMemoLinkHref(value); + const shareToken = memoId ? memoShareTokens[memoId] : null; + return shareToken ? `/share/${encodeURIComponent(shareToken)}` : value; }; -/** Rewrites only EdgeEver-owned resource URLs so a public viewer never needs a user session. */ -export const rewriteMemoResourcesForShare = (doc: TiptapDoc, token: string): TiptapDoc => { +/** Rewrites EdgeEver-owned resources and publicly shared memo links for anonymous viewers. */ +export const rewriteMemoResourcesForShare = ( + doc: TiptapDoc, + token: string, + memoShareTokens: Readonly> = {}, +): TiptapDoc => { const visit = (node: TiptapNode | TiptapTextNode): TiptapNode | TiptapTextNode => { const attrs = "attrs" in node && node.attrs - ? Object.fromEntries(Object.entries(node.attrs).map(([key, value]) => [key, rewriteAttributeValue(value, token)])) + ? Object.fromEntries(Object.entries(node.attrs).map(([key, value]) => [key, rewriteAttributeValue(value, token, memoShareTokens)])) : undefined; const marks = "marks" in node && node.marks ? node.marks.map((mark) => ({ ...mark, attrs: mark.attrs - ? Object.fromEntries(Object.entries(mark.attrs).map(([key, value]) => [key, rewriteAttributeValue(value, token)])) + ? Object.fromEntries(Object.entries(mark.attrs).map(([key, value]) => [key, rewriteAttributeValue(value, token, memoShareTokens)])) : undefined, })) : undefined; diff --git a/tests/note-sharing-menu.test.ts b/tests/note-sharing-menu.test.ts new file mode 100644 index 00000000..c57ff408 --- /dev/null +++ b/tests/note-sharing-menu.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { enUS } from "../packages/shared/src/i18n/en-US"; +import { zhCN } from "../packages/shared/src/i18n/zh-CN"; + +const readSource = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); + +const editorSource = readSource("../apps/web/src/components/EditorPane.tsx"); +const memoListSource = readSource("../apps/web/src/components/MemoListPane.tsx"); + +describe("note sharing menu", () => { + test("explains why sharing a newly created local note is disabled", () => { + expect(zhCN.sharing.afterSync).toBe("同步后可分享笔记"); + expect(enUS.sharing.afterSync).toBe("Share note after sync"); + expect(editorSource).toContain('isLocalMemoId(memo.id) ? "sharing.afterSync"'); + expect(memoListSource).toContain('isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync"'); + }); +});