fix(sharing): 修复新笔记同步与公开引用
This commit is contained in:
1 parent
81933658a2
commit
bd10429a6a
15 files changed
+331
-42
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<AppEnv>) => {
|
||||
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<AppEnv>) => {
|
||||
).bind(token).first<PublicMemoShareRow>();
|
||||
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<string, string> = {};
|
||||
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<ReferencedMemoShareRow>();
|
||||
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");
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<Link2 className={cn("h-4 w-4", isMemoShared ? "text-emerald-600" : "text-slate-500")} />
|
||||
{t(isMemoShared ? "sharing.manage" : "sharing.action")}
|
||||
{t(isLocalMemoId(memo.id) ? "sharing.afterSync" : isMemoShared ? "sharing.manage" : "sharing.action")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -1557,7 +1557,7 @@ export const MemoListPane = ({
|
||||
onClick={() => requestContextDocumentAction("share")}
|
||||
>
|
||||
<Link2 className="h-4 w-4 text-slate-500" />
|
||||
{t("sharing.action")}
|
||||
{t(isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync" : "sharing.action")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
|
||||
|
||||
@@ -75,7 +75,11 @@ const SharedThemeBlock = Node.create({
|
||||
|
||||
const SharedDocument = ({ share, token }: { share: PublicMemoShare; token: string }) => {
|
||||
const content = useMemo(
|
||||
() => rewriteMemoResourcesForShare(resolveMemoContentDoc(share.contentJson, share.contentMarkdown), token),
|
||||
() => rewriteMemoResourcesForShare(
|
||||
resolveMemoContentDoc(share.contentJson, share.contentMarkdown),
|
||||
token,
|
||||
share.memoShareTokens,
|
||||
),
|
||||
[share, token],
|
||||
);
|
||||
const editor = useEditor({
|
||||
|
||||
@@ -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<SyncQueueSummary>(emptySyncQueueSummary);
|
||||
const [isSyncingQueuedChanges, setIsSyncingQueuedChanges] = useState(false);
|
||||
|
||||
const applyMemoIdMappings = useCallback((memoIdMappings: ReadonlyMap<string, string>) => {
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, string>) => 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([
|
||||
|
||||
@@ -1206,6 +1206,7 @@ export const enUS = {
|
||||
},
|
||||
sharing: {
|
||||
action: "Share note",
|
||||
afterSync: "Share note after sync",
|
||||
active: "Shared",
|
||||
manage: "Manage sharing",
|
||||
title: "Share note",
|
||||
|
||||
@@ -1204,6 +1204,7 @@ export const zhCN = {
|
||||
},
|
||||
sharing: {
|
||||
action: "分享笔记",
|
||||
afterSync: "同步后可分享笔记",
|
||||
active: "正在分享",
|
||||
manage: "管理分享",
|
||||
title: "分享笔记",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
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];
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
const RESOURCE_URL_PATTERN = /^\/api\/v1\/resources\/([^/?#]+)\/blob(?:[?#].*)?$/;
|
||||
|
||||
const rewriteAttributeValue = (value: unknown, token: string) => {
|
||||
const rewriteAttributeValue = (
|
||||
value: unknown,
|
||||
token: string,
|
||||
memoShareTokens: Readonly<Record<string, string>>,
|
||||
) => {
|
||||
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<Record<string, string>> = {},
|
||||
): 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;
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user