fix(sharing): 修复新笔记同步与公开引用

This commit is contained in:
tianma-if committed 2026-08-26 01:50:57 +08:00
1 parent 81933658a2
commit bd10429a6a
15 files changed
+331 -42

No files matched your search

+105
View File
@@ -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();
});
});
+23 -3
View File
@@ -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");
+20 -6
View File
@@ -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
+1 -1
View File
@@ -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"
+5 -1
View File
@@ -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({
+30 -22
View File
@@ -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;
+40
View File
@@ -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");
+18
View File
@@ -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([
+1
View File
@@ -1206,6 +1206,7 @@ export const enUS = {
},
sharing: {
action: "Share note",
afterSync: "Share note after sync",
active: "Shared",
manage: "Manage sharing",
title: "Share note",
+1
View File
@@ -1204,6 +1204,7 @@ export const zhCN = {
},
sharing: {
action: "分享笔记",
afterSync: "同步后可分享笔记",
active: "正在分享",
manage: "管理分享",
title: "分享笔记",
+7 -1
View File
@@ -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"]);
});
});
+22
View File
@@ -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];
};
+18
View File
@@ -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");
});
});
+22 -8
View File
@@ -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;
+18
View File
@@ -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"');
});
});