diff --git a/AGENTS.md b/AGENTS.md index c7fd7b42..995849cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,17 @@ terraform/ # インフラ定義 - ルート `package.json` の `workspaces` は `packages/*` と `admin` のみを含む。`server/api`, `server/hocuspocus`, `server/mcp` は **意図的にルートの Bun workspace から外して**、個別の Bun プロジェクトとして管理する。 _Root `workspaces` covers only `packages/*` and `admin`. The three `server/*` services (`api`, `hocuspocus`, `mcp`) are intentionally kept **outside** the root Bun workspace and managed as standalone Bun projects._ + +### サーバ/クライアント間で共有する定数 / Sharing constants between server and client + +- `packages/shared`(`@zedi/shared`)は、フロント・admin・サーバすべてで共通利用したいピュアな TypeScript 定数を集約するためのワークスペースパッケージ。React や Node 専用 API には依存させない。 + _`packages/shared` (`@zedi/shared`) is a workspace package for pure TypeScript constants shared by client, admin, and (logically) server code. Keep it free of React or Node-only dependencies._ +- フロント (`src/`) と `admin/` はワークスペース内なので `import { ... } from "@zedi/shared/..."` で直接利用できる。 + _Workspace consumers (`src/`, `admin/`) import via `@zedi/shared/...`._ +- `server/api` 等のサーバプロジェクトはワークスペース外なので `@zedi/shared` を **直接 import できない**。代わりに同じ値を当該サーバ内に二重定義し、フロント側の vitest が `fs.readFileSync` でサーバファイルを読んで両者の文字列一致を検証するドリフト検知テスト(例: `src/lib/tagCharacterClassSync.test.ts`)を置くことで CI で同期を担保する。 + _Server projects (e.g. `server/api`) cannot import `@zedi/shared` because they are intentionally outside the workspace. Duplicate the constant inside the server source and add a client-side vitest (e.g. `src/lib/tagCharacterClassSync.test.ts`) that reads the server file via `fs.readFileSync` and asserts the two literals match. This keeps drift detectable in CI._ +- 値を更新する際は **`packages/shared` とサーバ側コピーを同時に編集すること**。ドリフト検知テストが落ちたら、片方しか変更していないサインなのでもう一方も追従させる。 + _When updating a shared value, edit `packages/shared` and the server-side copy together. If the drift test fails, the change touched only one side; sync the other._ - 理由 / Rationale: - Railway の Dockerfile ビルドは「各サービスの Root Directory」を build context に取る (例: `server/mcp`)。ここからルート `bun.lock` を参照するのは面倒で、context をサービス単位に閉じるほうが再現性が高い。 _Railway Dockerfile builds take each service's Root Directory as the build context. Scoping `bun.lock` per service keeps the build self-contained and reproducible._ diff --git a/bun.lock b/bun.lock index fc9ec009..333bb258 100644 --- a/bun.lock +++ b/bun.lock @@ -74,6 +74,7 @@ "@tiptap/starter-kit": "^3.20.0", "@tiptap/y-tiptap": "^3.0.2", "@xyflow/react": "^12.10.1", + "@zedi/shared": "workspace:*", "@zedi/ui": "workspace:*", "baseline-browser-mapping": "^2.9.19", "better-auth": "^1.2.0", @@ -211,6 +212,14 @@ "vitest": "^4.0.16", }, }, + "packages/shared": { + "name": "@zedi/shared", + "version": "0.1.0", + "devDependencies": { + "typescript": "^6.0.2", + "vitest": "^4.0.16", + }, + }, "packages/ui": { "name": "@zedi/ui", "version": "0.1.0", @@ -1393,6 +1402,8 @@ "@zedi/claude-sidecar": ["@zedi/claude-sidecar@workspace:packages/claude-sidecar"], + "@zedi/shared": ["@zedi/shared@workspace:packages/shared"], + "@zedi/ui": ["@zedi/ui@workspace:packages/ui"], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], diff --git a/package.json b/package.json index 6fd4e2c0..3372f719 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "format:check": "prettier --check .", "preview": "vite preview", "test": "vitest", - "test:run": "vitest run && vitest run --config packages/claude-sidecar/vitest.config.ts && vitest run --config server/hocuspocus/vitest.config.ts && vitest run --config server/mcp/vitest.config.ts", + "test:run": "vitest run && vitest run --config packages/shared/vitest.config.ts && vitest run --config packages/claude-sidecar/vitest.config.ts && vitest run --config server/hocuspocus/vitest.config.ts && vitest run --config server/mcp/vitest.config.ts", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", "test:e2e": "playwright test", @@ -155,6 +155,7 @@ "@tiptap/starter-kit": "^3.20.0", "@tiptap/y-tiptap": "^3.0.2", "@xyflow/react": "^12.10.1", + "@zedi/shared": "workspace:*", "@zedi/ui": "workspace:*", "baseline-browser-mapping": "^2.9.19", "better-auth": "^1.2.0", diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 00000000..f641b62e --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@zedi/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Source-of-truth constants shared between client (src, admin) and server packages. / クライアント (src, admin) とサーバ (server/api 等) で共有する定数モジュール。", + "exports": { + ".": "./src/index.ts", + "./tagCharacterClass": "./src/tagCharacterClass.ts" + }, + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "typescript": "^6.0.2", + "vitest": "^4.0.16" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 00000000..a6121744 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,9 @@ +/** + * `@zedi/shared` のエントリ。サーバ/クライアント/管理画面すべてで共有可能な + * ピュアな定数だけをここに集約する。React や Node 専用 API には依存しない。 + * + * Entry point for `@zedi/shared`. Holds pure constants that can be imported + * from server, client, and admin code alike. Must not depend on React or + * Node-only APIs so the package stays universally importable. + */ +export { TAG_NAME_CHAR_CLASS } from "./tagCharacterClass.js"; diff --git a/packages/shared/src/tagCharacterClass.test.ts b/packages/shared/src/tagCharacterClass.test.ts new file mode 100644 index 00000000..84b6762a --- /dev/null +++ b/packages/shared/src/tagCharacterClass.test.ts @@ -0,0 +1,44 @@ +/** + * `TAG_NAME_CHAR_CLASS` の最低限のスペックテスト。文字列リテラル仕様(正規表現 + * の文字クラス内側のみ、グローバル/アンカー無し)と、組み立てた正規表現が + * 期待通りの字種を受理/拒否することを確認する。 + * + * Lock down the minimum contract for `TAG_NAME_CHAR_CLASS`: it is the inner + * contents of a regex character class (no flags, no anchors), and a regex + * built from it accepts/rejects the documented script families. + */ +import { describe, it, expect } from "vitest"; + +import { TAG_NAME_CHAR_CLASS } from "./tagCharacterClass.js"; + +describe("TAG_NAME_CHAR_CLASS", () => { + it("contains no character-class brackets so callers can wrap it in `[...]`", () => { + // 完成した `[...]` ではなく中身だけを公開するという契約を固定する。 + // Lock the "inner contents only" contract so wrappers stay correct. + expect(TAG_NAME_CHAR_CLASS.startsWith("[")).toBe(false); + expect(TAG_NAME_CHAR_CLASS.endsWith("]")).toBe(false); + }); + + it("accepts ASCII letters, digits, underscore, and hyphen", () => { + const re = new RegExp(`^[${TAG_NAME_CHAR_CLASS}]+$`); + expect(re.test("Foo_bar-1")).toBe(true); + expect(re.test("ABCxyz089")).toBe(true); + }); + + it("accepts hiragana, katakana, and CJK characters", () => { + const re = new RegExp(`^[${TAG_NAME_CHAR_CLASS}]+$`); + // ひらがな・カタカナ・漢字。 + expect(re.test("ひらがな")).toBe(true); + expect(re.test("カタカナ")).toBe(true); + expect(re.test("日本語")).toBe(true); + expect(re.test("混合Mix日本語")).toBe(true); + }); + + it("rejects whitespace and ASCII punctuation outside the allowed set", () => { + const re = new RegExp(`^[${TAG_NAME_CHAR_CLASS}]+$`); + expect(re.test("has space")).toBe(false); + expect(re.test("dot.notation")).toBe(false); + expect(re.test("slash/sep")).toBe(false); + expect(re.test("emoji😀")).toBe(false); + }); +}); diff --git a/packages/shared/src/tagCharacterClass.ts b/packages/shared/src/tagCharacterClass.ts new file mode 100644 index 00000000..a508aaf4 --- /dev/null +++ b/packages/shared/src/tagCharacterClass.ts @@ -0,0 +1,41 @@ +/** + * タグ名 (`#name`) として許容する文字の集合を 1 ヶ所に集約した定数。 + * 正規表現の文字クラス (`[...]`) の **中身だけ** を提供する。完成した正規表現 + * を共有しないのは、貼り付け検出 (client) と名前検証 (server) で + * フラグ・アンカー・先読みが異なるため。両者には同じ文字集合だけを揃えれば + * 十分で、用途依存の組み立ては各呼び出し側に任せる方がドリフトに強い。 + * + * Single source of truth for the character set allowed inside a tag name + * (`#name`). Exposes only the **inner contents** of a regex character class + * (`[...]`). The full regex is intentionally not shared because the + * paste-detection regex (client) and the name-validation regex (server) need + * different flags, anchors, and look-arounds. Sharing only the character set + * keeps drift-prone surface area minimal. + * + * 含まれる字種 / Included scripts: + * - 半角英数字: `A-Za-z0-9` + * - 区切り: アンダースコア `_`、ハイフン `-` + * - ひらがな (Hiragana, Unicode block U+3040..U+309F) + * - カタカナ (Katakana, Unicode block U+30A0..U+30FF) + * ひらがなとカタカナは別ブロックだが、`぀-ヿ` (U+3040..U+30FF) の単一範囲で + * 両ブロックを連続して覆える。 + * Hiragana and Katakana are distinct Unicode blocks but the single range + * `぀-ヿ` (U+3040..U+30FF) covers both contiguously. + * - CJK 統合漢字 + 拡張 A: U+3400..U+9FFF (`㐀-鿿`) + * CJK Unified Ideographs + Extension A. + * + * 同期義務 / Sync obligation: + * - 本ファイルを編集したら、`server/api/src/services/ydocRenameRewrite.ts` + * の `TAG_NAME_CHAR_CLASS_STRING` も一致させること。`server/api` はワーク + * スペース外(自前の `bun.lock` を持つ Railway ビルド)なのでこの定数を + * 直接 import できない。代わりに `src/lib/tagCharacterClassSync.test.ts` + * が両者の文字列一致を CI でチェックする。 + * + * When this file changes, also update + * `server/api/src/services/ydocRenameRewrite.ts`'s + * `TAG_NAME_CHAR_CLASS_STRING` to match. `server/api` lives outside the + * Bun workspace (its own `bun.lock` is consumed by Railway), so it cannot + * import this constant. `src/lib/tagCharacterClassSync.test.ts` enforces + * the equality in CI. + */ +export const TAG_NAME_CHAR_CLASS = "A-Za-z0-9_\\-぀-ヿ㐀-鿿"; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 00000000..e4bd6558 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 00000000..9ca71dba --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + root: import.meta.dirname, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/server/api/src/__tests__/services/titleRenamePropagationService.test.ts b/server/api/src/__tests__/services/titleRenamePropagationService.test.ts index 8bcaa7cb..2ad01037 100644 --- a/server/api/src/__tests__/services/titleRenamePropagationService.test.ts +++ b/server/api/src/__tests__/services/titleRenamePropagationService.test.ts @@ -15,14 +15,50 @@ import { propagateTitleRename } from "../../services/titleRenamePropagationServi * page_contents 行に入っているようなバイナリ Y.Doc を生成するヘルパー。 * Build an encoded Y.Doc blob shaped like a `page_contents.ydoc_state` row. */ -function makeYdocWithWikiLink(title: string): Buffer { +function makeYdocWithWikiLink(title: string, targetId?: string): Buffer { const doc = new Y.Doc(); const fragment = doc.getXmlFragment("default"); const paragraph = new Y.XmlElement("paragraph"); fragment.insert(0, [paragraph]); const text = new Y.XmlText(); paragraph.insert(0, [text]); - text.insert(0, title, { wikiLink: { title, exists: true, referenced: false } }); + const wikiLink: Record = { title, exists: true, referenced: false }; + if (targetId !== undefined) { + wikiLink.targetId = targetId; + } + text.insert(0, title, { wikiLink }); + return Buffer.from(Y.encodeStateAsUpdate(doc)); +} + +/** + * 同名タイトルの 2 つのリンクを並べた Y.Doc を作るヘルパー。`targetId` で + * どちらが renamedPage を指すかを明示する。issue #737 の重複タイトルケース + * を検証する。 + * + * Build a Y.Doc with two same-titled links discriminated by `targetId`. + * Used to verify the issue #737 scenario where a rename must touch only one + * of the two visually identical links. + */ +function makeYdocWithTwoSameTitleLinks( + title: string, + firstTargetId: string, + secondTargetId: string, +): Buffer { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const paragraph = new Y.XmlElement("paragraph"); + fragment.insert(0, [paragraph]); + const text = new Y.XmlText(); + paragraph.insert(0, [text]); + text.insert(0, title, { + wikiLink: { title, exists: true, referenced: false, targetId: firstTargetId }, + }); + // null を挟んで 2 つ目のマーク区間を独立させる(Yjs の format 継承を断つ)。 + // Insert with `null` to break Yjs' formatting inheritance between segments. + text.insert(text.length, " and ", { wikiLink: null }); + text.insert(text.length, title, { + wikiLink: { title, exists: true, referenced: false, targetId: secondTargetId }, + }); return Buffer.from(Y.encodeStateAsUpdate(doc)); } @@ -42,6 +78,33 @@ function decodeYdocWikiLinkTitle(buffer: Buffer): string | null { return null; } +/** + * `decodeYdocWikiLinkTitle` の同名リンク 2 つ版。`targetId` ごとにタイトルを + * 取り出し、どちらが書き換わったかを検証可能にする。 + * + * Sibling helper to `decodeYdocWikiLinkTitle` that returns titles keyed by + * `targetId` so tests can assert which of the two same-titled links was + * rewritten and which was preserved. + */ +function decodeYdocWikiLinkTitlesByTargetId(buffer: Buffer): Record { + const doc = new Y.Doc(); + Y.applyUpdate(doc, new Uint8Array(buffer)); + const fragment = doc.getXmlFragment("default"); + const paragraph = fragment.get(0); + if (!(paragraph instanceof Y.XmlElement)) return {}; + const text = paragraph.get(0); + if (!(text instanceof Y.XmlText)) return {}; + const delta = text.toDelta() as Array<{ insert: unknown; attributes?: Record }>; + const out: Record = {}; + for (const item of delta) { + const wl = item.attributes?.wikiLink as { title?: string; targetId?: string } | undefined; + if (wl?.title && wl.targetId) { + out[wl.targetId] = wl.title; + } + } + return out; +} + const PAGE_ID = "11111111-aaaa-bbbb-cccc-000000000001"; const SOURCE_PAGE_ID = "11111111-aaaa-bbbb-cccc-000000000002"; const OWNER_ID = "owner-user-1"; @@ -337,4 +400,57 @@ describe("propagateTitleRename", () => { // Ghost promotion path still ran (empty result here). / ゴースト昇格の経路は通る。 expect(result.ghostPromotionsCount).toBe(0); }); + + it("rewrites only the link whose targetId matches the renamed page (issue #737)", async () => { + // 重複タイトル下のリネーム: ソースページ X が `[[Foo]]` を 2 回参照する。 + // 1 つは renamedPage (PAGE_ID) を、もう 1 つは別ページ (OTHER_TARGET_ID) + // を `targetId` で指している。ID 一致の方だけを `[[Bar]]` に書き換え、 + // もう一方は `[[Foo]]` のまま残ることを検証する。issue #737 案 A の本質。 + // Same-title rename: source page X holds two `[[Foo]]` marks pointing to + // different pages via `targetId`. Only the mark whose `targetId` matches + // the renamed page should become `[[Bar]]`; the other must stay `[[Foo]]`. + // This is the core acceptance scenario for issue #737 (approach A). + const OTHER_TARGET_ID = "33333333-aaaa-bbbb-cccc-000000000003"; + const originalYdoc = makeYdocWithTwoSameTitleLinks("Foo", PAGE_ID, OTHER_TARGET_ID); + + const { db, chains } = createMockDb([ + [{ sourceId: SOURCE_PAGE_ID }], + [], // FOR UPDATE + [{ pageId: SOURCE_PAGE_ID, ydocState: originalYdoc, version: 1 }], + [{ version: 2 }], // UPDATE page_contents + [], // UPDATE pages + PERSONAL_SCOPE_ROW, + [], // ghost candidates (none) + ]); + const invalidate = vi.fn().mockResolvedValue(undefined); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(1); + expect(result.sourcePagesSucceeded).toBe(1); + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + expect(invalidate).toHaveBeenCalledTimes(1); + + // 書き戻された ydoc_state を読み取り、targetId ごとのタイトル分布を検証。 + // Decode the persisted ydoc_state and check titles by `targetId`. + const updateChains = chains.filter((c) => c.startMethod === "update"); + const pageContentsUpdate = updateChains.find((c) => { + const setArg = c.ops.find((op) => op.method === "set")?.args[0] as + | Record + | undefined; + return setArg && "ydocState" in setArg; + }); + const pcSetArg = pageContentsUpdate?.ops.find((op) => op.method === "set")?.args[0] as + | { ydocState: Buffer } + | undefined; + expect(pcSetArg?.ydocState).toBeInstanceOf(Buffer); + if (pcSetArg?.ydocState) { + const titles = decodeYdocWikiLinkTitlesByTargetId(pcSetArg.ydocState); + expect(titles[PAGE_ID]).toBe("Bar"); + expect(titles[OTHER_TARGET_ID]).toBe("Foo"); + } + }); }); diff --git a/server/api/src/__tests__/services/ydocRenameRewrite.test.ts b/server/api/src/__tests__/services/ydocRenameRewrite.test.ts index 932a689b..384ad02c 100644 --- a/server/api/src/__tests__/services/ydocRenameRewrite.test.ts +++ b/server/api/src/__tests__/services/ydocRenameRewrite.test.ts @@ -229,6 +229,279 @@ describe("rewriteTitleRefsInDoc", () => { }); }); + describe("targetId-based matching (issue #737)", () => { + const RENAMED_PAGE_ID = "11111111-aaaa-bbbb-cccc-000000000001"; + const OTHER_PAGE_ID = "22222222-aaaa-bbbb-cccc-000000000002"; + + it("rewrites a wikiLink mark whose targetId matches renamedPageId", () => { + // 案 A: ID 一致で書き換え対象を特定する。タイトル一致だけに頼らないことで、 + // 同名ページが別 ID で共存していても正しい一方だけを書き換えられる。 + // Approach A: id-matching pinpoints which mark to rewrite, so that + // same-titled pages with different ids do not interfere. + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false, targetId: RENAMED_PAGE_ID }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar", { renamedPageId: RENAMED_PAGE_ID }); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + const delta = text.toDelta(); + expect(delta[0]?.insert).toBe("Bar"); + expect(delta[0]?.attributes?.wikiLink).toEqual({ + title: "Bar", + exists: true, + referenced: false, + targetId: RENAMED_PAGE_ID, + }); + }); + + it("does NOT rewrite a wikiLink mark whose title matches but targetId points elsewhere", () => { + // 同名ページの誤書き換え (issue #737) を防ぐ核心ケース。タイトルは一致 + // するが `targetId` が別ページを指しているため、書き換えてはいけない。 + // The exact issue #737 scenario: same title but a different `targetId`. + // The mark refers to a different page and must stay untouched. + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false, targetId: OTHER_PAGE_ID }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar", { renamedPageId: RENAMED_PAGE_ID }); + + expect(result.wikiLinkMarksUpdated).toBe(0); + expect(result.wikiLinkTextUpdated).toBe(0); + expect(text.toDelta()).toEqual([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false, targetId: OTHER_PAGE_ID }, + }, + }, + ]); + }); + + it("falls back to title matching for marks without targetId (lazy migration)", () => { + // 旧データ・未解決マークでは `targetId` が無いので、従来通りタイトル一致 + // で書き換える。これにより既存 Y.Doc を移行せずに済む。 + // Lazy migration: marks without `targetId` keep matching by title so + // existing Y.Docs do not require an upfront migration pass. + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar", { renamedPageId: RENAMED_PAGE_ID }); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + const delta = text.toDelta(); + expect(delta[0]?.insert).toBe("Bar"); + expect(delta[0]?.attributes?.wikiLink).toMatchObject({ title: "Bar" }); + }); + + it("treats empty-string targetId as missing and falls back to title match", () => { + // `data-target-id=""` が parseHTML されたケースなど、空文字 `targetId` を + // 「ID 無し」と等価に扱う。これも lazy migration 経路に倒す。 + // Empty-string `targetId` (e.g. parsed from a stray empty data-attr) + // is treated as id-less so it lands on the legacy fallback path. + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false, targetId: "" }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar", { renamedPageId: RENAMED_PAGE_ID }); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(plainText(text)).toBe("Bar"); + }); + + it("rewrites tag marks by targetId match and skips same-name tags pointing elsewhere", () => { + // タグマークも同様。同名タグが別ページに紐付いている場合は触らない。 + // Tag marks honour the same id-strict rule: same name on a different + // page id must not be rewritten. + const { doc, text } = buildDocWithParagraph([ + { + insert: "foo", + attributes: { + tag: { name: "foo", exists: true, referenced: false, targetId: RENAMED_PAGE_ID }, + }, + }, + { insert: " ", attributes: { tag: null, wikiLink: null } }, + { + insert: "foo", + attributes: { + tag: { name: "foo", exists: true, referenced: false, targetId: OTHER_PAGE_ID }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "foo", "bar", { renamedPageId: RENAMED_PAGE_ID }); + + expect(result.tagMarksUpdated).toBe(1); + expect(result.tagTextUpdated).toBe(1); + // 1 つ目の tag は `bar` に書き換わり、2 つ目は `foo` のまま残る。 + // First tag becomes `bar`; the second one stays `foo`. + expect(plainText(text)).toBe("bar foo"); + }); + + it("skips marks that carry a targetId when renamedPageId is omitted (cannot verify)", () => { + // `renamedPageId` を渡さない呼び出しで、マークに `targetId` がある場合は + // 同名ページとの判別ができないため安全側に倒して書き換えない。 + // `targetId` 無しのマークだけが従来通りタイトル一致でフォールバックする。 + // Without `renamedPageId` we cannot verify a mark's `targetId`, so the + // safe default is to skip rewriting it (avoids the same-title bug + // regressing for any legacy caller). Marks without `targetId` still + // fall back to title matching as before. + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false, targetId: OTHER_PAGE_ID }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(0); + expect(plainText(text)).toBe("Foo"); + }); + + it("keeps rewriting id-less marks by title even when renamedPageId is omitted", () => { + // `renamedPageId` 無しでも、`targetId` を持たないマークは従来通り + // タイトル一致で書き換える(後方互換)。 + // Marks without `targetId` continue to use title-fallback rewriting + // even when `renamedPageId` is omitted (backward compat). + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { + wikiLink: { title: "Foo", exists: true, referenced: false }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(plainText(text)).toBe("Bar"); + }); + + // タグマーク版の fallback 挙動パリティ。同じ 2 ケースを `tag` マーク側でも + // 保証する(CodeRabbit レビュー指摘)。`tag` 単独でリグレッションが + // 入らないようテスト面でも `wikiLink` と同等の網を張る。 + // Tag-mark parity for the two `renamedPageId`-omitted fallback branches + // (CodeRabbit review). Mirrors the wikiLink coverage so a tag-only + // regression cannot slip past the suite. + it("skips tag marks that carry a targetId when renamedPageId is omitted", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "foo", + attributes: { + tag: { name: "foo", exists: true, referenced: false, targetId: OTHER_PAGE_ID }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "foo", "bar"); + + expect(result.tagMarksUpdated).toBe(0); + expect(result.tagTextUpdated).toBe(0); + expect(plainText(text)).toBe("foo"); + }); + + it("keeps rewriting id-less tag marks by name even when renamedPageId is omitted", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "foo", + attributes: { + tag: { name: "foo", exists: true, referenced: false }, + }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "foo", "bar"); + + expect(result.tagMarksUpdated).toBe(1); + expect(result.tagTextUpdated).toBe(1); + expect(plainText(text)).toBe("bar"); + }); + }); + + describe("Backward-compat for legacy 4-arg fragmentName form", () => { + // 旧 API では第 4 引数が `fragmentName: string` だった。文字列をそのまま + // 受け取って `{ fragmentName }` として解釈できることを固定する + // (CodeRabbit レビュー指摘)。これにより、issue #737 以前のスナップショット + // から拾い上げられた呼び出し元が静かに既定フラグメントへ書き換わる事故 + // を防ぐ。 + // Pre-issue-#737 callers passed the fourth arg as a `fragmentName` + // string. Lock in that the function still accepts that shape so legacy + // callers do not silently retarget the default fragment (CodeRabbit). + it("treats a string fourth argument as fragmentName", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("custom"); + const paragraph = new Y.XmlElement("paragraph"); + fragment.insert(0, [paragraph]); + const text = new Y.XmlText(); + paragraph.insert(0, [text]); + text.insert(0, "Foo", { wikiLink: { title: "Foo", exists: true, referenced: false } }); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar", "custom"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(plainText(text)).toBe("Bar"); + }); + + it("does not touch the default fragment when only `custom` is asked for", () => { + // 旧 API の呼び出しがオプション形へ自動変換され、誤って default フラグ + // メントを書き換えてしまわないことを担保する。 + // Guard against a regression where the legacy form is parsed as + // options and silently rewrites the default fragment instead. + const doc = new Y.Doc(); + const defaultFragment = doc.getXmlFragment("default"); + const defaultPara = new Y.XmlElement("paragraph"); + defaultFragment.insert(0, [defaultPara]); + const defaultText = new Y.XmlText(); + defaultPara.insert(0, [defaultText]); + defaultText.insert(0, "Foo", { + wikiLink: { title: "Foo", exists: true, referenced: false }, + }); + + const customFragment = doc.getXmlFragment("custom"); + const customPara = new Y.XmlElement("paragraph"); + customFragment.insert(0, [customPara]); + const customText = new Y.XmlText(); + customPara.insert(0, [customText]); + customText.insert(0, "Foo", { + wikiLink: { title: "Foo", exists: true, referenced: false }, + }); + + rewriteTitleRefsInDoc(doc, "Foo", "Bar", "custom"); + + // 既定フラグメントは触らず、`custom` のみが書き換わる。 + // The default fragment is left untouched; only `custom` rewrites. + expect(plainText(defaultText)).toBe("Foo"); + expect(plainText(customText)).toBe("Bar"); + }); + }); + describe("Guards and edge cases", () => { it("is a no-op when oldTitle and newTitle normalize to the same value", () => { const { doc, text } = buildDocWithParagraph([ diff --git a/server/api/src/services/titleRenamePropagationService.ts b/server/api/src/services/titleRenamePropagationService.ts index 0ee91a88..ef2c4314 100644 --- a/server/api/src/services/titleRenamePropagationService.ts +++ b/server/api/src/services/titleRenamePropagationService.ts @@ -119,6 +119,7 @@ function toBuffer(ydocState: unknown): Buffer | null { async function rewriteSourcePage( db: Database, sourcePageId: string, + renamedPageId: string, oldTitle: string, newTitle: string, ): Promise<{ changed: boolean; rewrite: RewriteResult }> { @@ -154,7 +155,13 @@ async function rewriteSourcePage( const doc = new Y.Doc(); Y.applyUpdate(doc, new Uint8Array(buffer)); - const rewrite = rewriteTitleRefsInDoc(doc, oldTitle, newTitle); + // `renamedPageId` を渡すことで `targetId` 属性付きマークは ID 一致のみで + // 書き換える(issue #737)。`targetId` が無い旧マークはタイトル一致で + // フォールバック書き換えされる。 + // Pass `renamedPageId` so marks carrying a `targetId` are rewritten only + // on id match (issue #737); legacy marks without `targetId` continue to + // use the title-only fallback (lazy migration). + const rewrite = rewriteTitleRefsInDoc(doc, oldTitle, newTitle, { renamedPageId }); const hasChanges = rewrite.wikiLinkMarksUpdated > 0 || rewrite.tagMarksUpdated > 0; if (!hasChanges) { return { changed: false, rewrite }; @@ -330,7 +337,13 @@ export async function propagateTitleRename( for (const sourceId of uniqueSourceIds) { result.sourcePagesAttempted += 1; try { - const { changed, rewrite } = await rewriteSourcePage(db, sourceId, trimmedOld, trimmedNew); + const { changed, rewrite } = await rewriteSourcePage( + db, + sourceId, + renamedPageId, + trimmedOld, + trimmedNew, + ); result.sourcePagesSucceeded += 1; result.wikiLinkMarksUpdated += rewrite.wikiLinkMarksUpdated; result.wikiLinkTextUpdated += rewrite.wikiLinkTextUpdated; diff --git a/server/api/src/services/ydocRenameRewrite.ts b/server/api/src/services/ydocRenameRewrite.ts index 36424fbb..b63d40fa 100644 --- a/server/api/src/services/ydocRenameRewrite.ts +++ b/server/api/src/services/ydocRenameRewrite.ts @@ -5,18 +5,31 @@ * Pure helper that rewrites WikiLink and tag marks inside a Y.Doc when the * referenced page title changes. Called from `titleRenamePropagationService`. * - * 設計方針 / Design notes (issue #726): - * - 対象: `wikiLink` マーク(`attrs.title`)と `tag` マーク(`attrs.name`)。 - * - マッチは小文字・前後スペース除去で大文字小文字・空白差異を吸収する。 - * - セグメントのテキストが旧タイトル(正規化済み)と一致する場合にのみ - * テキストを書き換える。一致しない場合は「手動で編集された」扱いで - * テキストはそのままにし、マーク属性だけ更新する。 + * 設計方針 / Design notes (issue #726, updated for issue #737): + * - 対象: `wikiLink` マーク(`attrs.title` / `attrs.targetId`)と `tag` マーク + * (`attrs.name` / `attrs.targetId`)。 + * - マッチング優先順位: + * 1. マークが `targetId` 属性(UUID 文字列)を持つ場合は **id 一致のみ** で + * 書き換える(`renamedPageId` と一致したときに書き換え)。これにより + * 同名ページが共存していても誤書き換えを防ぐ(issue #737)。 + * 2. `targetId` を持たない(旧データ・未解決状態)場合はタイトル/名前 + * 文字列の一致でフォールバック書き換えを行う(既存挙動 / lazy migration)。 + * - セグメントのテキストは旧タイトル(正規化済み)と一致する場合にのみ書き換える。 + * 一致しない場合は「手動で編集された」扱いでテキストはそのまま、マーク属性だけ + * 更新する。 * - タグ書き換えは新タイトルがタグ名として有効な文字集合(`tagUtils.ts` の * `TAG_PASTE_REGEX` に準拠)のときだけ行う。スペースや無効文字を含む * タイトルへ追従するとタグが壊れるため。 * - * - Targets `wikiLink` marks (keyed on `attrs.title`) and `tag` marks - * (keyed on `attrs.name`). + * - Targets `wikiLink` marks (`attrs.title` / `attrs.targetId`) and `tag` + * marks (`attrs.name` / `attrs.targetId`). + * - Match precedence: + * 1. When the mark carries a `targetId` (UUID string), only rewrite when + * `targetId === renamedPageId`. This avoids same-title pages being + * rewritten in lockstep (issue #737). + * 2. Otherwise (no `targetId` — pre-issue-#737 data or unresolved marks), + * fall back to title/name string matching (legacy behaviour, lazy + * migration so older docs still rewrite). * - Matching is case/whitespace insensitive to line up with `wikiLinkUtils` * / `tagUtils` client-side normalization. * - Segment text is replaced only when it matches the old title after @@ -45,15 +58,35 @@ export interface RewriteResult { } /** - * タグ名として許容する文字集合。`src/lib/tagUtils.ts` の `TAG_PASTE_REGEX` - * と同じ字種(英数字・アンダースコア・ハイフン + ひらがな/カタカナ/CJK) - * を想定している。`TAG_PASTE_REGEX` が変わったらここも更新する必要がある。 + * タグ名として許容する文字集合(正規表現の文字クラス内側のみ)。クライアント + * 側の `@zedi/shared/tagCharacterClass` (`TAG_NAME_CHAR_CLASS`) と同一文字列で + * なければならない。`server/api` はワークスペース外で独自の `bun.lock` を持つ + * (Railway 単一 build context) ため `@zedi/shared` を直接 import できない。 + * 代わりに `src/lib/tagCharacterClassSync.test.ts` がクライアント側の vitest + * で本ファイルを `fs.readFileSync` し、文字列一致を CI で検証する。本定数を + * 編集する場合は `packages/shared/src/tagCharacterClass.ts` も同時に更新する + * こと。 * - * Allowed characters for a tag name. Mirrors `TAG_PASTE_REGEX` in - * `src/lib/tagUtils.ts` (alphanumerics, underscore, hyphen, plus hiragana / - * katakana / CJK). Keep the two in sync if the client-side regex changes. + * Allowed characters for a tag name (inner contents of a regex character + * class). MUST stay byte-equal to `TAG_NAME_CHAR_CLASS` in + * `@zedi/shared/tagCharacterClass`. `server/api` is intentionally outside the + * Bun workspace (its own `bun.lock` is the Railway build context), so it + * cannot import `@zedi/shared` directly. The client-side vitest file + * `src/lib/tagCharacterClassSync.test.ts` reads this file via + * `fs.readFileSync` and asserts both literals match in CI. When editing this + * constant, update `packages/shared/src/tagCharacterClass.ts` in lockstep. */ -const VALID_TAG_NAME_REGEX = /^[A-Za-z0-9_\-぀-ヿ㐀-鿿]+$/; +export const TAG_NAME_CHAR_CLASS_STRING = "A-Za-z0-9_\\-぀-ヿ㐀-鿿"; + +// ReDoS 安全 / ReDoS-safe: +// `TAG_NAME_CHAR_CLASS_STRING` はハードコードされた定数(外部入力ではない)。 +// パターンは `^[...]+$` のみで、ネストした量指定子・代替も無いため +// 入力長に対して線形時間。静的解析ツールが警告を出した場合は誤検知。 +// `TAG_NAME_CHAR_CLASS_STRING` is a hardcoded constant (never user input). +// The pattern is a single anchored character class with one quantifier and +// no nested quantifiers / alternations, so matching is linear in input +// length. Static-analysis ReDoS warnings on this regex are false positives. +const VALID_TAG_NAME_REGEX = new RegExp(`^[${TAG_NAME_CHAR_CLASS_STRING}]+$`); function normalizeTitle(value: string): string { return value.toLowerCase().trim(); @@ -92,20 +125,70 @@ interface SegmentPlan { tagMark: Record | null; } +/** + * `targetId` 属性が UUID 文字列として有効か判定するヘルパー。空文字や非文字列、 + * 純粋な空白は「id 無し」として扱い、タイトル一致 fallback の対象にする。 + * + * Decide whether `targetId` is a usable UUID string. Empty / non-string / + * whitespace-only values fall back to the legacy title-matching path so + * pre-issue-#737 marks still propagate. + */ +function isUsableTargetId(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +/** + * 旧データ/未解決マークかを判定する。`targetId` を持たない場合のみ + * タイトル一致 fallback を許可する(issue #737 lazy migration)。 + * + * Detect a legacy / unresolved mark. Only marks without a usable `targetId` + * are allowed to match by title (issue #737 lazy migration). + */ +function shouldUseTitleFallback(mark: Record): boolean { + return !isUsableTargetId(mark.targetId); +} + function planSegment( attributes: Record, normalizedOld: string, allowTagRewrite: boolean, + renamedPageId: string | null, ): SegmentPlan { const wikiLinkMark = isPlainObject(attributes.wikiLink) ? attributes.wikiLink : null; const tagMark = isPlainObject(attributes.tag) ? attributes.tag : null; const wikiTitle = wikiLinkMark ? extractStringAttr(wikiLinkMark.title) : null; const tagName = tagMark ? extractStringAttr(tagMark.name) : null; + + // `targetId` を持つマークは ID 一致のみで判定する。同名ページの誤書き換え + // (issue #737) を防ぐため、`targetId` が renamedPageId と異なる場合は + // 例えタイトルが一致していても書き換えない。`targetId` が無いマークだけが + // タイトル一致 fallback の対象(lazy migration)。 + // Marks carrying a `targetId` are matched strictly by id. Even if the + // title also matches, a non-matching id means the mark targets a different + // (same-titled) page and must not be rewritten. Only id-less marks fall + // back to title matching (legacy / lazy migration). + const wikiHasFallback = wikiLinkMark !== null && shouldUseTitleFallback(wikiLinkMark); + const tagHasFallback = tagMark !== null && shouldUseTitleFallback(tagMark); + + const wikiIdMatches = + wikiLinkMark !== null && + renamedPageId !== null && + isUsableTargetId(wikiLinkMark.targetId) && + wikiLinkMark.targetId === renamedPageId; + const tagIdMatches = + tagMark !== null && + renamedPageId !== null && + isUsableTargetId(tagMark.targetId) && + tagMark.targetId === renamedPageId; + + const wikiTitleMatches = wikiTitle !== null && normalizeTitle(wikiTitle) === normalizedOld; + const tagTitleMatches = tagName !== null && normalizeTitle(tagName) === normalizedOld; + return { wikiLinkMark, tagMark, - wikiMatches: wikiTitle !== null && normalizeTitle(wikiTitle) === normalizedOld, - tagMatches: tagName !== null && allowTagRewrite && normalizeTitle(tagName) === normalizedOld, + wikiMatches: wikiIdMatches || (wikiHasFallback && wikiTitleMatches), + tagMatches: allowTagRewrite && (tagIdMatches || (tagHasFallback && tagTitleMatches)), }; } @@ -135,6 +218,7 @@ function rewriteText( oldTitle: string, newTitle: string, allowTagRewrite: boolean, + renamedPageId: string | null, result: RewriteResult, ): void { const delta = text.toDelta() as Array; @@ -164,7 +248,7 @@ function rewriteText( continue; } - const plan = planSegment(attributes, normalizedOld, allowTagRewrite); + const plan = planSegment(attributes, normalizedOld, allowTagRewrite, renamedPageId); if (!plan.wikiMatches && !plan.tagMatches) { offset += length; continue; @@ -210,6 +294,7 @@ function walk( oldTitle: string, newTitle: string, allowTagRewrite: boolean, + renamedPageId: string | null, result: RewriteResult, ): void { // `node.get(i)` は Yjs の連結リストを頭から辿るため O(i)。インデックス @@ -221,36 +306,100 @@ function walk( const children = node.toArray() as XmlNode[]; for (const child of children) { if (child instanceof Y.XmlText) { - rewriteText(child, oldTitle, newTitle, allowTagRewrite, result); + rewriteText(child, oldTitle, newTitle, allowTagRewrite, renamedPageId, result); } else if (child instanceof Y.XmlElement) { - walk(child, oldTitle, newTitle, allowTagRewrite, result); + walk(child, oldTitle, newTitle, allowTagRewrite, renamedPageId, result); } // Y.XmlHook は Tiptap のスキーマで通常使わないためスキップする。 // Y.XmlHook is not used by Tiptap's default schema, so skip it. } } +/** + * `rewriteTitleRefsInDoc` のオプション。`renamedPageId` を渡せるようにし、 + * `targetId` 属性ベースの厳密マッチを有効化する(issue #737)。 + * + * Options for `rewriteTitleRefsInDoc`. `renamedPageId` enables strict + * `targetId`-based matching introduced for issue #737. + */ +export interface RewriteTitleRefsOptions { + /** + * リネーム対象ページの UUID。マークが `targetId` 属性を持つ場合は ID 一致で + * のみ書き換える(同名ページの誤書き換えを防ぐ)。`null` / 省略時は、 + * `targetId` を **持たない** マークだけがタイトル一致 fallback で書き換わる。 + * 一方、`targetId` を持つマークは検証手段が無いため安全側に倒して **書き換え + * ない**(誤書き換え防止 / issue #737)。 + * + * UUID of the renamed page. When provided, marks carrying a `targetId` + * attribute are rewritten only on id match — preventing same-title pages + * from being rewritten in lockstep. When `null`/omitted, only marks + * **without** a usable `targetId` fall back to legacy title-only matching; + * marks that already carry a `targetId` are **skipped** because their + * target cannot be verified safely (issue #737). + */ + renamedPageId?: string | null; + /** + * 対象 XmlFragment 名。Tiptap の既定値 `"default"`。テスト用途以外では + * 通常変更しない。 + * + * Target XmlFragment name; Tiptap default `"default"`. Tests rarely need + * to override this. + */ + fragmentName?: string; +} + /** * `doc` 内の WikiLink / タグマークについて、旧タイトル `oldTitle` を参照 * しているものを新タイトル `newTitle` へ書き換える。テキストノードは * セグメントのテキストが旧タイトルと一致する場合のみ書き換える。 * + * `options.renamedPageId` を指定すると、`targetId` 属性を持つマークは ID + * 一致でのみ書き換える(同名ページの誤書き換えを防ぐ・issue #737)。 + * `targetId` を持たない既存マークは従来通りタイトル一致でフォールバックする + * (lazy migration)。 + * * Rewrite WikiLink and tag marks in `doc` whose target matches `oldTitle` * so they point at `newTitle`. Segment text is only rewritten when it * matches the old title (so user-edited link text is preserved). * + * Passing `options.renamedPageId` switches marks that carry a `targetId` + * attribute to id-strict matching (preventing same-title-page collisions — + * issue #737). Marks without a `targetId` continue to match by title + * (legacy / lazy migration). + * + * 後方互換 / Backward compatibility: + * 第 4 引数は旧 API では `fragmentName: string` だった。文字列を渡された場合は + * `{ fragmentName }` として解釈し、issue #737 以前の呼び出し元が静かに既定 + * フラグメントへ書き換わってしまうのを防ぐ。 + * The fourth argument used to be a `fragmentName` string. When a string is + * passed it is interpreted as `{ fragmentName }`, so pre-issue-#737 callers + * are not silently retargeted at the default fragment. + * * @param doc - 書き換え対象の Y.Doc / the Y.Doc to mutate. * @param oldTitle - 旧ページタイトル / previous page title. * @param newTitle - 新ページタイトル / new page title. - * @param fragmentName - 対象 XmlFragment 名(デフォルト `"default"`)/ fragment name (default `"default"`). + * @param optionsOrFragmentName - 任意オプション、または旧 API の fragmentName + * 文字列 / either the new options object or the legacy `fragmentName` + * string (kept for backward compatibility). * @returns 書き換え件数 / counts of what was rewritten. */ export function rewriteTitleRefsInDoc( doc: Y.Doc, oldTitle: string, newTitle: string, - fragmentName = "default", + optionsOrFragmentName: RewriteTitleRefsOptions | string = {}, ): RewriteResult { + // 旧 4-arg 形式 (`fragmentName` 文字列) を `{ fragmentName }` に正規化する。 + // 文字列をそのまま `options` として読むと `"foo".fragmentName` が undefined + // になり、既定フラグメントを書き換えてしまう静かな破壊が発生する。 + // Normalize the legacy 4-arg form (`fragmentName` string) into the options + // shape. Reading a raw string as `options` would silently retarget the + // default fragment because `"foo".fragmentName === undefined`. + const options: RewriteTitleRefsOptions = + typeof optionsOrFragmentName === "string" + ? { fragmentName: optionsOrFragmentName } + : optionsOrFragmentName; + const result: RewriteResult = { wikiLinkMarksUpdated: 0, wikiLinkTextUpdated: 0, @@ -262,10 +411,12 @@ export function rewriteTitleRefsInDoc( if (normalizeTitle(oldTitle) === normalizeTitle(newTitle)) return result; const allowTagRewrite = VALID_TAG_NAME_REGEX.test(newTitle); + const fragmentName = options.fragmentName ?? "default"; + const renamedPageId = isUsableTargetId(options.renamedPageId) ? options.renamedPageId : null; const fragment = doc.getXmlFragment(fragmentName); doc.transact(() => { - walk(fragment, oldTitle, newTitle, allowTagRewrite, result); + walk(fragment, oldTitle, newTitle, allowTagRewrite, renamedPageId, result); }, "rename-propagation"); return result; diff --git a/src/components/editor/TiptapEditor/EditorBubbleMenu.test.tsx b/src/components/editor/TiptapEditor/EditorBubbleMenu.test.tsx index 256423d6..fb1b4b54 100644 --- a/src/components/editor/TiptapEditor/EditorBubbleMenu.test.tsx +++ b/src/components/editor/TiptapEditor/EditorBubbleMenu.test.tsx @@ -47,6 +47,9 @@ vi.mock("@/hooks/usePageQueries", () => ({ checkExistence: vi.fn().mockResolvedValue({ pageTitles: new Set(), referencedTitles: new Set(), + // issue #737: 新フィールド `pageTitleToId` の契約に追従。 + // Match the issue #737 contract by returning an empty map. + pageTitleToId: new Map(), }), }) as unknown, })); diff --git a/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.test.ts b/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.test.ts index aa273667..f20a5148 100644 --- a/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.test.ts +++ b/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.test.ts @@ -49,7 +49,15 @@ function createMockEditor(options: { describe("useBubbleMenuWikiLink", () => { beforeEach(() => { vi.clearAllMocks(); - mockCheckExistence.mockResolvedValue({ pageTitles: new Set(), referencedTitles: new Set() }); + // issue #737: 既定モックは `pageTitleToId` を空 Map で返す。個別テストが + // resolved 経路を試したい場合は `mockResolvedValue` を上書きする。 + // Default mock returns an empty `pageTitleToId` (issue #737); tests that + // exercise the resolved branch override `mockResolvedValue` directly. + mockCheckExistence.mockResolvedValue({ + pageTitles: new Set(), + referencedTitles: new Set(), + pageTitleToId: new Map(), + }); }); it("returns isWikiLinkSelection false when editor is not in wikiLink", () => { @@ -99,7 +107,7 @@ describe("useBubbleMenuWikiLink", () => { marks: [ { type: "wikiLink", - attrs: { title: "New Page", exists: false, referenced: false }, + attrs: { title: "New Page", exists: false, referenced: false, targetId: null }, }, ], text: "[[New Page]]", @@ -108,10 +116,13 @@ describe("useBubbleMenuWikiLink", () => { expect(editor.chainReturn.run).toHaveBeenCalled(); }); - it("convertToWikiLink uses exists and referenced from checkExistence", async () => { + it("convertToWikiLink uses exists, referenced, and targetId from checkExistence", async () => { + // issue #737: 解決済みターゲットの id を `targetId` 属性に埋める。 + // Resolved target id is written into the `targetId` attribute (issue #737). mockCheckExistence.mockResolvedValue({ pageTitles: new Set(["existing page"]), referencedTitles: new Set(["existing page"]), + pageTitleToId: new Map([["existing page", "page-existing-id"]]), }); const editor = createMockEditor({ textBetween: "Existing Page" }); const { result } = renderHook(() => useBubbleMenuWikiLink({ editor, pageId: "p1" })); @@ -126,7 +137,12 @@ describe("useBubbleMenuWikiLink", () => { marks: [ { type: "wikiLink", - attrs: { title: "Existing Page", exists: true, referenced: true }, + attrs: { + title: "Existing Page", + exists: true, + referenced: true, + targetId: "page-existing-id", + }, }, ], text: "[[Existing Page]]", @@ -134,10 +150,11 @@ describe("useBubbleMenuWikiLink", () => { ]); }); - it("convertToWikiLink uses referenced true when only referencedTitles has the title", async () => { + it("convertToWikiLink uses referenced true and null targetId when only ghosted", async () => { mockCheckExistence.mockResolvedValue({ pageTitles: new Set(), referencedTitles: new Set(["ghost"]), + pageTitleToId: new Map(), }); const editor = createMockEditor({ textBetween: "Ghost" }); const { result } = renderHook(() => useBubbleMenuWikiLink({ editor, pageId: "p1" })); @@ -152,7 +169,9 @@ describe("useBubbleMenuWikiLink", () => { marks: [ { type: "wikiLink", - attrs: { title: "Ghost", exists: false, referenced: true }, + // `targetId` は未解決なので `null`(リネーム伝播はタイトル一致 fallback)。 + // Unresolved → `targetId: null` (rename uses title fallback). + attrs: { title: "Ghost", exists: false, referenced: true, targetId: null }, }, ], text: "[[Ghost]]", @@ -185,14 +204,15 @@ describe("useBubbleMenuWikiLink", () => { }); it("convertToWikiLink ignores second call until first resolves (re-entrancy guard)", async () => { - const deferred: { - resolve: (v: { pageTitles: Set; referencedTitles: Set }) => void; - } = { resolve: () => {} }; - const checkPromise = new Promise<{ pageTitles: Set; referencedTitles: Set }>( - (r) => { - deferred.resolve = r; - }, - ); + type CheckResult = { + pageTitles: Set; + referencedTitles: Set; + pageTitleToId: Map; + }; + const deferred: { resolve: (v: CheckResult) => void } = { resolve: () => {} }; + const checkPromise = new Promise((r) => { + deferred.resolve = r; + }); mockCheckExistence.mockReturnValue(checkPromise); const editor = createMockEditor({ textBetween: "Foo" }); @@ -210,7 +230,11 @@ describe("useBubbleMenuWikiLink", () => { if (firstCall === undefined) throw new Error("expected firstCall"); await act(async () => { - deferred.resolve({ pageTitles: new Set(), referencedTitles: new Set() }); + deferred.resolve({ + pageTitles: new Set(), + referencedTitles: new Set(), + pageTitleToId: new Map(), + }); await firstCall; }); diff --git a/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.ts b/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.ts index 28ef5ffe..09f8d33a 100644 --- a/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.ts +++ b/src/components/editor/TiptapEditor/useBubbleMenuWikiLink.ts @@ -2,12 +2,53 @@ import { useCallback, useRef, useState } from "react"; import type { Editor } from "@tiptap/core"; import { useWikiLinkExistsChecker } from "@/hooks/usePageQueries"; +/** + * `useBubbleMenuWikiLink` のオプション。バブルメニューから WikiLink への + * 変換を行うエディタと、解決スコープ判定に使う現在のページ id を受け取る。 + * + * Options for {@link useBubbleMenuWikiLink}. Provides the editor that will + * receive the WikiLink conversion and the current page id used to scope + * existence checks (and to exclude self-references). + */ export interface UseBubbleMenuWikiLinkOptions { editor: Editor; pageId?: string; } -export function useBubbleMenuWikiLink({ editor, pageId }: UseBubbleMenuWikiLinkOptions) { +/** + * `useBubbleMenuWikiLink` の戻り値。バブルメニューが必要とする状態と + * コマンドを公開契約として固定する(CodeRabbit レビュー指摘 / 戦略的 + * `any` 禁止に従い、戻り値の型を明示)。 + * + * Return shape of {@link useBubbleMenuWikiLink}. Fixes the public contract + * so downstream consumers stay stable as the payload evolves (per CodeRabbit + * review and the project's "no inferred public types" rule). + */ +export interface UseBubbleMenuWikiLinkResult { + /** True while the current selection sits inside a `wikiLink` mark. */ + isWikiLinkSelection: boolean; + /** Convert the current selection text into a `[[Title]]` WikiLink mark. */ + convertToWikiLink: () => Promise; + /** Remove the `wikiLink` mark from the current selection. */ + unsetWikiLink: () => void; + /** True while {@link UseBubbleMenuWikiLinkResult.convertToWikiLink} is running. */ + isConverting: boolean; +} + +/** + * バブルメニューの「WikiLink に変換」操作を提供するフック。選択中テキストを + * `[[Title]]` マークに変換し、解決済みのターゲットページがあれば `targetId` + * 属性も埋める(issue #737)。 + * + * Hook providing the bubble-menu "convert to WikiLink" action. Wraps the + * selection in a `[[Title]]` mark and, when the title resolves to an + * existing page, populates the `targetId` attribute (issue #737) so future + * rename propagation can disambiguate same-title pages by id. + */ +export function useBubbleMenuWikiLink({ + editor, + pageId, +}: UseBubbleMenuWikiLinkOptions): UseBubbleMenuWikiLinkResult { const { checkExistence } = useWikiLinkExistsChecker(); const [isConverting, setIsConverting] = useState(false); const convertingRef = useRef(false); @@ -18,7 +59,7 @@ export function useBubbleMenuWikiLink({ editor, pageId }: UseBubbleMenuWikiLinkO if (convertingRef.current) return; const { from, to } = editor.state.selection; - const text = editor.state.doc.textBetween(from, to, null, "\ufffc").trim(); + const text = editor.state.doc.textBetween(from, to, null, "").trim(); if (!text) return; convertingRef.current = true; @@ -26,17 +67,26 @@ export function useBubbleMenuWikiLink({ editor, pageId }: UseBubbleMenuWikiLinkO try { let exists = false; let referenced = false; + let targetId: string | null = null; if (pageId !== undefined) { - const { pageTitles, referencedTitles } = await checkExistence([text], pageId); + const { pageTitles, referencedTitles, pageTitleToId } = await checkExistence( + [text], + pageId, + ); const normalized = text.toLowerCase().trim(); exists = pageTitles.has(normalized); referenced = referencedTitles.has(normalized); + // 解決済みターゲット ID を埋めることで、後続のリネーム伝播が同名ページとの + // 衝突を ID 一致で回避できる(issue #737)。未解決時は `null` のまま残し、 + // 旧データと同様にタイトル一致 fallback を許す。 + // Populate the resolved target id so future rename propagation can + // disambiguate same-title pages by id (issue #737). Leaving it null + // preserves the legacy title-only fallback path for unresolved marks. + targetId = pageTitleToId.get(normalized) ?? null; } const { from: currentFrom, to: currentTo } = editor.state.selection; - const currentText = editor.state.doc - .textBetween(currentFrom, currentTo, null, "\ufffc") - .trim(); + const currentText = editor.state.doc.textBetween(currentFrom, currentTo, null, "").trim(); if (currentText !== text) return; editor @@ -49,7 +99,7 @@ export function useBubbleMenuWikiLink({ editor, pageId }: UseBubbleMenuWikiLinkO marks: [ { type: "wikiLink", - attrs: { title: text, exists, referenced }, + attrs: { title: text, exists, referenced, targetId }, }, ], text: `[[${text}]]`, diff --git a/src/components/editor/TiptapEditor/useEditorBubbleMenu.test.ts b/src/components/editor/TiptapEditor/useEditorBubbleMenu.test.ts index 2079d648..8ea6e0f9 100644 --- a/src/components/editor/TiptapEditor/useEditorBubbleMenu.test.ts +++ b/src/components/editor/TiptapEditor/useEditorBubbleMenu.test.ts @@ -9,6 +9,9 @@ vi.mock("@/hooks/usePageQueries", () => ({ checkExistence: vi.fn().mockResolvedValue({ pageTitles: new Set(), referencedTitles: new Set(), + // issue #737: `pageTitleToId` を返す契約に追従。 + // Match the issue #737 contract by returning an empty map. + pageTitleToId: new Map(), }), }) as unknown, })); diff --git a/src/components/editor/TiptapEditor/useTagStatusSync.test.tsx b/src/components/editor/TiptapEditor/useTagStatusSync.test.tsx index f05404bd..2a0f0462 100644 --- a/src/components/editor/TiptapEditor/useTagStatusSync.test.tsx +++ b/src/components/editor/TiptapEditor/useTagStatusSync.test.tsx @@ -17,14 +17,17 @@ vi.mock("@/hooks/usePageQueries", () => ({ useWikiLinkExistsChecker: vi.fn( (options?: { notePages?: MockNotePage[]; pageNoteId?: string | null }) => ({ checkExistence: vi.fn(async (titles: string[]) => { - const pageTitles = new Set( - (options?.pageNoteId ? (options.notePages ?? []) : []).map((page) => - page.title.toLowerCase().trim(), - ), + const inScope = options?.pageNoteId ? (options.notePages ?? []) : []; + const pageTitles = new Set(inScope.map((page) => page.title.toLowerCase().trim())); + // issue #737: `pageTitleToId` を返すモック契約。 + // Mock contract for issue #737. + const pageTitleToId = new Map( + inScope.map((page) => [page.title.toLowerCase().trim(), page.id]), ); return { pageTitles, referencedTitles: new Set(), + pageTitleToId, }; }), }), @@ -154,9 +157,12 @@ describe("useTagStatusSync (issue #725 Phase 1)", () => { await vi.advanceTimersByTimeAsync(150); }); + // issue #737: 解決時には `targetId` も同時に payload に乗る。 + // Resolution also writes `targetId` (issue #737). expect(chainApi.updateAttributes).toHaveBeenCalledWith("tag", { exists: true, referenced: false, + targetId: "page-beta", }); expect(tagMark.attrs.exists).toBe(true); expect(onChange).toHaveBeenCalledTimes(1); diff --git a/src/components/editor/TiptapEditor/useTagStatusSync.ts b/src/components/editor/TiptapEditor/useTagStatusSync.ts index 484a5c2d..a1c06f73 100644 Binary files a/src/components/editor/TiptapEditor/useTagStatusSync.ts and b/src/components/editor/TiptapEditor/useTagStatusSync.ts differ diff --git a/src/components/editor/TiptapEditor/useWikiLinkStatusSync.test.tsx b/src/components/editor/TiptapEditor/useWikiLinkStatusSync.test.tsx index ee9a856d..aed41954 100644 --- a/src/components/editor/TiptapEditor/useWikiLinkStatusSync.test.tsx +++ b/src/components/editor/TiptapEditor/useWikiLinkStatusSync.test.tsx @@ -17,14 +17,19 @@ vi.mock("@/hooks/usePageQueries", () => ({ useWikiLinkExistsChecker: vi.fn( (options?: { notePages?: MockNotePage[]; pageNoteId?: string | null }) => ({ checkExistence: vi.fn(async (titles: string[]) => { - const pageTitles = new Set( - (options?.pageNoteId ? (options.notePages ?? []) : []).map((page) => - page.title.toLowerCase().trim(), - ), + const inScope = options?.pageNoteId ? (options.notePages ?? []) : []; + const pageTitles = new Set(inScope.map((page) => page.title.toLowerCase().trim())); + // issue #737: `pageTitleToId` を返すモック契約。テストは「resolution は + // するが id 解決は不要」のシナリオを想定しているため空 Map を返しても十分。 + // Mock contract for issue #737. The current scenarios only assert + // exists/referenced changes, so an empty map is fine. + const pageTitleToId = new Map( + inScope.map((page) => [page.title.toLowerCase().trim(), page.id]), ); return { pageTitles, referencedTitles: new Set(), + pageTitleToId, }; }), }), @@ -143,9 +148,12 @@ describe("useWikiLinkStatusSync", () => { await vi.advanceTimersByTimeAsync(150); }); + // issue #737: 解決時には `targetId` も同時に payload に乗る。 + // Resolution also writes `targetId` (issue #737). expect(chainApi.updateAttributes).toHaveBeenCalledWith("wikiLink", { exists: true, referenced: false, + targetId: "page-beta", }); expect(wikiLinkMark.attrs.exists).toBe(true); expect(onChange).toHaveBeenCalledTimes(1); diff --git a/src/components/editor/TiptapEditor/useWikiLinkStatusSync.ts b/src/components/editor/TiptapEditor/useWikiLinkStatusSync.ts index 04652308..87dcb375 100644 --- a/src/components/editor/TiptapEditor/useWikiLinkStatusSync.ts +++ b/src/components/editor/TiptapEditor/useWikiLinkStatusSync.ts @@ -106,7 +106,7 @@ export function useWikiLinkStatusSync({ const titles = getUniqueWikiLinkTitles(currentWikiLinks); // ページの存在確認と参照状態を一括チェック - const { pageTitles, referencedTitles } = await checkExistence(titles, pageId); + const { pageTitles, referencedTitles, pageTitleToId } = await checkExistence(titles, pageId); // チェック準備ができていない場合はスキップ(次回再試行) if (pageTitles.size === 0 && titles.length > 0) { @@ -114,7 +114,7 @@ export function useWikiLinkStatusSync({ } // エディター内のWikiLinkマークを検索して更新が必要なものを収集 - const updates = collectWikiLinkUpdates(editor, pageTitles, referencedTitles); + const updates = collectWikiLinkUpdates(editor, pageTitles, referencedTitles, pageTitleToId); // チェック完了を記録 lastCheckedRef.current = { pageId, wikiLinkCount: currentCount, pageScopeSignature }; @@ -142,6 +142,15 @@ interface WikiLinkUpdate { to: number; exists: boolean; referenced: boolean; + /** + * 解決済みターゲットページの id。`null` のままにしておくと、後段で + * `updateAttributes` の payload から外して既存値を保持する(属性を消さない)。 + * + * Resolved target page id. When `null`, the value is omitted from the + * `updateAttributes` payload so an existing `targetId` is preserved + * (we never blank out a previously-resolved id). + */ + targetId: string | null; } /** @@ -151,6 +160,7 @@ function collectWikiLinkUpdates( editor: Editor, pageTitles: Set, referencedTitles: Set, + pageTitleToId: Map, ): WikiLinkUpdate[] { const updates: WikiLinkUpdate[] = []; const { doc } = editor.state; @@ -164,14 +174,27 @@ function collectWikiLinkUpdates( const normalizedTitle = (mark.attrs.title as string).toLowerCase().trim(); const newExists = pageTitles.has(normalizedTitle); const newReferenced = referencedTitles.has(normalizedTitle); + // 解決済みなら id を埋める (issue #737)。未解決時は `null` を返すと + // 適用フェーズで属性ペイロードから外し、既存の `targetId` を温存する。 + // Populate `targetId` when the link resolves (issue #737). A `null` + // means "don't touch" — the apply step skips the field so a previously + // populated id is preserved through transient unresolved states. + const resolvedId = newExists ? (pageTitleToId.get(normalizedTitle) ?? null) : null; + const currentTargetId = typeof mark.attrs.targetId === "string" ? mark.attrs.targetId : null; + const targetIdChanged = resolvedId !== null && resolvedId !== currentTargetId; // ステータスが変わった場合のみ更新対象に追加 - if (mark.attrs.exists !== newExists || mark.attrs.referenced !== newReferenced) { + if ( + mark.attrs.exists !== newExists || + mark.attrs.referenced !== newReferenced || + targetIdChanged + ) { updates.push({ from: pos, to: pos + node.nodeSize, exists: newExists, referenced: newReferenced, + targetId: targetIdChanged ? resolvedId : null, }); } }); @@ -186,14 +209,23 @@ function collectWikiLinkUpdates( function applyWikiLinkUpdates(editor: Editor, updates: WikiLinkUpdate[]): void { // 位置がずれないよう逆順で適用 for (const update of updates.reverse()) { + const attrs: Record = { + exists: update.exists, + referenced: update.referenced, + }; + // `targetId` を渡すのは「新しく解決された」ときだけ。`null` のまま + // 上書きすると、解決状態が一時的に外れた瞬間に id を失ってしまう。 + // Only include `targetId` when we actually resolved a new id; passing + // `null` would clobber a previously-resolved id during transient + // unresolved windows (e.g. note-page list still loading). + if (update.targetId !== null) { + attrs.targetId = update.targetId; + } editor .chain() .setTextSelection({ from: update.from, to: update.to }) .extendMarkRange("wikiLink") - .updateAttributes("wikiLink", { - exists: update.exists, - referenced: update.referenced, - }) + .updateAttributes("wikiLink", attrs) .run(); } } diff --git a/src/components/editor/extensions/TagExtension.test.ts b/src/components/editor/extensions/TagExtension.test.ts index b3ba4757..b7f99e46 100644 --- a/src/components/editor/extensions/TagExtension.test.ts +++ b/src/components/editor/extensions/TagExtension.test.ts @@ -202,4 +202,67 @@ describe("Tag extension configuration", () => { expect(extension.config.renderHTML).toBeDefined(); expect(extension.config.addAttributes).toBeDefined(); }); + + describe("targetId attribute (issue #737)", () => { + // 重複タイトル下でリネームを ID 一致で識別するため、tag マークに `targetId` + // 属性を追加した(issue #737 / 案 A)。本テスト群は属性宣言が正しい既定値 + // と HTML ラウンドトリップを持つことを固定する。 + // Pin the schema for the new `targetId` attribute used by rename + // propagation to discriminate same-title pages (issue #737, approach A). + function getTargetIdSpec(): { + default: unknown; + parseHTML: (el: HTMLElement) => unknown; + renderHTML: (attrs: Record) => Record; + } { + const extension = Tag.configure({}); + const addAttributes = extension.config.addAttributes; + if (typeof addAttributes !== "function") { + throw new Error("addAttributes must be a function"); + } + const attrs = addAttributes.call({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(extension as any), + parent: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) as Record; + const targetId = attrs.targetId as ReturnType; + if (!targetId) throw new Error("targetId attribute missing"); + return targetId; + } + + it("declares a targetId attribute with default null", () => { + const spec = getTargetIdSpec(); + expect(spec.default).toBeNull(); + }); + + it("parses targetId from data-target-id on the rendered span", () => { + const spec = getTargetIdSpec(); + const el = document.createElement("span"); + el.setAttribute("data-target-id", "11111111-aaaa-bbbb-cccc-000000000001"); + expect(spec.parseHTML(el)).toBe("11111111-aaaa-bbbb-cccc-000000000001"); + + const empty = document.createElement("span"); + expect(spec.parseHTML(empty)).toBeNull(); + }); + + it("omits data-target-id when targetId is null or empty", () => { + // 属性が無いマーク(旧データや未解決状態)で `data-target-id=""` を出さない + // ことで、サーバ側 `rewriteTitleRefsInDoc` が「id が無い → タイトル fallback」 + // と判定できるようにする。 + // Pre-issue-#737 marks (and unresolved fresh pastes) must not emit a + // `data-target-id` attribute so the server-side rewriter sees them as + // id-less and falls back to title matching. + const spec = getTargetIdSpec(); + expect(spec.renderHTML({ targetId: null })).toEqual({}); + expect(spec.renderHTML({ targetId: "" })).toEqual({}); + expect(spec.renderHTML({})).toEqual({}); + }); + + it("emits data-target-id when targetId is a non-empty string", () => { + const spec = getTargetIdSpec(); + expect(spec.renderHTML({ targetId: "11111111-aaaa-bbbb-cccc-000000000001" })).toEqual({ + "data-target-id": "11111111-aaaa-bbbb-cccc-000000000001", + }); + }); + }); }); diff --git a/src/components/editor/extensions/TagExtension.ts b/src/components/editor/extensions/TagExtension.ts index a9276b6f..d5451191 100644 --- a/src/components/editor/extensions/TagExtension.ts +++ b/src/components/editor/extensions/TagExtension.ts @@ -1,5 +1,6 @@ import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { TAG_NAME_CHAR_CLASS } from "@zedi/shared/tagCharacterClass"; /** * Regex matching hashtag patterns `#name` in pasted text. @@ -9,8 +10,10 @@ import { Plugin, PluginKey } from "@tiptap/pm/state"; * Preconditions to match: * - `#` must not be preceded by a word character, `/`, or another `#` * (excludes `abc#tag`, URL fragments like `/page#anchor`, and `##`). - * - The name must consist of Latin letters/digits, underscore, hyphen, - * Hiragana, Katakana, or CJK Unified/Extension A characters. + * - The name must consist of characters in {@link TAG_NAME_CHAR_CLASS} + * (Latin letters/digits, underscore, hyphen, Hiragana, Katakana, CJK + * Unified/Extension A). The character class lives in `@zedi/shared` so + * the server's `VALID_TAG_NAME_REGEX` cannot drift from it. * - Trailing punctuation (`、。,.!?:;` 等) terminates the name. * * Tiptap's `markPasteRule` applies the mark to the *last* capture group and @@ -24,6 +27,8 @@ import { Plugin, PluginKey } from "@tiptap/pm/state"; * それ以外を削除する仕様(`WikiLinkExtension` と同様)。先頭の `#` を * マーク範囲に含めるため、敢えてキャプチャグループを使わず `match[0]` を * そのままマーク対象とし、タグ名は {@link extractTagName} で後付け抽出する。 + * 文字クラス本体は `@zedi/shared` の `TAG_NAME_CHAR_CLASS` を共有することで、 + * サーバ側 (`VALID_TAG_NAME_REGEX`) との二重定義によるドリフトを防ぐ。 * * Fine-grained exclusions (numeric-only, 6/8-char hex colors) are applied in * `getAttributes` via {@link isExcludedTagName} so reject reasons sit next to @@ -32,13 +37,13 @@ import { Plugin, PluginKey } from "@tiptap/pm/state"; * 数字のみ・6/8 桁純 hex のような細かな除外は {@link isExcludedTagName} で * 行い、理由とデータ形状をまとめて管理する。 */ -export const TAG_PASTE_REGEX = /(?({ "data-referenced": String(attributes.referenced), }), }, + /** + * 解決済みターゲットページの UUID。`useTagStatusSync` がリンク解決時に + * 埋め、リネーム伝播(issue #737 / `ydocRenameRewrite`)がタグ名文字列 + * ではなく ID 一致で対象を特定できるようにする。未解決や旧データでは + * `null` で、その場合の伝播は名前文字列でフォールバックする + * (後方互換のため)。 + * + * Resolved target page UUID. Populated by `useTagStatusSync` once the + * tag resolves to an existing page so rename propagation + * (issue #737 / `ydocRenameRewrite`) matches by id instead of by name + * string. `null` for unresolved or pre-issue-#737 marks; the rewriter + * falls back to name matching in that case (lazy migration). + */ + targetId: { + default: null, + parseHTML: (element) => element.getAttribute("data-target-id"), + renderHTML: (attributes) => { + const value = attributes.targetId; + if (typeof value !== "string" || value.length === 0) { + return {}; + } + return { "data-target-id": value }; + }, + }, }; }, @@ -197,7 +226,7 @@ export const Tag = Mark.create({ // the leading `#`) and reject when it hits an exclusion rule. const name = extractTagName(match[0] ?? ""); if (!name || isExcludedTagName(name)) return false; - return { name, exists: false, referenced: false }; + return { name, exists: false, referenced: false, targetId: null }; }, }), ]; diff --git a/src/components/editor/extensions/WikiLinkExtension.test.ts b/src/components/editor/extensions/WikiLinkExtension.test.ts index 6388d5bf..b5a8841f 100644 --- a/src/components/editor/extensions/WikiLinkExtension.test.ts +++ b/src/components/editor/extensions/WikiLinkExtension.test.ts @@ -90,5 +90,68 @@ describe("WikiLinkExtension paste rule", () => { expect(extension.config.renderHTML).toBeDefined(); expect(extension.config.addAttributes).toBeDefined(); }); + + describe("targetId attribute (issue #737)", () => { + // 重複タイトル下でリネームを ID 一致で識別するため、wikiLink マークに + // `targetId` 属性を追加した(issue #737 / 案 A)。本テスト群は属性宣言が + // 正しい既定値・HTML ラウンドトリップを持つことを固定する。 + // Pin the schema for the new `targetId` attribute used by rename + // propagation to discriminate same-title pages (issue #737, approach A). + function getTargetIdSpec(): { + default: unknown; + parseHTML: (el: HTMLElement) => unknown; + renderHTML: (attrs: Record) => Record; + } { + const extension = WikiLink.configure({}); + const addAttributes = extension.config.addAttributes; + if (typeof addAttributes !== "function") { + throw new Error("addAttributes must be a function"); + } + const attrs = addAttributes.call({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(extension as any), + parent: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) as Record; + const targetId = attrs.targetId as ReturnType; + if (!targetId) throw new Error("targetId attribute missing"); + return targetId; + } + + it("declares a targetId attribute with default null", () => { + const spec = getTargetIdSpec(); + expect(spec.default).toBeNull(); + }); + + it("parses targetId from data-target-id on the rendered span", () => { + const spec = getTargetIdSpec(); + const el = document.createElement("span"); + el.setAttribute("data-target-id", "11111111-aaaa-bbbb-cccc-000000000001"); + expect(spec.parseHTML(el)).toBe("11111111-aaaa-bbbb-cccc-000000000001"); + + const empty = document.createElement("span"); + expect(spec.parseHTML(empty)).toBeNull(); + }); + + it("omits data-target-id when targetId is null or empty", () => { + // 属性が無いマーク(旧データや未解決状態)で `data-target-id=""` を出さない + // ことで、サーバ側 `rewriteTitleRefsInDoc` が「id が無い → タイトル fallback」 + // と判定できるようにする。 + // Pre-issue-#737 marks (and unresolved fresh pastes) must not emit a + // `data-target-id` attribute so the server-side rewriter sees them as + // id-less and falls back to title matching. + const spec = getTargetIdSpec(); + expect(spec.renderHTML({ targetId: null })).toEqual({}); + expect(spec.renderHTML({ targetId: "" })).toEqual({}); + expect(spec.renderHTML({})).toEqual({}); + }); + + it("emits data-target-id when targetId is a non-empty string", () => { + const spec = getTargetIdSpec(); + expect(spec.renderHTML({ targetId: "11111111-aaaa-bbbb-cccc-000000000001" })).toEqual({ + "data-target-id": "11111111-aaaa-bbbb-cccc-000000000001", + }); + }); + }); }); }); diff --git a/src/components/editor/extensions/WikiLinkExtension.ts b/src/components/editor/extensions/WikiLinkExtension.ts index f7d7d61e..cd0f62b4 100644 --- a/src/components/editor/extensions/WikiLinkExtension.ts +++ b/src/components/editor/extensions/WikiLinkExtension.ts @@ -53,7 +53,11 @@ export interface WikiLinkOptions { declare module "@tiptap/core" { interface Commands { wikiLink: { - setWikiLink: (attributes: { title: string; exists: boolean }) => ReturnType; + setWikiLink: (attributes: { + title: string; + exists: boolean; + targetId?: string | null; + }) => ReturnType; unsetWikiLink: () => ReturnType; }; } @@ -98,6 +102,31 @@ export const WikiLink = Mark.create({ "data-referenced": String(attributes.referenced), }), }, + /** + * 解決済みターゲットページの UUID。リンクが既存ページに解決されたタイミング + * (`useWikiLinkStatusSync`) で埋められ、リネーム伝播(issue #737 / `ydocRenameRewrite`) + * がタイトル文字列ではなく ID 一致で対象を特定できるようにする。未解決の + * 段階や旧データでは `null` で、この場合の伝播はタイトル文字列にフォール + * バックする(後方互換のため)。 + * + * Resolved target page UUID. Filled in by `useWikiLinkStatusSync` once + * the link resolves to an existing page so rename propagation + * (issue #737 / `ydocRenameRewrite`) can match by id instead of by + * title string — preventing same-title pages from being rewritten in + * lockstep. `null` for unresolved or pre-issue-#737 marks; the + * rewriter falls back to title matching in that case. + */ + targetId: { + default: null, + parseHTML: (element) => element.getAttribute("data-target-id"), + renderHTML: (attributes) => { + const value = attributes.targetId; + if (typeof value !== "string" || value.length === 0) { + return {}; + } + return { "data-target-id": value }; + }, + }, }; }, @@ -141,7 +170,7 @@ export const WikiLink = Mark.create({ // match[0] is the full `[[Title]]` literal; extract only the title. const title = extractWikiLinkTitle(match[0] ?? ""); if (!title) return false; - return { title, exists: false, referenced: false }; + return { title, exists: false, referenced: false, targetId: null }; }, }), ]; diff --git a/src/hooks/usePageQueries.ts b/src/hooks/usePageQueries.ts index bf5ec6be..d1f94738 100644 --- a/src/hooks/usePageQueries.ts +++ b/src/hooks/usePageQueries.ts @@ -807,9 +807,25 @@ export function useWikiLinkExistsChecker(options: UseWikiLinkExistsCheckerOption ): Promise<{ pageTitles: Set; referencedTitles: Set; + /** + * 正規化済みタイトル → ターゲットページ id のマップ。同一スコープ内に + * 同名ページが複数あった場合は **最後に出現したページの id** が残る + * (Map への上書き)。`useWikiLinkStatusSync` / `useTagStatusSync` が + * `targetId` 属性を埋めるためだけに使う(issue #737 / 案 A)。 + * + * Normalized title → target page id map. With duplicate titles inside + * the same scope the **last write wins** (Map overwrite). Used by the + * status-sync hooks to populate the `targetId` attribute on resolved + * marks (issue #737, approach A). + */ + pageTitleToId: Map; }> => { if (!isLoaded || titles.length === 0) { - return { pageTitles: new Set(), referencedTitles: new Set() }; + return { + pageTitles: new Set(), + referencedTitles: new Set(), + pageTitleToId: new Map(), + }; } const repo = await getRepository(); @@ -821,15 +837,27 @@ export function useWikiLinkExistsChecker(options: UseWikiLinkExistsCheckerOption // Select the candidate source based on scope (issue #713 Phase 4). // If note-scope candidates have not loaded yet, return empty sets so // we do not mis-classify valid same-note links as missing on this pass. - let pageTitles: Set; - if (pageNoteId !== null) { - if (notePages === undefined) { - return { pageTitles: new Set(), referencedTitles: new Set() }; - } - pageTitles = new Set(notePages.map((p) => p.title.toLowerCase().trim())); - } else { - const pages = await repo.getPagesSummary(userId); - pageTitles = new Set(pages.map((p) => p.title.toLowerCase().trim())); + const sourcePages = pageNoteId !== null ? notePages : await repo.getPagesSummary(userId); + if (pageNoteId !== null && sourcePages === undefined) { + return { + pageTitles: new Set(), + referencedTitles: new Set(), + pageTitleToId: new Map(), + }; + } + + // 単一ループで `pageTitles` と `pageTitleToId` を構築する。`.map()` で + // Set を作ってから別ループで Map を埋める旧実装は冗長で、データに対する + // 走査が 2 回発生していた(Gemini レビュー指摘)。 + // Single pass populates both `pageTitles` and `pageTitleToId`. The + // earlier shape used `.map()` to seed the Set and a separate loop for + // the Map, walking the same data twice (Gemini review feedback). + const pageTitles = new Set(); + const pageTitleToId = new Map(); + for (const p of sourcePages ?? []) { + const normalized = p.title.toLowerCase().trim(); + pageTitles.add(normalized); + pageTitleToId.set(normalized, p.id); } // Get ghost links to check referenced status. ノートスコープのゴースト @@ -862,7 +890,7 @@ export function useWikiLinkExistsChecker(options: UseWikiLinkExistsCheckerOption } } - return { pageTitles, referencedTitles }; + return { pageTitles, referencedTitles, pageTitleToId }; }, [getRepository, userId, isLoaded, pageNoteId, notePages], ); diff --git a/src/lib/tagCharacterClassSync.test.ts b/src/lib/tagCharacterClassSync.test.ts new file mode 100644 index 00000000..4ee40ce7 --- /dev/null +++ b/src/lib/tagCharacterClassSync.test.ts @@ -0,0 +1,56 @@ +/** + * `@zedi/shared` の `TAG_NAME_CHAR_CLASS` と、`server/api` 側で同じ文字列を + * 二重定義している `TAG_NAME_CHAR_CLASS_STRING` がドリフトしていないことを + * CI で保証するテスト。 + * + * `server/api` はルートの Bun workspace から意図的に外れており(Railway は + * `server/api/` 自体を build context にする)、`@zedi/shared` を直接 import + * することができない。そのためサーバ側にも同一文字列を持たせ、本テストが + * クライアント側の vitest で両者の一致を検証する。文字クラスを更新する際は + * `packages/shared/src/tagCharacterClass.ts` と + * `server/api/src/services/ydocRenameRewrite.ts` を必ず同時に書き換える。 + * + * Drift detector that fails CI when `@zedi/shared`'s `TAG_NAME_CHAR_CLASS` + * and the server-side duplicate `TAG_NAME_CHAR_CLASS_STRING` in + * `server/api/src/services/ydocRenameRewrite.ts` disagree. `server/api` + * intentionally lives outside the Bun workspace (Railway uses `server/api/` + * as the build context), so it cannot import `@zedi/shared`. This test reads + * the server file from disk and compares the literal value against the + * source-of-truth constant. + */ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, it, expect } from "vitest"; +import { TAG_NAME_CHAR_CLASS } from "@zedi/shared/tagCharacterClass"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe("TAG_NAME_CHAR_CLASS sync between @zedi/shared and server/api", () => { + it("server/api/src/services/ydocRenameRewrite.ts mirrors the shared constant", () => { + const serverFilePath = resolve(__dirname, "../../server/api/src/services/ydocRenameRewrite.ts"); + const source = readFileSync(serverFilePath, "utf8"); + + // 抽出パターンは `export const TAG_NAME_CHAR_CLASS_STRING = "..."` の + // 値部分を取り出す。文字列リテラルはダブルクォート前提(プロジェクト + // 全体の Prettier 設定)。テンプレートリテラル化したい場合は本テストも + // 拡張する。 + // Extract the literal value of `export const TAG_NAME_CHAR_CLASS_STRING`. + // Uses a double-quoted string literal (matches Prettier defaults). If the + // server file ever switches to a template literal, extend this regex. + const match = source.match( + /export const TAG_NAME_CHAR_CLASS_STRING\s*=\s*"((?:[^"\\]|\\.)*)";?/, + ); + + expect(match, "TAG_NAME_CHAR_CLASS_STRING export not found in server file").not.toBeNull(); + if (!match) return; + + // ソース内のエスケープシーケンス(`\\-` 等)を実値に解決して比較する。 + // JSON.parse でデコードできるようにダブルクォートを再付与する。 + // Decode JS-escape sequences (`\\-` etc.) so the comparison is between + // semantic string values, not raw source bytes. + const literalValue = JSON.parse(`"${match[1]}"`) as string; + expect(literalValue).toBe(TAG_NAME_CHAR_CLASS); + }); +}); diff --git a/src/lib/tagUtils.test.ts b/src/lib/tagUtils.test.ts index 59292d9d..c49b28d9 100644 --- a/src/lib/tagUtils.test.ts +++ b/src/lib/tagUtils.test.ts @@ -256,6 +256,54 @@ describe("updateTagAttributes", () => { expect(result.content).toBe(bad); expect(result.hasChanges).toBe(false); }); + + describe("targetId plumbing (issue #737)", () => { + // `pageTitleToId` を渡すと resolved タグに `targetId` を埋める。 + // Pin the `targetId` plumbing introduced for issue #737. + const TARGET_ID = "11111111-aaaa-bbbb-cccc-000000000001"; + + function buildContent(extraAttrs: Record = {}): string { + return JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "tech", + marks: [ + { + type: "tag", + attrs: { name: "tech", exists: false, referenced: false, ...extraAttrs }, + }, + ], + }, + ], + }, + ], + }); + } + + it("populates targetId when pageTitleToId is provided and tag resolves", () => { + const content = buildContent(); + const map = new Map([["tech", TARGET_ID]]); + const result = updateTagAttributes(content, new Set(["tech"]), new Set(), map); + expect(result.hasChanges).toBe(true); + const attrs = JSON.parse(result.content).content[0].content[0].marks[0].attrs; + expect(attrs.exists).toBe(true); + expect(attrs.targetId).toBe(TARGET_ID); + }); + + it("leaves targetId untouched when pageTitleToId is omitted", () => { + // 既存マークの `targetId` は触らない契約を固定する。 + // Pin that omitting the map preserves any pre-existing id. + const content = buildContent({ targetId: "preexisting-id" }); + const result = updateTagAttributes(content, new Set(["tech"]), new Set()); + const attrs = JSON.parse(result.content).content[0].content[0].marks[0].attrs; + expect(attrs.targetId).toBe("preexisting-id"); + }); + }); }); describe("getUniqueTagNames", () => { diff --git a/src/lib/tagUtils.ts b/src/lib/tagUtils.ts index f9bde2a6..40e1ed35 100644 --- a/src/lib/tagUtils.ts +++ b/src/lib/tagUtils.ts @@ -104,6 +104,11 @@ export function extractTagsFromContent(content: string): TagInfo[] { * 属性を更新する。`pageTitles` / `referencedTitles` は呼び出し側で解決済みの * ページタイトル集合(小文字・トリム正規化済み)。 * + * `pageTitleToId` を渡すと、解決時に `targetId` 属性も埋める(issue #737)。 + * 省略時は既存の `targetId` を温存する。 + * Pass `pageTitleToId` (issue #737) to also populate the `targetId` + * attribute on resolved marks; omitted → preserve existing id. + * * @returns 更新後の JSON と、属性変更が発生したかどうか。 * The updated JSON and a flag indicating whether any mark changed. */ @@ -111,6 +116,7 @@ export function updateTagAttributes( content: string, pageTitles: Set, referencedTitles: Set, + pageTitleToId?: Map, ): { content: string; hasChanges: boolean } { if (!content) return { content, hasChanges: false }; @@ -138,16 +144,35 @@ export function updateTagAttributes( const normalizedName = name.toLowerCase(); const newExists = pageTitles.has(normalizedName); const newReferenced = referencedTitles.has(normalizedName); - - if (attrs.exists !== newExists || attrs.referenced !== newReferenced) { + // 解決済みターゲット ID を埋める (issue #737)。`null` のまま + // 上書きしない (既存値温存) ため、resolved 時のみ書き換え対象。 + // Populate the resolved target id (issue #737). Never overwrite + // an existing id with `null`; only update when resolved. + const resolvedTargetId = + newExists && pageTitleToId !== undefined + ? (pageTitleToId.get(normalizedName) ?? null) + : null; + const currentTargetId = typeof attrs.targetId === "string" ? attrs.targetId : null; + const targetIdChanged = + resolvedTargetId !== null && resolvedTargetId !== currentTargetId; + + if ( + attrs.exists !== newExists || + attrs.referenced !== newReferenced || + targetIdChanged + ) { hasChanges = true; + const nextAttrs: Record = { + ...attrs, + exists: newExists, + referenced: newReferenced, + }; + if (targetIdChanged) { + nextAttrs.targetId = resolvedTargetId; + } return { ...mark, - attrs: { - ...attrs, - exists: newExists, - referenced: newReferenced, - }, + attrs: nextAttrs, }; } } diff --git a/src/lib/wikiLinkUtils.test.ts b/src/lib/wikiLinkUtils.test.ts index 781e35f3..e1e59aef 100644 --- a/src/lib/wikiLinkUtils.test.ts +++ b/src/lib/wikiLinkUtils.test.ts @@ -489,6 +489,67 @@ describe("updateWikiLinkAttributes", () => { expect(result.content).toBe(bad); expect(result.hasChanges).toBe(false); }); + + describe("targetId plumbing (issue #737)", () => { + // `pageTitleToId` を渡すと resolved リンクに `targetId` を埋める。 + // Pin the `targetId` plumbing introduced for issue #737. + const TARGET_ID = "11111111-aaaa-bbbb-cccc-000000000001"; + + function buildContent(extraAttrs: Record = {}): string { + return JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Link", + marks: [ + { + type: "wikiLink", + attrs: { title: "Page", exists: false, referenced: false, ...extraAttrs }, + }, + ], + }, + ], + }, + ], + }); + } + + it("populates targetId when pageTitleToId is provided and link resolves", () => { + const content = buildContent(); + const map = new Map([["page", TARGET_ID]]); + const result = updateWikiLinkAttributes(content, new Set(["page"]), new Set(), map); + expect(result.hasChanges).toBe(true); + const attrs = JSON.parse(result.content).content[0].content[0].marks[0].attrs; + expect(attrs.exists).toBe(true); + expect(attrs.targetId).toBe(TARGET_ID); + }); + + it("leaves targetId untouched when pageTitleToId is omitted", () => { + // 既存マークの `targetId` は触らない契約を固定する。 + // Pin the contract that omitting the map does not blank a stale id. + const content = buildContent({ targetId: "preexisting-id" }); + const result = updateWikiLinkAttributes(content, new Set(["page"]), new Set()); + const attrs = JSON.parse(result.content).content[0].content[0].marks[0].attrs; + expect(attrs.targetId).toBe("preexisting-id"); + }); + + it("does not set targetId when the link does not resolve", () => { + const content = buildContent(); + const map = new Map([["other", TARGET_ID]]); + const result = updateWikiLinkAttributes(content, new Set(["other"]), new Set(), map); + // exists changed for "other"-not-page? No — the link title is "page" but + // pageTitles only contains "other", so newExists stays false. + // Reflect that hasChanges should be false for this configuration. + // タイトルは "page" だが pageTitles は "other" だけ持つので未解決のまま。 + expect(result.hasChanges).toBe(false); + const attrs = JSON.parse(result.content).content[0].content[0].marks[0].attrs; + expect(attrs.targetId).toBeUndefined(); + }); + }); }); describe("getUniqueWikiLinkTitles", () => { diff --git a/src/lib/wikiLinkUtils.ts b/src/lib/wikiLinkUtils.ts index f7202c6a..23b4a5b8 100644 --- a/src/lib/wikiLinkUtils.ts +++ b/src/lib/wikiLinkUtils.ts @@ -3,6 +3,15 @@ * ページ内のWikiLinkの解析と状態更新を行う */ +/** + * 同期・描画フローで扱う WikiLink マークの最小形。`exists` は同名ページが + * 解決可能かどうか、`referenced` は他ページからゴーストリンクで参照されて + * いるかを表す。 + * + * Minimal shape of a WikiLink consumed by sync / render flows. `exists` + * indicates whether a same-titled page resolves; `referenced` tracks ghost + * references from other pages. + */ export interface WikiLinkInfo { title: string; exists: boolean; @@ -66,12 +75,21 @@ export function extractWikiLinksFromContent(content: string): WikiLinkInfo[] { * @param content 元のTiptap JSONコンテンツ * @param pageTitles 存在するページのタイトルセット(小文字正規化済み) * @param referencedTitles 他ページから参照されているリンクテキストのセット(小文字正規化済み) + * @param pageTitleToId 正規化済みタイトル → ターゲットページ id のマップ。issue #737 で + * 追加。`exists` が true になるリンクに `targetId` 属性を埋めることで、リネーム伝播 + * が同名ページとの衝突を ID 一致で回避できるようにする。省略時は `targetId` を更新 + * しない(既存マークの値を温存する)。 + * Optional normalized title → target page id map (issue #737). When provided, + * resolved links get their `targetId` populated so server-side rename + * propagation can disambiguate same-title pages by id. When omitted, the + * existing `targetId` is left untouched. * @returns 更新されたコンテンツと変更があったかどうか */ export function updateWikiLinkAttributes( content: string, pageTitles: Set, referencedTitles: Set, + pageTitleToId?: Map, ): { content: string; hasChanges: boolean } { if (!content) return { content, hasChanges: false }; @@ -99,17 +117,37 @@ export function updateWikiLinkAttributes( const normalizedTitle = (attrs.title as string).toLowerCase().trim(); const newExists = pageTitles.has(normalizedTitle); const newReferenced = referencedTitles.has(normalizedTitle); + // 解決済みターゲット ID を埋める (issue #737)。`null` のまま + // 上書きしない (既存値温存) ため、resolved 時のみ書き換え対象。 + // Populate the resolved target id (issue #737). Never overwrite + // an existing id with `null`; only update when the link + // resolves and the map provides a fresh id. + const resolvedTargetId = + newExists && pageTitleToId !== undefined + ? (pageTitleToId.get(normalizedTitle) ?? null) + : null; + const currentTargetId = typeof attrs.targetId === "string" ? attrs.targetId : null; + const targetIdChanged = + resolvedTargetId !== null && resolvedTargetId !== currentTargetId; // 状態が変わった場合のみ更新 - if (attrs.exists !== newExists || attrs.referenced !== newReferenced) { + if ( + attrs.exists !== newExists || + attrs.referenced !== newReferenced || + targetIdChanged + ) { hasChanges = true; + const nextAttrs: Record = { + ...attrs, + exists: newExists, + referenced: newReferenced, + }; + if (targetIdChanged) { + nextAttrs.targetId = resolvedTargetId; + } return { ...mark, - attrs: { - ...attrs, - exists: newExists, - referenced: newReferenced, - }, + attrs: nextAttrs, }; } }