From d1625e288951598ecb6c2789fff9d89be7047fb0 Mon Sep 17 00:00:00 2001 From: Akimasa Sugai <119780981+otomatty@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:51:10 +0900 Subject: [PATCH 01/22] fix: address PR #757 review follow-ups (#764) (#765) - claude-sidecar: emit `tool-use-complete` on abort/exception paths when a tool is still active, keeping start/complete pairs balanced for downstream consumers; add regression tests for both paths. - server/mcp tools test: normalize a malformed `/** ... /` helper comment to standard multiline JSDoc. - useMermaidGenerator tests: switch shared mock reset from `vi.clearAllMocks` to `vi.resetAllMocks` so per-test `mockImplementation`/`mockResolvedValue` no longer leak between cases. - useMermaidGenerator tests: realign "synchronous throws" case to use a true sync throw, and add a separate async-rejection case so the title matches the setup. - admin useConfirmDialogs tests: add English to Japanese-only `it(...)` titles to match the JP/EN bilingual convention. Co-authored-by: Claude --- .../src/pages/users/useConfirmDialogs.test.ts | 18 ++--- .../claude-sidecar/src/handlers/query.test.ts | 66 +++++++++++++++++++ packages/claude-sidecar/src/handlers/query.ts | 12 ++++ server/mcp/src/__tests__/tools/index.test.ts | 6 +- src/hooks/useMermaidGenerator.test.ts | 26 +++++++- 5 files changed, 116 insertions(+), 12 deletions(-) diff --git a/admin/src/pages/users/useConfirmDialogs.test.ts b/admin/src/pages/users/useConfirmDialogs.test.ts index 65891ee0..8bda1dd5 100644 --- a/admin/src/pages/users/useConfirmDialogs.test.ts +++ b/admin/src/pages/users/useConfirmDialogs.test.ts @@ -59,7 +59,7 @@ describe("useConfirmDialogs - role change", () => { expect(result.current.roleChangeTarget).toBeNull(); }); - it("requestRoleChange → confirm で onRoleChange を呼んで target を null に戻す", () => { + it("requestRoleChange → confirm で onRoleChange を呼んで target を null に戻す / requestRoleChange then confirm fires onRoleChange and clears target", () => { const { result, onRoleChange } = makeHook(); act(() => { result.current.requestRoleChange(userA, "admin"); @@ -81,7 +81,7 @@ describe("useConfirmDialogs - role change", () => { expect(onRoleChange).not.toHaveBeenCalled(); }); - it("cancel で target を null に戻す", () => { + it("cancel で target を null に戻す / cancel clears target", () => { const { result } = makeHook(); act(() => { result.current.requestRoleChange(userA, "admin"); @@ -92,7 +92,7 @@ describe("useConfirmDialogs - role change", () => { }); describe("useConfirmDialogs - unsuspend", () => { - it("request → confirm で onUnsuspend を呼ぶ", () => { + it("request → confirm で onUnsuspend を呼ぶ / request then confirm fires onUnsuspend", () => { const { result, onUnsuspend } = makeHook(); act(() => { result.current.requestUnsuspend(userA); @@ -106,7 +106,7 @@ describe("useConfirmDialogs - unsuspend", () => { expect(result.current.unsuspendTarget).toBeNull(); }); - it("cancel で target を null に戻す", () => { + it("cancel で target を null に戻す / cancel clears target", () => { const { result } = makeHook(); act(() => { result.current.requestUnsuspend(userA); @@ -121,7 +121,7 @@ describe("useConfirmDialogs - delete with impact", () => { vi.mocked(getUserImpact).mockReset(); }); - it("requestDelete でローディング状態 → impact 取得後に impact 反映", async () => { + it("requestDelete でローディング状態 → impact 取得後に impact 反映 / requestDelete shows loading then applies impact once it resolves", async () => { vi.mocked(getUserImpact).mockResolvedValueOnce(sampleImpact); const { result } = makeHook(); @@ -141,7 +141,7 @@ describe("useConfirmDialogs - delete with impact", () => { expect(result.current.deleteTarget?.impact).toEqual(sampleImpact); }); - it("getUserImpact が失敗したら loadingImpact: false で impact は null のまま", async () => { + it("getUserImpact が失敗したら loadingImpact: false で impact は null のまま / when getUserImpact rejects, loadingImpact becomes false and impact stays null", async () => { vi.mocked(getUserImpact).mockRejectedValueOnce(new Error("nope")); const { result } = makeHook(); @@ -203,7 +203,7 @@ describe("useConfirmDialogs - delete with impact", () => { expect(result.current.deleteTarget?.loadingImpact).toBe(false); }); - it("cancelDelete は requestId をインクリメントし、後から来た resolve を無効化する", async () => { + it("cancelDelete は requestId をインクリメントし、後から来た resolve を無効化する / cancelDelete bumps requestId and invalidates a late resolve", async () => { let resolveLate: ((v: UserImpact) => void) | null = null; vi.mocked(getUserImpact).mockImplementationOnce( () => @@ -229,7 +229,7 @@ describe("useConfirmDialogs - delete with impact", () => { expect(result.current.deleteTarget).toBeNull(); }); - it("confirmDelete で onDelete を呼んで target を null にする", async () => { + it("confirmDelete で onDelete を呼んで target を null にする / confirmDelete fires onDelete and clears target", async () => { vi.mocked(getUserImpact).mockResolvedValueOnce(sampleImpact); const { result, onDelete } = makeHook(); act(() => { @@ -246,7 +246,7 @@ describe("useConfirmDialogs - delete with impact", () => { expect(result.current.deleteTarget).toBeNull(); }); - it("confirmDelete が target なしのときは onDelete を呼ばない", () => { + it("confirmDelete が target なしのときは onDelete を呼ばない / confirmDelete without target does not fire onDelete", () => { const { result, onDelete } = makeHook(); act(() => { result.current.confirmDelete(); diff --git a/packages/claude-sidecar/src/handlers/query.test.ts b/packages/claude-sidecar/src/handlers/query.test.ts index edb5b73f..b82d61fc 100644 --- a/packages/claude-sidecar/src/handlers/query.test.ts +++ b/packages/claude-sidecar/src/handlers/query.test.ts @@ -615,6 +615,72 @@ describe("runQuery", () => { ]); }); + it("flushes an active tool with tool-use-complete when aborted mid-tool", async () => { + // Start a tool, then abort before it finishes — the consumer must still see + // tool-use-complete so the start/complete pair stays balanced. + // ツール開始後に中断した場合でも tool-use-complete を発火させ、開始/完了対応を保つ。 + const ac = new AbortController(); + // Custom iterable: emit tool-use-start, abort, then yield one more event that + // the loop should detect-and-break-on rather than process. + // tool-use-start を流した直後に abort し、次のイベントでループを抜けるイテラブル。 + queryMock.mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield partial({ + type: "content_block_start", + content_block: { type: "tool_use", name: "Bash", input: '{"command":"sleep 1"}' }, + }) as unknown as SDKMessage; + ac.abort(); + yield partial({ + type: "content_block_delta", + delta: { type: "text_delta", text: "ignored" }, + }) as unknown as SDKMessage; + }, + }); + + await runQuery({ + id: "q1", + prompt: "p", + writeLine: (l) => writes.push(l), + abortController: ac, + tracker, + }); + + expect(parsed()).toEqual([ + { type: "tool-use-start", id: "q1", toolName: "Bash", toolInput: '{"command":"sleep 1"}' }, + { type: "tool-use-complete", id: "q1", toolName: "Bash" }, + { type: "error", id: "q1", error: "Query aborted", code: "aborted" }, + ]); + }); + + it("flushes an active tool with tool-use-complete when the stream throws mid-tool", async () => { + // If the SDK iterator throws while a tool is active, emit tool-use-complete before the error. + // SDK イテレータがツール処理中に例外を投げた場合でも、エラーの前に tool-use-complete を発火させる。 + queryMock.mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield partial({ + type: "content_block_start", + content_block: { type: "tool_use", name: "Read", input: '{"path":"/x"}' }, + }) as unknown as SDKMessage; + throw new Error("stream blew up"); + }, + }); + + await runQuery({ + id: "q1", + prompt: "p", + writeLine: (l) => writes.push(l), + abortController: new AbortController(), + tracker, + }); + + expect(parsed()).toEqual([ + { type: "tool-use-start", id: "q1", toolName: "Read", toolInput: '{"path":"/x"}' }, + { type: "tool-use-complete", id: "q1", toolName: "Read" }, + { type: "error", id: "q1", error: "stream blew up", code: "query_exception" }, + ]); + expect(tracker.snapshot().status).toBe("idle"); + }); + it("emits stream-complete with the aggregated text when the stream ends without a result", async () => { queryMock.mockReturnValue( makeQueryIterable([ diff --git a/packages/claude-sidecar/src/handlers/query.ts b/packages/claude-sidecar/src/handlers/query.ts index abaa93f1..44c6ebc8 100644 --- a/packages/claude-sidecar/src/handlers/query.ts +++ b/packages/claude-sidecar/src/handlers/query.ts @@ -326,6 +326,12 @@ export async function runQuery(params: { } if (abortController.signal.aborted) { + // Flush any active tool so downstream consumers see a balanced start/complete pair. + // 中断時もアクティブなツールを完了扱いにし、開始/完了の対応関係を保つ。 + if (activeToolName) { + emit({ type: "tool-use-complete", id, toolName: activeToolName }); + activeToolName = null; + } emit({ type: "error", id, @@ -341,6 +347,12 @@ export async function runQuery(params: { result: { content: aggregated }, }); } catch (err) { + // Flush any active tool before reporting the exception so the start/complete pair stays balanced. + // 例外発生時もアクティブなツールを完了扱いにし、開始/完了の対応関係を保つ。 + if (activeToolName) { + emit({ type: "tool-use-complete", id, toolName: activeToolName }); + activeToolName = null; + } const message = err instanceof Error ? err.message : String(err); emit({ type: "error", diff --git a/server/mcp/src/__tests__/tools/index.test.ts b/server/mcp/src/__tests__/tools/index.test.ts index 6e8c6080..d96049bf 100644 --- a/server/mcp/src/__tests__/tools/index.test.ts +++ b/server/mcp/src/__tests__/tools/index.test.ts @@ -16,8 +16,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ZediClient } from "../../client/ZediClient.js"; import { ALL_TOOL_NAMES, registerAllTools } from "../../tools/index.js"; -/** Build a fully-mocked ZediClient. Tools call only the methods we register, so types are safe. / - * 全メソッドをモック化した ZediClient。 */ +/** + * Build a fully-mocked ZediClient. Tools call only the methods we register, so types are safe. + * 全メソッドをモック化した ZediClient。 + */ function createMockClient(): ZediClient { return { getCurrentUser: vi.fn(), diff --git a/src/hooks/useMermaidGenerator.test.ts b/src/hooks/useMermaidGenerator.test.ts index 9d2d34d8..a952eb01 100644 --- a/src/hooks/useMermaidGenerator.test.ts +++ b/src/hooks/useMermaidGenerator.test.ts @@ -47,7 +47,13 @@ afterEach(() => { describe("useMermaidGenerator", () => { beforeEach(() => { - vi.clearAllMocks(); + // `clearAllMocks` only resets call history; implementations set via + // `mockImplementation` / `mockResolvedValue` / `mockRejectedValueOnce` would + // leak into later tests. `resetAllMocks` also clears those implementations, + // so each test starts from a clean baseline. + // `clearAllMocks` は呼び出し履歴しかクリアしないため、mockImplementation 等の実装が + // 後続テストへ持ち越される。`resetAllMocks` で実装も含めて初期化する。 + vi.resetAllMocks(); }); it("starts in idle state with no result/error", () => { @@ -141,6 +147,24 @@ describe("useMermaidGenerator", () => { }); it("generate catches synchronous throws from generateMermaidDiagram and sets error", async () => { + // True sync throw: the mock raises before returning a promise so the hook's + // try/catch must convert it into an error state. + // 真の同期 throw: Promise を返す前に例外を投げ、hook の try/catch でエラー化されることを検証。 + mockGenerateMermaidDiagram.mockImplementationOnce(() => { + throw new Error("network"); + }); + + const { result } = renderHook(() => useMermaidGenerator()); + + await act(async () => { + await result.current.generate("topic", ["pie"]); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error?.message).toBe("network"); + }); + + it("generate catches async rejections from generateMermaidDiagram and sets error", async () => { mockGenerateMermaidDiagram.mockRejectedValueOnce(new Error("network")); const { result } = renderHook(() => useMermaidGenerator()); From db3245142bda46951d5327c3d4f4817eca0ec91d Mon Sep 17 00:00:00 2001 From: Akimasa Sugai <119780981+otomatty@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:13:56 +0900 Subject: [PATCH 02/22] feat: add input rule for real-time hashtag tagging (#766) (#769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editor): add tag input rule so typed `#name` becomes a styled mark (#766) Phase 1 (#725) only registered `addPasteRules`, so live typing in the editor never produced the styled tag mark — only paste / pre-saved JSON did. Add `addInputRules` with a custom `InputRule` (not `markInputRule`) so the user-typed terminator (whitespace / CJK punctuation) is preserved instead of being deleted, and the same exclusion contract as the paste rule (`extractTagName` / `isExcludedTagName`) is reused so reject reasons stay colocated. * test(editor): use StarterKit in tag input-rule integration tests knip CI flagged `@tiptap/extension-{document,paragraph,text,code}` as unlisted dependencies because they are transitive deps via `@tiptap/starter-kit`. Switch the test editor to depend on `StarterKit` (already a direct dep used elsewhere in the codebase) so the test no longer reaches into transitive packages, and relax the `# Heading` assertion to only check that no tag mark is produced (StarterKit's heading input rule may legitimately fire on `# `). * feat(editor): broaden tag input-rule terminator to any non-name char Address Gemini review feedback on PR #769: the previous terminator set `[\s、。,.!?:;]` missed parentheses, brackets, quotes, and similar boundary characters, so `(#tag)` / `"#tag"` / `[#tag]` never triggered the input rule. Replace it with `[^TAG_NAME_CHAR_CLASS]` so any character outside the tag-name class closes the tag — matching how Twitter / GitHub treat hashtags. Add regex tests for parentheses / brackets / quotes / `「」` and an editor integration test for `(#tag)`. --------- Co-authored-by: Claude --- .../editor/extensions/TagExtension.test.ts | 335 +++++++++++++++++- .../editor/extensions/TagExtension.ts | 107 +++++- 2 files changed, 440 insertions(+), 2 deletions(-) diff --git a/src/components/editor/extensions/TagExtension.test.ts b/src/components/editor/extensions/TagExtension.test.ts index c65a0392..f406a189 100644 --- a/src/components/editor/extensions/TagExtension.test.ts +++ b/src/components/editor/extensions/TagExtension.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from "vitest"; -import { Tag, TAG_PASTE_REGEX, extractTagName, isExcludedTagName } from "./TagExtension"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { + Tag, + TAG_INPUT_REGEX, + TAG_PASTE_REGEX, + extractTagName, + isExcludedTagName, +} from "./TagExtension"; /** * Tests for Tag mark extension (hashtag `#name` syntax). @@ -189,6 +197,331 @@ describe("TagExtension paste rule", () => { }); }); +/** + * Tests for the input-rule regex and the `addInputRules` wiring (issue #766). + * Phase 1 (#725) only registered `addPasteRules`, so typing `#tag` directly + * never produced the styled mark — only paste / pre-saved JSON did. The input + * rule fixes that gap by detecting `#name` followed by a terminator char in + * real time. + * + * 入力規則用正規表現と `addInputRules` 配線のテスト(issue #766)。Phase 1 + * (#725) は paste rule のみを登録していたため、エディタに `#tag` を直接 + * タイプしてもマークが付かなかった。本入力規則が `#name` + 終端文字を + * リアルタイム検知して埋める。 + */ +describe("TagExtension input rule", () => { + describe("TAG_INPUT_REGEX", () => { + /** + * The regex must use exactly one capture group — `match[1]` is the + * `#name` literal that the input-rule handler converts to a document + * range. Exposing more captures would break the handler's index math + * silently; test pins the contract. + * + * キャプチャは 1 つだけ。`match[1]` が `#name` リテラルを表し、これを + * 元にハンドラがドキュメント範囲を計算する。誤って追加すると静かに壊れる + * ためここで固定する。 + */ + it("captures exactly one group for the `#name` literal", () => { + const m = "#tech ".match(TAG_INPUT_REGEX); + expect(m).not.toBeNull(); + expect(m).toHaveLength(2); // [fullMatch, captureGroup] + expect(m?.[1]).toBe("#tech"); + }); + + it("matches `#tech ` with a space terminator", () => { + expect("#tech ".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech"); + }); + + it("matches `#tech\\n` with a newline terminator", () => { + expect("#tech\n".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech"); + }); + + it("matches CJK names with Japanese punctuation terminators", () => { + // `、` / `。` のような和文句読点でも確定すること(受け入れ条件)。 + // CJK punctuation must terminate as well per acceptance criteria. + expect("#技術、".match(TAG_INPUT_REGEX)?.[1]).toBe("#技術"); + expect("#趣味。".match(TAG_INPUT_REGEX)?.[1]).toBe("#趣味"); + }); + + it("matches names with hyphens and underscores", () => { + expect("#front-end ".match(TAG_INPUT_REGEX)?.[1]).toBe("#front-end"); + expect("#back_end ".match(TAG_INPUT_REGEX)?.[1]).toBe("#back_end"); + }); + + it("matches when `#` is preceded by a non-word boundary character", () => { + // 行中の空白直後に `#tag` をタイプしたケース。 + // Typing `#tag` after a space mid-line. + expect("Hello #tech ".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech"); + }); + + it("matches at the very start of the input string", () => { + // 段落先頭から `#tag ` をタイプしたケース。 + // Typing `#tag` at the start of a paragraph. + expect("#intro ".match(TAG_INPUT_REGEX)?.[1]).toBe("#intro"); + }); + + it("does not match `# Heading` (Markdown ATX heading)", () => { + // `# ` は Markdown 見出しのため、タグ化対象外。 + // `# ` is a Markdown heading marker, not a tag. + expect("# Heading".match(TAG_INPUT_REGEX)).toBeNull(); + }); + + it("does not match `## Subheading` (Markdown level-2 heading)", () => { + expect("## Subheading".match(TAG_INPUT_REGEX)).toBeNull(); + }); + + it("does not match `abc#tag ` (word boundary violation)", () => { + // 単語中の `#` はタグと見なさない(URL や ID の可能性)。 + // `#` embedded in a word is not a tag (could be a URL fragment / id). + expect("abc#tag ".match(TAG_INPUT_REGEX)).toBeNull(); + }); + + it("does not match `/page#anchor ` (slash-prefixed URL fragment)", () => { + expect("/page#anchor ".match(TAG_INPUT_REGEX)).toBeNull(); + }); + + it("terminates on common punctuation (`,.!?:;`)", () => { + expect("#tech,".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech"); + expect("#tech.".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech"); + expect("#tech!".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech"); + expect("#tech?".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech"); + expect("#tech:".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech"); + expect("#tech;".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech"); + }); + + it("terminates on parentheses, brackets, and quotes", () => { + // 終端を `[^TAG_NAME_CHAR_CLASS]` まで広げたので、`(#tag)` のように + // 括弧で囲んだ場合や引用符で閉じた場合も `)` / `"` / `'` 入力時点で + // タグ化される(issue #769 レビュー反映)。 + // The terminator class is `[^TAG_NAME_CHAR_CLASS]`, so `(#tag)`, + // `"#tag"`, `[#tag]` etc. all close the tag on the closing character + // (review feedback on issue #769). + expect("(#tag)".match(TAG_INPUT_REGEX)?.[1]).toBe("#tag"); + expect("[#tag]".match(TAG_INPUT_REGEX)?.[1]).toBe("#tag"); + expect("{#tag}".match(TAG_INPUT_REGEX)?.[1]).toBe("#tag"); + expect('"#tag"'.match(TAG_INPUT_REGEX)?.[1]).toBe("#tag"); + expect("'#tag'".match(TAG_INPUT_REGEX)?.[1]).toBe("#tag"); + expect("「#技術」".match(TAG_INPUT_REGEX)?.[1]).toBe("#技術"); + }); + + it("terminates when a second `#` is typed (e.g. `#tech#design`)", () => { + // `#` 自体も `TAG_NAME_CHAR_CLASS` に含まれないため終端扱いとなり、 + // 連続して別タグを打ち始めた瞬間に直前のタグが確定する。 + // `#` is not in `TAG_NAME_CHAR_CLASS`, so a second `#` terminates the + // previous tag — typing back-to-back tags `#tech#design` finalises + // `#tech` the moment the second `#` is typed. + expect("#tech#".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech"); + }); + + it("does not match a `#` with no following name", () => { + expect("# ".match(TAG_INPUT_REGEX)).toBeNull(); + }); + + it("only matches the most recent `#name` segment (anchored to end)", () => { + // 入力規則は `$` でアンカーしているので、過去に登場した `#tech ` は + // 再マッチさせず、現在打鍵中の `#design ` だけに反応する。 + // The regex is anchored with `$`, so a previously-entered tag like + // `#tech ` will not re-fire — only the just-completed `#design ` does. + const m = "#tech and #design ".match(TAG_INPUT_REGEX); + expect(m?.[1]).toBe("#design"); + }); + }); + + describe("addInputRules wiring", () => { + it("declares `addInputRules` as a function on the extension config", () => { + const extension = Tag.configure({}); + expect(extension.config.addInputRules).toBeDefined(); + expect(typeof extension.config.addInputRules).toBe("function"); + }); + }); + + describe("integration with a real Tiptap editor", () => { + /** + * Build a minimal editor with `StarterKit` (which contains Document, + * Paragraph, Text, Code, and basic input rules) plus the Tag mark under + * test. Using StarterKit instead of cherry-picked extensions keeps + * dependencies aligned with the rest of the codebase (it is already + * imported elsewhere) and avoids unlisted-dependency violations. + * + * Tag マークと StarterKit(Document / Paragraph / Text / Code を内包)で + * 編集インスタンスを作る。本体側でも StarterKit を使っており、 + * テストの依存も合わせることで knip の unlisted dependency 検出を回避する。 + */ + function makeEditor(initialContent: string): Editor { + return new Editor({ + extensions: [StarterKit, Tag], + content: initialContent, + }); + } + + /** + * Simulate the user typing `text` at position `pos` in the editor by + * dispatching the input-rule plugin's `handleTextInput`. Tiptap installs + * the input-rule plugin via `addInputRules`, and ProseMirror invokes + * `handleTextInput` for every keystroke; calling it directly is the + * canonical way to exercise input rules in unit tests without a real + * keyboard. + * + * `view.someProp("handleTextInput")` でタイプ操作を再現する。Tiptap が + * 登録した input-rule プラグインの `handleTextInput` が呼ばれ、規則が + * マッチすれば `tr` を組み立て自動的にディスパッチされる。 + */ + function typeAt(editor: Editor, pos: number, text: string): boolean | undefined { + const { view } = editor; + return view.someProp("handleTextInput", (handler) => handler(view, pos, pos, text)); + } + + it("applies the tag mark when `#tech ` is completed by a space", () => { + // 段落 `#tech` の末尾(pos = 6)に空白をタイプしたシナリオ。 + // Paragraph contains `#tech`; user types a space at end (pos 6). + const editor = makeEditor("

#tech

"); + try { + const handled = typeAt(editor, 6, " "); + expect(handled).toBe(true); + const html = editor.getHTML(); + expect(html).toContain('data-name="tech"'); + expect(html).toContain("data-tag"); + } finally { + editor.destroy(); + } + }); + + it("applies the tag mark when `(#tag)` is closed with a `)`", () => { + // 括弧で囲んだケース:`(#tag` の後に `)` を打鍵すると `#tag` がタグ化。 + // Closing `)` after `(#tag` finalises the tag — review feedback on #769. + const editor = makeEditor("

(#tag

"); + try { + const handled = typeAt(editor, 6, ")"); + expect(handled).toBe(true); + const html = editor.getHTML(); + expect(html).toContain('data-name="tag"'); + expect(html).toContain("data-tag"); + } finally { + editor.destroy(); + } + }); + + it("applies the tag mark for `#技術、` with a Japanese punctuation terminator", () => { + // 和文句読点 `、` で確定するケース(受け入れ条件)。 + // Acceptance criterion: `、` finalises CJK tag names. + const editor = makeEditor("

#技術

"); + try { + const handled = typeAt(editor, 4, "、"); + expect(handled).toBe(true); + const html = editor.getHTML(); + expect(html).toContain('data-name="技術"'); + expect(html).toContain("data-tag"); + } finally { + editor.destroy(); + } + }); + + it("does not mark numeric-only `#1 ` (delegates to `isExcludedTagName`)", () => { + const editor = makeEditor("

#1

"); + try { + const handled = typeAt(editor, 3, " "); + // 入力規則がリジェクトする → ProseMirror のデフォルト挿入に委ねる。 + // Rule rejects → handler returns null → no plugin claims the input. + expect(handled).toBeFalsy(); + const html = editor.getHTML(); + expect(html).not.toContain("data-tag"); + } finally { + editor.destroy(); + } + }); + + it("does not mark 6-char hex `#aabbcc ` (CSS color heuristic)", () => { + const editor = makeEditor("

#aabbcc

"); + try { + const handled = typeAt(editor, 8, " "); + expect(handled).toBeFalsy(); + const html = editor.getHTML(); + expect(html).not.toContain("data-tag"); + } finally { + editor.destroy(); + } + }); + + it("does not mark 8-char hex `#aabbccdd ` (CSS color with alpha)", () => { + const editor = makeEditor("

#aabbccdd

"); + try { + const handled = typeAt(editor, 10, " "); + expect(handled).toBeFalsy(); + const html = editor.getHTML(); + expect(html).not.toContain("data-tag"); + } finally { + editor.destroy(); + } + }); + + it("does not mark a Markdown heading `# Heading`", () => { + // 裸の `#` のあとに空白を打鍵しても、タグ規則は `#` 直後に最低 1 文字の + // 名前を要求するため発火しない。StarterKit の heading 入力規則が代わりに + // 反応する可能性があるが、本テストはあくまで「タグマークが付かない」 + // ことだけを保証する。 + // Bare `#` followed by space must not produce a tag mark — the regex + // demands at least one name character after `#`. StarterKit's heading + // input rule may fire instead, but we only assert that no `data-tag` + // appears in the result (the heading transform is StarterKit's concern). + const editor = makeEditor("

#

"); + try { + typeAt(editor, 2, " "); + const html = editor.getHTML(); + expect(html).not.toContain("data-tag"); + } finally { + editor.destroy(); + } + }); + + it("does not mark `abc#tag ` (word-boundary violation)", () => { + const editor = makeEditor("

abc#tag

"); + try { + const handled = typeAt(editor, 8, " "); + expect(handled).toBeFalsy(); + const html = editor.getHTML(); + expect(html).not.toContain("data-tag"); + } finally { + editor.destroy(); + } + }); + + it("does not mark `#tag` typed inside an inline code mark", () => { + // `code` マークと共存させない (`excludes: "code"`) ため、コード内では + // タグマークが付与されないことを確認する。 + // Tags must not appear inside inline code (`excludes: "code"`). + const editor = makeEditor("

#tag

"); + try { + const handled = typeAt(editor, 6, " "); + // 規則そのものはマッチしないか、衝突マークを検知して null を返す。 + // 結果として `data-tag` 属性が文書に現れないことが本テストの保証。 + // Either the rule does not match inside code or the handler skips + // due to mark exclusion; either way no `data-tag` should appear. + expect(handled).toBeFalsy(); + const html = editor.getHTML(); + expect(html).not.toContain("data-tag"); + } finally { + editor.destroy(); + } + }); + + it("preserves the user-typed terminator after applying the mark", () => { + // `markInputRule` は終端文字を削除してしまうため独自ハンドラを使った。 + // 終端文字(空白)が消えていないことを後置確認する。 + // Our custom handler intentionally avoids `markInputRule` so the + // terminator the user just typed stays in the doc — verify it. + const editor = makeEditor("

#tech

"); + try { + typeAt(editor, 6, " "); + const text = editor.state.doc.textContent; + // `#tech ` のままで、末尾空白が削除されていないこと。 + expect(text).toBe("#tech "); + } finally { + editor.destroy(); + } + }); + }); +}); + describe("Tag extension configuration", () => { it("has addPasteRules defined", () => { const extension = Tag.configure({}); diff --git a/src/components/editor/extensions/TagExtension.ts b/src/components/editor/extensions/TagExtension.ts index a6a60118..6a39b4da 100644 --- a/src/components/editor/extensions/TagExtension.ts +++ b/src/components/editor/extensions/TagExtension.ts @@ -1,4 +1,4 @@ -import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core"; +import { InputRule, Mark, markPasteRule, mergeAttributes } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { TAG_NAME_CHAR_CLASS } from "@zedi/shared/tagCharacterClass"; @@ -39,6 +39,41 @@ import { TAG_NAME_CHAR_CLASS } from "@zedi/shared/tagCharacterClass"; */ export const TAG_PASTE_REGEX = new RegExp(`(?({ ]; }, + addInputRules() { + // `this.type` を外側で捕捉し、ハンドラは PURE な関数として閉じ込める。 + // Capture `this.type` outside the handler so the rule stays a pure closure. + const markType = this.type; + + return [ + new InputRule({ + find: TAG_INPUT_REGEX, + handler: ({ state, range, match }) => { + const tagText = match[1]; + const fullMatch = match[0]; + if (!tagText || !fullMatch) return null; + + // Re-use the same exclusion contract as the paste rule so typed and + // pasted input share their reject reasons (numeric-only / 6/8-hex). + // 貼り付け規則と同じ除外ルールに合わせる(数字のみ・6/8 桁 hex)。 + const name = extractTagName(tagText); + if (!name || isExcludedTagName(name)) return null; + + // Compute the document range of the `#name` portion: the regex may + // include a non-capturing leading boundary (`[^\w/#]`) and a trailing + // terminator (whitespace or punctuation), neither of which should be + // marked. Code-mark neighbourhoods are filtered upstream by the + // input-rule runner (it short-circuits on `mark.type.spec.code`), so + // the `excludes: "code"` invariant requires no extra guard here. + // `#name` 本体の文書内範囲を算出する。先頭境界(非単語文字)と + // 末尾の終端文字はマーク範囲に含めない。コードマーク内では Tiptap の + // 入力規則ランナー側で短絡されるためここで追加チェックは不要。 + const tagOffsetInMatch = fullMatch.indexOf(tagText); + const tagStart = range.from + tagOffsetInMatch; + const tagEnd = tagStart + tagText.length; + + // Tiptap の入力規則プラグインはハンドラ呼び出し時点でユーザーが + // タイプした文字(終端文字)をまだドキュメントに挿入していない。 + // `tr.steps.length > 0` で `handleTextInput` が `true` を返す扱いと + // なり、ProseMirror デフォルトのテキスト挿入はスキップされるため、 + // ここで終端文字を明示的に挿入してユーザー入力を保全する。終端文字は + // `fullMatch` のうちキャプチャ後ろに残る部分(通常 1 文字)に等しい。 + // Tiptap's input-rule plugin invokes the handler before ProseMirror + // commits the just-typed character. Returning a transaction with + // `steps > 0` causes the plugin to dispatch it and skip the default + // text insertion, so we re-insert the terminator (the slice of + // `fullMatch` after the capture) ourselves to preserve the keystroke. + const typedTerminator = fullMatch.slice(tagOffsetInMatch + tagText.length); + + // 注: `state.tr` は呼び出すたびに新しい Transaction を返すゲッター。 + // Tiptap が `state` をラップして `tr` が同一インスタンスを返すように + // しているため、destructure して取り出した `tr` への変更が一括で + // ディスパッチされる。 + // `state.tr` is a getter; Tiptap proxies `state` so accesses share a + // single `tr` instance which is then dispatched after the handler. + const { tr } = state; + if (typedTerminator.length > 0) { + // 終端文字をマーク範囲の直後に挿入する(範囲シフトを起こさないよう + // mark 適用より前に行う)。 + // Insert the terminator first so the subsequent `addMark` range + // does not need rebasing. + tr.insertText(typedTerminator, range.to); + } + tr.addMark( + tagStart, + tagEnd, + markType.create({ name, exists: false, referenced: false, targetId: null }), + ); + tr.removeStoredMark(markType); + }, + }), + ]; + }, + addProseMirrorPlugins() { const { onTagClick } = this.options; From df84e9ea44963611040372e347dd59e03b6a5b50 Mon Sep 17 00:00:00 2001 From: Akimasa Sugai <119780981+otomatty@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:14:03 +0900 Subject: [PATCH 03/22] i18n: Add internationalization support for error messages and UI text (#776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(i18n): ベタ書き日本語の主要箇所を i18n キーに置き換え react-i18next を導入済みだが、ソース内に残っていた日本語ベタ書きを errors / common 名前空間を中心に i18n 化する。 - common.json に untitledPage / sharedNoteContext / newConversation / date / search / wikiIndex キーを追加 - errors.json にトースト・例外メッセージ用キーを大幅追加 (画像アップロード、AI 接続テスト、AI サービス、ストリーミング、 Mermaid 生成、サムネイル commit、Web Clipper、コンテンツ移行 等) - aiChat.json に context.disable / enable を追加 主な置換対象: - src/pages/NotePageView.tsx: タイトル保存失敗 toast / loading 表示 (useTranslation 導入済みなのにベタ書きが残っていた) - src/pages/Notes.tsx: signInRequired のフォールバック削除 - src/pages/SearchResults.tsx: 検索結果ヘッダー・空状態・最小文字数 - src/pages/IndexPage.tsx: Wiki Index ページ全体 - src/components/ai-chat/*: 「新しい会話」「無題のページ」「コンテキスト 有効/無効」 - src/hooks/*: useImageUpload / useMermaidGenerator / useTitleValidation / useStorageSettings / useAISettings / useAIChatExecute(Regenerate) / useGlobalSearch - src/lib/aiClient.ts: 接続テスト系メッセージ - src/lib/aiService.ts / aiServiceServer.ts / aiServiceModels.ts: API 呼び出しエラー、ストリーミングエラー - src/lib/aiSettings.ts / storageSettings.ts: 保存失敗エラー - src/lib/thumbnailCommit.ts: 画像保存タイムアウト等 - src/lib/webClipper/getClipErrorMessage.ts: クリップエラー全般 - src/lib/contentUtils.ts: 無題のページ / コンテンツ移行警告 - src/lib/dateUtils.ts: 今日 / 昨日 / たった今 等の相対時刻 (日本語/英語で date-fns ロケール切り替え) 非 React 環境からは @/i18n を直接 import して i18n.t() を呼び出す パターンを採用 (ImageCreateDialog や slashCommandItems と同じ方式)。 generalSettings.ts は i18n の初期化時に loadGeneralSettings を読み込む ため、循環依存を避けて元のままとする。 テスト更新: - src/pages/NotePageView.test.tsx: i18n モックがキーをそのまま返す 実装に合わせ、期待値をキー文字列へ更新 * refactor(i18n): レビュー指摘の反映と Unit Tests の修復 PR #776 の自動レビュー(Gemini / Codex / CodeRabbit / Devin)と Unit Tests CI 失敗を反映する。 ### Unit Tests CI 修復 - AIChatMessage.test.tsx / MermaidGeneratorDialog.test.tsx の `react-i18next` モックに `initReactI18next` を追加。lib (例: contentUtils / aiSettings) が `@/i18n` を直接 import するように なったため、モックでも export が必要になった。 - PageEditorHeader.test.tsx に `useTranslation` モックを追加し、 `editor.savedAt` テンプレートを再現。 ### dateUtils - `getActiveLocale` を export し、戻り値を `Locale` で明示 (date-fns/locale)。日本語以外は `enUS` にフォールバック。 - 日付フォーマット文字列 (`M月d日(E)` / `MMM d (EEE)` / `M/d` / `yyyy年M月d日 HH:mm`) を common.json の `date.format.*` に移動。 - JSDoc の英語複数形誤記 "3 day ago" → "3 days ago" を修正。 ### 複数形対応 - `common.search.resultsCount` と `common.date.{minutes,hours,days}Ago` に i18next の `_one` / `_other` 形式を導入。"1 day ago" / "5 days ago" のような英語の複数形が正しく出るようにする。 ### IndexPage - `Date#toLocaleString()` をやめ、`date-fns/format` + `getActiveLocale()` で `common.date.format.fullDateTime` を使う ように変更(ブラウザロケールではなくアプリの言語設定に従う)。 ### contentUtils - `parts.join("、")` を locale-aware にし、`common.listSeparator` を介して英語ロケールでは `, ` を使うようにする。 ### useGlobalSearch / SearchResults - 共有検索結果のタイトルが空文字列の場合のフォールバックを `??` から `r.title?.trim() ? r.title : t(...)` に修正。 ### webClipper/getClipErrorMessage - 上流エラー文言が日本語固定だった分岐に英語フラグメントを追加し、 ロケール非依存のマッチングへ拡張。 ### thumbnailCommit - HTTP エラー時のフォールバックメッセージを `errors.imageSaveFailed` 経由に変更(ja/en それぞれを定義)。 ### aiServiceModels - ネットワーク例外・レスポンス読み取り失敗時のメッセージを `errors.{networkUnreachable,requestFailed,responseReadFailed}` に 分離して i18n 化。 ### PageEditorHeader - `{formatTimeAgo(lastSaved)}に保存` の文字列連結を `t("editor.savedAt", { relative: ... })` に置き換え(英語ロケールで 「5 min agoに保存」のような言語混在を防止)。 ### キー名の修正 - `errors.seedTestRunFailed` のタイポ(「seed」は無関係)を `errors.testRunFailed` に改名。useAISettings / useStorageSettings の 参照箇所も合わせて更新。 ### 自動生成された空 JSDoc の除去 - pre-commit hook の eslint --fix で挿入された空 JSDoc ブロックを aiChatInputHelpers.ts / useMermaidGenerator.ts / useStorageSettings.ts から除去(合計 58 ブロック)。 --------- Co-authored-by: Claude --- src/components/ai-chat/AIChatContextBar.tsx | 2 +- .../ai-chat/AIChatConversationList.tsx | 6 +- src/components/ai-chat/AIChatInput.tsx | 4 +- src/components/ai-chat/AIChatMessage.test.tsx | 5 ++ src/components/ai-chat/aiChatInputHelpers.ts | 84 ++++++++++++++++++- .../editor/MermaidGeneratorDialog.test.tsx | 3 + .../PageEditor/PageEditorHeader.test.tsx | 16 ++++ .../editor/PageEditor/PageEditorHeader.tsx | 2 +- src/hooks/useAIChatExecute.ts | 3 +- src/hooks/useAIChatExecuteRegenerate.ts | 3 +- src/hooks/useAISettings.ts | 3 +- src/hooks/useGlobalSearch.ts | 7 +- src/hooks/useImageUpload.ts | 13 +-- src/hooks/useMermaidGenerator.ts | 38 ++++++++- src/hooks/useStorageSettings.ts | 68 ++++++++++++++- src/hooks/useTitleValidation.ts | 5 +- src/i18n/locales/en/aiChat.json | 4 +- src/i18n/locales/en/common.json | 46 +++++++++- src/i18n/locales/en/editor.json | 1 + src/i18n/locales/en/errors.json | 57 ++++++++++++- src/i18n/locales/ja/aiChat.json | 4 +- src/i18n/locales/ja/common.json | 46 +++++++++- src/i18n/locales/ja/editor.json | 1 + src/i18n/locales/ja/errors.json | 57 ++++++++++++- src/lib/aiClient.ts | 28 +++---- src/lib/aiService.ts | 13 +-- src/lib/aiServiceModels.ts | 38 +++++---- src/lib/aiServiceServer.ts | 13 +-- src/lib/aiSettings.ts | 3 +- src/lib/contentUtils.ts | 18 ++-- src/lib/dateUtils.ts | 56 +++++++++---- src/lib/storageSettings.ts | 3 +- src/lib/thumbnailCommit.ts | 12 +-- src/lib/webClipper/getClipErrorMessage.ts | 36 +++++--- src/pages/IndexPage.tsx | 46 ++++++---- src/pages/NotePageView.test.tsx | 8 +- src/pages/NotePageView.tsx | 9 +- src/pages/Notes.tsx | 4 +- src/pages/SearchResults.tsx | 29 ++++--- 39 files changed, 645 insertions(+), 149 deletions(-) diff --git a/src/components/ai-chat/AIChatContextBar.tsx b/src/components/ai-chat/AIChatContextBar.tsx index a5ccf174..4f0ef752 100644 --- a/src/components/ai-chat/AIChatContextBar.tsx +++ b/src/components/ai-chat/AIChatContextBar.tsx @@ -36,7 +36,7 @@ export function AIChatContextBar() { diff --git a/src/components/ai-chat/AIChatInput.tsx b/src/components/ai-chat/AIChatInput.tsx index f106c269..1cb3c4be 100644 --- a/src/components/ai-chat/AIChatInput.tsx +++ b/src/components/ai-chat/AIChatInput.tsx @@ -108,12 +108,12 @@ export function AIChatInput({ )} onMouseDown={(e) => { e.preventDefault(); - selectMentionPage({ id: page.id, title: page.title || "無題のページ" }); + selectMentionPage({ id: page.id, title: page.title || t("common.untitledPage") }); }} onMouseEnter={() => setMentionIndex(idx)} > - {page.title || "無題のページ"} + {page.title || t("common.untitledPage")} ))} diff --git a/src/components/ai-chat/AIChatMessage.test.tsx b/src/components/ai-chat/AIChatMessage.test.tsx index 541e191f..739ab1a9 100644 --- a/src/components/ai-chat/AIChatMessage.test.tsx +++ b/src/components/ai-chat/AIChatMessage.test.tsx @@ -6,6 +6,11 @@ vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => (key === "aiChat.messages.loadingSkeleton" ? "Loading response" : key), }), + // i18n インスタンスを直接 import している lib の初期化が読み込まれるため、 + // initReactI18next も最低限のモックを返す。 + // Lib code that imports the `i18n` instance pulls `i18n/index.ts` which + // calls `i18n.use(initReactI18next)`, so the mock must export it too. + initReactI18next: { type: "3rdParty", init: () => undefined }, })); import { AIChatMessage } from "./AIChatMessage"; diff --git a/src/components/ai-chat/aiChatInputHelpers.ts b/src/components/ai-chat/aiChatInputHelpers.ts index 2e2409be..8d0cf5a1 100644 --- a/src/components/ai-chat/aiChatInputHelpers.ts +++ b/src/components/ai-chat/aiChatInputHelpers.ts @@ -1,3 +1,4 @@ +import i18n from "@/i18n"; import type { ReferencedPage } from "../../types/aiChat"; const FILE_TEXT_SVG = @@ -11,7 +12,13 @@ function escapeHtml(s: string): string { .replace(/"/g, """); } +/** + * + */ export function createChipElement(id: string, title: string): HTMLSpanElement { + /** + * + */ const chip = document.createElement("span"); chip.contentEditable = "false"; chip.dataset.pageId = id; @@ -23,13 +30,25 @@ export function createChipElement(id: string, title: string): HTMLSpanElement { return chip; } +/** + * + */ export function getEditorContentFromEditor(editor: HTMLDivElement | null): { text: string; refs: ReferencedPage[]; } { if (!editor) return { text: "", refs: [] }; + /** + * + */ let text = ""; + /** + * + */ const refs: ReferencedPage[] = []; + /** + * + */ const walk = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { text += node.textContent || ""; @@ -52,6 +71,9 @@ export function getEditorContentFromEditor(editor: HTMLDivElement | null): { return { text, refs }; } +/** + * + */ export function insertChipAtCursorInEditor( editor: HTMLDivElement | null, id: string, @@ -59,20 +81,41 @@ export function insertChipAtCursorInEditor( onAfter: () => void, ): void { if (!editor) return; + /** + * + */ const chip = createChipElement(id, title); + /** + * + */ const sel = window.getSelection(); + /** + * + */ let inserted = false; if (sel && sel.rangeCount && editor.contains(sel.anchorNode)) { + /** + * + */ const range = sel.getRangeAt(0); range.deleteContents(); range.insertNode(chip); inserted = true; } if (!inserted) editor.appendChild(chip); + /** + * + */ const spacer = document.createTextNode("\u00A0"); chip.after(spacer); + /** + * + */ const cursorSel = window.getSelection(); if (cursorSel) { + /** + * + */ const r = document.createRange(); r.setStartAfter(spacer); r.collapse(true); @@ -83,32 +126,71 @@ export function insertChipAtCursorInEditor( editor.focus(); } +/** + * + */ export function replaceMentionWithChip( editor: HTMLDivElement | null, page: { id: string; title: string }, onAfter: () => void, ): void { if (!editor) return; - const title = page.title || "無題のページ"; + /** + * + */ + const title = page.title || i18n.t("common.untitledPage"); + /** + * + */ const sel = window.getSelection(); if (!sel || !sel.rangeCount) return; + /** + * + */ const range = sel.getRangeAt(0); if (!editor.contains(range.startContainer)) return; + /** + * + */ const node = range.startContainer; if (node.nodeType !== Node.TEXT_NODE) return; + /** + * + */ const text = node.textContent || ""; + /** + * + */ const cursorPos = range.startOffset; + /** + * + */ const beforeCursor = text.slice(0, cursorPos); + /** + * + */ const lastAt = beforeCursor.lastIndexOf("@"); if (lastAt < 0) return; + /** + * + */ const chip = createChipElement(page.id, title); + /** + * + */ const deleteRange = document.createRange(); deleteRange.setStart(node, lastAt); deleteRange.setEnd(node, cursorPos); deleteRange.deleteContents(); deleteRange.insertNode(chip); + /** + * + */ const spacer = document.createTextNode("\u00A0"); chip.after(spacer); + /** + * + */ const newRange = document.createRange(); newRange.setStartAfter(spacer); newRange.collapse(true); diff --git a/src/components/editor/MermaidGeneratorDialog.test.tsx b/src/components/editor/MermaidGeneratorDialog.test.tsx index 43336cad..1f15eb70 100644 --- a/src/components/editor/MermaidGeneratorDialog.test.tsx +++ b/src/components/editor/MermaidGeneratorDialog.test.tsx @@ -38,6 +38,9 @@ vi.mock("@/hooks/useMermaidGenerator", () => ({ vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "ja" } }), + // i18n インスタンスを直接 import している lib (例: aiSettings) が + // i18n/index.ts を読み込むため、initReactI18next の最低限のモックを返す。 + initReactI18next: { type: "3rdParty", init: () => undefined }, })); vi.mock("mermaid", () => ({ diff --git a/src/components/editor/PageEditor/PageEditorHeader.test.tsx b/src/components/editor/PageEditor/PageEditorHeader.test.tsx index 21149bf2..af99db4e 100644 --- a/src/components/editor/PageEditor/PageEditorHeader.test.tsx +++ b/src/components/editor/PageEditor/PageEditorHeader.test.tsx @@ -19,6 +19,22 @@ vi.mock("@/lib/dateUtils", () => ({ formatTimeAgo: (ts: number) => `formatted:${ts}`, })); +// `useTranslation` をモックして、i18n インスタンス未初期化エラーを避けつつ +// `editor.savedAt` の実テンプレートを再現する。 +// Stub `useTranslation` so the test does not boot i18next, and reproduce the +// actual `editor.savedAt` template behaviour. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (key === "editor.savedAt" && opts?.relative !== undefined) { + return `${String(opts.relative)}に保存`; + } + return key; + }, + }), + initReactI18next: { type: "3rdParty", init: () => undefined }, +})); + vi.mock("@/contexts/GlobalSearchContext", () => ({ useGlobalSearchContextOptional: () => null, })); diff --git a/src/components/editor/PageEditor/PageEditorHeader.tsx b/src/components/editor/PageEditor/PageEditorHeader.tsx index 9cc6a8c4..a67c7f23 100644 --- a/src/components/editor/PageEditor/PageEditorHeader.tsx +++ b/src/components/editor/PageEditor/PageEditorHeader.tsx @@ -193,7 +193,7 @@ export const PageEditorHeader: React.FC = ({ )} {lastSaved && ( - {formatTimeAgo(lastSaved)}に保存 + {t("editor.savedAt", { relative: formatTimeAgo(lastSaved) })} )} diff --git a/src/hooks/useAIChatExecute.ts b/src/hooks/useAIChatExecute.ts index acb68236..bb319a57 100644 --- a/src/hooks/useAIChatExecute.ts +++ b/src/hooks/useAIChatExecute.ts @@ -1,4 +1,5 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react"; +import i18n from "@/i18n"; import type { ChatMessage, ChatTreeState, @@ -127,7 +128,7 @@ export async function executeSendMessage(params: ExecuteSendMessageParams): Prom } settings = loaded; } catch (err) { - const errorMessage = err instanceof Error ? err.message : "AI設定の読み込みに失敗しました"; + const errorMessage = err instanceof Error ? err.message : i18n.t("errors.aiSettingsLoadFailed"); patchAssistantSettingsLoadFailure( setTree, assistantMessageId, diff --git a/src/hooks/useAIChatExecuteRegenerate.ts b/src/hooks/useAIChatExecuteRegenerate.ts index 09260b48..a88deab1 100644 --- a/src/hooks/useAIChatExecuteRegenerate.ts +++ b/src/hooks/useAIChatExecuteRegenerate.ts @@ -1,4 +1,5 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react"; +import i18n from "@/i18n"; import type { ChatTreeState, PageContext, TreeChatMessage } from "../types/aiChat"; import type { AIServiceRequest } from "../lib/aiService"; import { loadAISettings } from "../lib/aiSettings"; @@ -94,7 +95,7 @@ export async function executeRegenerateAssistant( } settings = loaded; } catch (err) { - const errorMessage = err instanceof Error ? err.message : "AI設定の読み込みに失敗しました"; + const errorMessage = err instanceof Error ? err.message : i18n.t("errors.aiSettingsLoadFailed"); patchAssistantSettingsLoadFailure( setTree, newAssistantId, diff --git a/src/hooks/useAISettings.ts b/src/hooks/useAISettings.ts index 8335ace0..505e84b3 100644 --- a/src/hooks/useAISettings.ts +++ b/src/hooks/useAISettings.ts @@ -1,6 +1,7 @@ // AI設定を管理するカスタムフック import { useState, useEffect, useCallback } from "react"; +import i18n from "@/i18n"; import { AISettings, getDefaultModel, getDefaultModels, getProviderById } from "@/types/ai"; import { loadAISettings, @@ -154,7 +155,7 @@ export function useAISettings(): UseAISettingsReturn { } catch (error) { const errorResult: ConnectionTestResult = { success: false, - message: "テストの実行に失敗しました", + message: i18n.t("errors.testRunFailed"), error: error instanceof Error ? error.message : "Unknown error", }; setTestResult(errorResult); diff --git a/src/hooks/useGlobalSearch.ts b/src/hooks/useGlobalSearch.ts index 72dd4360..403b86ad 100644 --- a/src/hooks/useGlobalSearch.ts +++ b/src/hooks/useGlobalSearch.ts @@ -1,4 +1,5 @@ import { useState, useMemo } from "react"; +import i18n from "@/i18n"; import { useSearchPages, useSearchSharedNotes } from "./usePageQueries"; import { useDebouncedValue } from "./useDebouncedValue"; import { extractPlainText } from "@/lib/contentUtils"; @@ -157,7 +158,7 @@ export function buildGlobalSearchResults( const highlightedText = highlightKeywords(matchedText, keywords); return { pageId: page.id, - title: page.title || "無題のページ", + title: page.title || i18n.t("common.untitledPage"), highlightedText, matchType, sourceUrl: page.sourceUrl, @@ -179,8 +180,8 @@ export function buildGlobalSearchResults( // Only note-native rows route under /notes; bare linked personal rows // (`note_id IS NULL`) keep the personal /pages destination. noteId: r.note_id ?? undefined, - title: r.title ?? "無題のページ", - highlightedText: highlightedText || "(共有ノート)", + title: r.title?.trim() ? r.title : i18n.t("common.untitledPage"), + highlightedText: highlightedText || i18n.t("common.sharedNoteContext"), matchType: "content" as MatchType, sourceUrl: r.source_url ?? undefined, score: 0, diff --git a/src/hooks/useImageUpload.ts b/src/hooks/useImageUpload.ts index f9a42d18..69f52e77 100644 --- a/src/hooks/useImageUpload.ts +++ b/src/hooks/useImageUpload.ts @@ -1,6 +1,7 @@ // 画像アップロードを管理するカスタムフック import { useState, useCallback } from "react"; +import i18n from "@/i18n"; import { useStorageSettings } from "./useStorageSettings"; import { useAuth } from "./useAuth"; import { @@ -69,11 +70,11 @@ export function useImageUpload(): UseImageUploadReturn { throwIfAborted(); if (!isStorageConfiguredForUpload(settings)) { - throw new Error("ストレージが設定されていません。設定画面でストレージを設定してください。"); + throw new Error(i18n.t("errors.storageNotConfigured")); } if (!file.type.startsWith("image/")) { - throw new Error("画像ファイルのみアップロードできます"); + throw new Error(i18n.t("errors.imageOnly")); } setState((prev) => ({ @@ -126,7 +127,8 @@ export function useImageUpload(): UseImageUploadReturn { })); throw error; } - const errorMessage = error instanceof Error ? error.message : "アップロードに失敗しました"; + const errorMessage = + error instanceof Error ? error.message : i18n.t("errors.imageUploadFailed"); setState((prev) => ({ ...prev, isUploading: false, @@ -150,7 +152,7 @@ export function useImageUpload(): UseImageUploadReturn { const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (imageFiles.length === 0) { - throw new Error("画像ファイルが選択されていません"); + throw new Error(i18n.t("errors.imageNotSelected")); } setState((prev) => ({ @@ -166,7 +168,8 @@ export function useImageUpload(): UseImageUploadReturn { return urls; } catch (error) { - const errorMessage = error instanceof Error ? error.message : "アップロードに失敗しました"; + const errorMessage = + error instanceof Error ? error.message : i18n.t("errors.imageUploadFailed"); setState((prev) => ({ ...prev, isUploading: false, diff --git a/src/hooks/useMermaidGenerator.ts b/src/hooks/useMermaidGenerator.ts index 62ce56b3..466c7212 100644 --- a/src/hooks/useMermaidGenerator.ts +++ b/src/hooks/useMermaidGenerator.ts @@ -1,4 +1,5 @@ import { useState, useCallback } from "react"; +import i18n from "@/i18n"; import { generateMermaidDiagram, getAISettingsOrThrow, @@ -6,6 +7,9 @@ import { MermaidGeneratorResult, } from "@/lib/mermaidGenerator"; +/** + * + */ export type MermaidGeneratorStatus = "idle" | "generating" | "completed" | "error"; interface UseMermaidGeneratorReturn { @@ -18,12 +22,30 @@ interface UseMermaidGeneratorReturn { checkAIConfigured: () => Promise; } +/** + * + */ export function useMermaidGenerator(): UseMermaidGeneratorReturn { + /** + * + */ const [status, setStatus] = useState("idle"); + /** + * + */ const [result, setResult] = useState(null); + /** + * + */ const [error, setError] = useState(null); + /** + * + */ const [isAIConfigured, setIsAIConfigured] = useState(null); + /** + * + */ const checkAIConfigured = useCallback(async (): Promise => { try { await getAISettingsOrThrow(); @@ -35,15 +57,18 @@ export function useMermaidGenerator(): UseMermaidGeneratorReturn { } }, []); + /** + * + */ const generate = useCallback(async (text: string, diagramTypes: MermaidDiagramType[]) => { if (!text.trim()) { - setError(new Error("テキストが空です")); + setError(new Error(i18n.t("errors.mermaidEmptyText"))); setStatus("error"); return; } if (diagramTypes.length === 0) { - setError(new Error("ダイアグラムタイプを選択してください")); + setError(new Error(i18n.t("errors.mermaidTypeRequired"))); setStatus("error"); return; } @@ -64,12 +89,19 @@ export function useMermaidGenerator(): UseMermaidGeneratorReturn { }, }); } catch (err) { - const error = err instanceof Error ? err : new Error("生成中にエラーが発生しました"); + /** + * + */ + const error = + err instanceof Error ? err : new Error(i18n.t("errors.mermaidGenerationFailed")); setError(error); setStatus("error"); } }, []); + /** + * + */ const reset = useCallback(() => { setStatus("idle"); setResult(null); diff --git a/src/hooks/useStorageSettings.ts b/src/hooks/useStorageSettings.ts index 885537d4..864c987f 100644 --- a/src/hooks/useStorageSettings.ts +++ b/src/hooks/useStorageSettings.ts @@ -1,6 +1,7 @@ // ストレージ設定を管理するカスタムフック import { useState, useEffect, useCallback } from "react"; +import i18n from "@/i18n"; import { StorageSettings } from "@/types/storage"; import { loadStorageSettings, @@ -28,18 +29,45 @@ interface UseStorageSettingsReturn { reset: () => void; } +/** + * + */ export function useStorageSettings(): UseStorageSettingsReturn { + /** + * + */ const { getToken, isSignedIn } = useAuth(); + /** + * + */ const [settings, setSettings] = useState(getDefaultStorageSettings()); + /** + * + */ const [isLoading, setIsLoading] = useState(true); + /** + * + */ const [isSaving, setIsSaving] = useState(false); + /** + * + */ const [isTesting, setIsTesting] = useState(false); + /** + * + */ const [testResult, setTestResult] = useState(null); // 初期読み込み useEffect(() => { + /** + * + */ const load = async () => { try { + /** + * + */ const loaded = await loadStorageSettings(); if (loaded) { setSettings(loaded); @@ -54,8 +82,14 @@ export function useStorageSettings(): UseStorageSettingsReturn { }, []); // 設定を更新する + /** + * + */ const updateSettings = useCallback((updates: Partial) => { setSettings((prev) => { + /** + * + */ const newSettings = { ...prev, ...updates }; // 外部ストレージに切り替えたときに provider が s3 のままなら外部の先頭に合わせる @@ -77,6 +111,9 @@ export function useStorageSettings(): UseStorageSettingsReturn { }, []); // 設定のconfigを更新する + /** + * + */ const updateConfig = useCallback((updates: Partial) => { setSettings((prev) => ({ ...prev, @@ -87,9 +124,15 @@ export function useStorageSettings(): UseStorageSettingsReturn { }, []); // 設定を保存する + /** + * + */ const save = useCallback(async (): Promise => { setIsSaving(true); try { + /** + * + */ const settingsToSave: StorageSettings = { ...settings, isConfigured: isStorageConfiguredForUpload(settings), @@ -106,11 +149,17 @@ export function useStorageSettings(): UseStorageSettingsReturn { }, [settings]); // 接続テストを実行する(デフォルト優先のときはデフォルトストレージ、外部のときは選択中の外部プロバイダー) + /** + * + */ const test = useCallback(async (): Promise => { if (!isSignedIn && settings.preferDefaultStorage !== false) { + /** + * + */ const noAuthResult: ConnectionTestResult = { success: false, - message: "デフォルトストレージのテストにはサインインが必要です", + message: i18n.t("errors.defaultStorageSignInRequired"), }; setTestResult(noAuthResult); return noAuthResult; @@ -120,18 +169,30 @@ export function useStorageSettings(): UseStorageSettingsReturn { setTestResult(null); try { + /** + * + */ const settingsToTest = settings.preferDefaultStorage !== false ? { ...settings, provider: "s3" as const, config: {} } : settings; + /** + * + */ const provider = getStorageProvider(settingsToTest, { getToken }); + /** + * + */ const result = await provider.testConnection(); setTestResult(result); return result; } catch (error) { + /** + * + */ const errorResult: ConnectionTestResult = { success: false, - message: "テストの実行に失敗しました", + message: i18n.t("errors.testRunFailed"), error: error instanceof Error ? error.message : "Unknown error", }; setTestResult(errorResult); @@ -142,6 +203,9 @@ export function useStorageSettings(): UseStorageSettingsReturn { }, [settings, getToken, isSignedIn]); // 設定をリセットする + /** + * + */ const reset = useCallback(() => { clearStorageSettings(); setSettings(getDefaultStorageSettings()); diff --git a/src/hooks/useTitleValidation.ts b/src/hooks/useTitleValidation.ts index 0f381bdb..24e37335 100644 --- a/src/hooks/useTitleValidation.ts +++ b/src/hooks/useTitleValidation.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import i18n from "@/i18n"; import { useCheckDuplicateTitle } from "./usePageQueries"; import type { Page } from "@/types/page"; @@ -52,7 +53,7 @@ export function useTitleValidation(options: UseTitleValidationOptions) { duplicatePage: null, isValidating: false, isEmpty: true, - errorMessage: isNewPage ? null : "タイトルを入力してください", + errorMessage: isNewPage ? null : i18n.t("errors.titleRequired"), }); return; } @@ -80,7 +81,7 @@ export function useTitleValidation(options: UseTitleValidationOptions) { isValidating: false, isEmpty: false, errorMessage: duplicate - ? `「${duplicate.title}」というタイトルのページが既に存在します` + ? i18n.t("errors.titleDuplicate", { title: duplicate.title }) : null, }); }, debounceMs); diff --git a/src/i18n/locales/en/aiChat.json b/src/i18n/locales/en/aiChat.json index 2270333f..e32c97d5 100644 --- a/src/i18n/locales/en/aiChat.json +++ b/src/i18n/locales/en/aiChat.json @@ -30,7 +30,9 @@ "withContext": "Ask about this page..." }, "context": { - "referencing": "Referencing \"{{title}}\"" + "referencing": "Referencing \"{{title}}\"", + "disable": "Disable context", + "enable": "Enable context" }, "welcome": { "editor": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index e185ff05..620f9dde 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -36,5 +36,49 @@ "useWithoutSignIn": "Use without signing in", "startUsingApp": "Start using the app", "createPageFailed": "Failed to create page", - "copyFailed": "Copy failed" + "copyFailed": "Copy failed", + "untitledPage": "Untitled page", + "sharedNoteContext": "(Shared note)", + "newConversation": "New conversation", + "search": { + "resultsHeading": "Results for \"{{query}}\"", + "resultsCount_one": "{{count}} result", + "resultsCount_other": "{{count}} results", + "promptHeading": "Enter a search keyword", + "minChars": "Please enter at least 3 characters", + "emptyTitle": "No results found", + "emptyDescription": "Try a different keyword", + "initialHint": "Use the search bar in the header to enter a keyword" + }, + "listSeparator": ", ", + "date": { + "today": "Today ({{date}})", + "yesterday": "Yesterday ({{date}})", + "justNow": "Just now", + "minutesAgo_one": "{{count}} min ago", + "minutesAgo_other": "{{count}} min ago", + "hoursAgo_one": "{{count}} hr ago", + "hoursAgo_other": "{{count}} hr ago", + "daysAgo_one": "{{count}} day ago", + "daysAgo_other": "{{count}} days ago", + "format": { + "labelToday": "MMM d (EEE)", + "labelOther": "MMM d (EEE)", + "shortMonthDay": "M/d", + "fullDateTime": "MMM d, yyyy HH:mm" + } + }, + "wikiIndex": { + "pageTitle": "Wiki Index", + "backToHome": "Back to home", + "rebuild": "Rebuild", + "description": "Auto-generated category index (equivalent to Karpathy's LLM Wiki index.md). Rebuild after adding or updating pages to refresh categories.", + "totalPagesLabel": "Total pages:", + "categoryCountLabel": "Categories:", + "lastBuiltAt": "Last built at {{date}}", + "notBuiltYet": "Not built yet", + "openIndexPage": "Open __index__ page", + "noPagesYet": "No pages yet.", + "pageCount": "{{count}} pages" + } } diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 1d4714f4..c33cdf04 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -1,5 +1,6 @@ { "uploadedImageAlt": "Uploaded image", + "savedAt": "Saved {{relative}}", "slashMenuAriaLabel": "Slash command menu", "slashNoResults": "No matching commands", "slashAgentRunning": "Running Claude Code…", diff --git a/src/i18n/locales/en/errors.json b/src/i18n/locales/en/errors.json index 29fc9bfb..bb205d40 100644 --- a/src/i18n/locales/en/errors.json +++ b/src/i18n/locales/en/errors.json @@ -1,5 +1,60 @@ { "notFoundTitle": "Page not found", "notFoundDescription": "The page you're looking for doesn't exist or may have been moved.", - "backToHome": "Back to home" + "backToHome": "Back to home", + "titleSaveFailedTitle": "Failed to save title", + "titleSaveFailedDescription": "Check your network connection and try again.", + "titleRequired": "Please enter a title", + "titleDuplicate": "A page titled \"{{title}}\" already exists", + "imageOnly": "Only image files can be uploaded", + "imageNotSelected": "No image file selected", + "imageUploadFailed": "Upload failed", + "storageNotConfigured": "Storage is not configured. Please configure storage in settings.", + "defaultStorageSignInRequired": "Sign-in is required to test the default storage", + "aiSettingsLoadFailed": "Failed to load AI settings", + "aiSettingsSaveFailed": "Failed to save AI settings", + "storageSettingsSaveFailed": "Failed to save storage settings", + "generalSettingsSaveFailed": "Failed to save general settings", + "testRunFailed": "Failed to run test", + "indexRebuildFailed": "Failed to rebuild Index", + "mermaidEmptyText": "Text is empty", + "mermaidTypeRequired": "Please select a diagram type", + "mermaidGenerationFailed": "An error occurred during generation", + "apiKeyInvalid": "API key is invalid", + "apiKeyRequired": "Please enter an API key", + "apiConnectionFailed": "Connection failed", + "anthropicConnectionSuccess": "Connected. Anthropic API is available.", + "connectionSuccessWithModels": "Connected. {{count}} models are available.", + "unknownProvider": "Unknown provider: {{provider}}", + "unexpectedResponseFormat": "Unexpected response format", + "claudeCodeConnectionTestUnsupported": "Claude Code does not support API-key connection tests. Check availability in settings.", + "aiApiCallError": "AI API call error", + "claudeCodeCallError": "Claude Code call error", + "claudeCodeUnavailable": "Claude Code is not available. Make sure Claude Code is installed in the desktop app.", + "aiApiServerUrlMissing": "AI API server URL is not configured", + "streamingResponseUnavailable": "Streaming response is not available", + "streamingResponseEmpty": "Streaming response was disconnected without data", + "apiUrlNotConfigured": "API URL is not configured", + "imageSaveFailed": "Failed to save image: {{status}}", + "imageSaveTimeout": "Image save request timed out", + "loginRequired": "Sign-in is required", + "imageUrlNotReturned": "Could not obtain image URL", + "apiError": "An API error occurred", + "networkUnreachable": "Network error: cannot reach {{url}}. Check CORS or the URL.", + "requestFailed": "Request failed: {{message}}", + "responseReadFailed": "Failed to read response: {{message}}", + "apiResponseNotJson": "Response is not JSON", + "apiResponseInvalid": "API response format is invalid", + "apiBaseUrlMissing": "VITE_API_BASE_URL is not configured. Set the API server URL in .env.", + "contentInvalid": "There was a problem with the content.", + "contentUnsupportedNode": "Unsupported node: {{types}}", + "contentUnsupportedMark": "Unsupported mark: {{types}}", + "migrationDataIssue": "There was an issue with migration data. {{fields}} were detected and automatically corrected.", + "webClipInvalidUrl": "Please enter a valid URL.", + "webClipNetworkError": "A network error occurred. Check your connection.", + "webClipTimeout": "The request timed out. Please try again later.", + "webClipExtractFailed": "Failed to extract content. This page may not be supported.", + "webClipFetchFailed": "Failed to fetch the page. Verify the URL.", + "webClipGenericError": "An error occurred. Please try again later.", + "webClipUnknownError": "An unexpected error occurred." } diff --git a/src/i18n/locales/ja/aiChat.json b/src/i18n/locales/ja/aiChat.json index 81a90215..f4d1b7b1 100644 --- a/src/i18n/locales/ja/aiChat.json +++ b/src/i18n/locales/ja/aiChat.json @@ -30,7 +30,9 @@ "withContext": "このページについて質問..." }, "context": { - "referencing": "「{{title}}」を参照中" + "referencing": "「{{title}}」を参照中", + "disable": "コンテキストを無効化", + "enable": "コンテキストを有効化" }, "welcome": { "editor": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 72aade53..d124c2d2 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -36,5 +36,49 @@ "useWithoutSignIn": "ログインせずに使う", "startUsingApp": "アプリを使い始める", "createPageFailed": "ページの作成に失敗しました", - "copyFailed": "コピーに失敗しました" + "copyFailed": "コピーに失敗しました", + "untitledPage": "無題のページ", + "sharedNoteContext": "(共有ノート)", + "newConversation": "新しい会話", + "search": { + "resultsHeading": "「{{query}}」の検索結果", + "resultsCount_one": "{{count}}件", + "resultsCount_other": "{{count}}件", + "promptHeading": "検索キーワードを入力してください", + "minChars": "3文字以上入力してください", + "emptyTitle": "検索結果が見つかりません", + "emptyDescription": "別のキーワードで検索してみてください", + "initialHint": "ヘッダーの検索バーからキーワードを入力してください" + }, + "listSeparator": "、", + "date": { + "today": "今日({{date}})", + "yesterday": "昨日({{date}})", + "justNow": "たった今", + "minutesAgo_one": "{{count}}分前", + "minutesAgo_other": "{{count}}分前", + "hoursAgo_one": "{{count}}時間前", + "hoursAgo_other": "{{count}}時間前", + "daysAgo_one": "{{count}}日前", + "daysAgo_other": "{{count}}日前", + "format": { + "labelToday": "M月d日・E", + "labelOther": "M月d日(E)", + "shortMonthDay": "M/d", + "fullDateTime": "yyyy年M月d日 HH:mm" + } + }, + "wikiIndex": { + "pageTitle": "Wiki Index / カテゴリ目次", + "backToHome": "ホームへ戻る", + "rebuild": "再構築", + "description": "AI が生成するカテゴリ別目次(Karpathy LLM Wiki の index.md 相当)。ページを追加・更新したあとに再構築するとカテゴリが更新されます。", + "totalPagesLabel": "対象ページ数:", + "categoryCountLabel": "カテゴリ数:", + "lastBuiltAt": "最終ビルド: {{date}}", + "notBuiltYet": "まだ再構築されていません", + "openIndexPage": "__index__ ページを開く", + "noPagesYet": "まだページがありません。", + "pageCount": "{{count}} ページ" + } } diff --git a/src/i18n/locales/ja/editor.json b/src/i18n/locales/ja/editor.json index e703a953..b12e07d2 100644 --- a/src/i18n/locales/ja/editor.json +++ b/src/i18n/locales/ja/editor.json @@ -1,5 +1,6 @@ { "uploadedImageAlt": "アップロードした画像", + "savedAt": "{{relative}}に保存", "slashMenuAriaLabel": "スラッシュコマンドメニュー", "slashNoResults": "一致する項目がありません", "slashAgentRunning": "Claude Code を実行中…", diff --git a/src/i18n/locales/ja/errors.json b/src/i18n/locales/ja/errors.json index fe7fcb21..6857b13b 100644 --- a/src/i18n/locales/ja/errors.json +++ b/src/i18n/locales/ja/errors.json @@ -1,5 +1,60 @@ { "notFoundTitle": "ページが見つかりません", "notFoundDescription": "お探しのページは存在しないか、移動した可能性があります。", - "backToHome": "ホームに戻る" + "backToHome": "ホームに戻る", + "titleSaveFailedTitle": "タイトルの保存に失敗しました", + "titleSaveFailedDescription": "通信環境を確認し、再度お試しください。", + "titleRequired": "タイトルを入力してください", + "titleDuplicate": "「{{title}}」というタイトルのページが既に存在します", + "imageOnly": "画像ファイルのみアップロードできます", + "imageNotSelected": "画像ファイルが選択されていません", + "imageUploadFailed": "アップロードに失敗しました", + "storageNotConfigured": "ストレージが設定されていません。設定画面でストレージを設定してください。", + "defaultStorageSignInRequired": "デフォルトストレージのテストにはサインインが必要です", + "aiSettingsLoadFailed": "AI設定の読み込みに失敗しました", + "aiSettingsSaveFailed": "AI設定の保存に失敗しました", + "storageSettingsSaveFailed": "ストレージ設定の保存に失敗しました", + "generalSettingsSaveFailed": "一般設定の保存に失敗しました", + "testRunFailed": "テストの実行に失敗しました", + "indexRebuildFailed": "Index の再構築に失敗しました", + "mermaidEmptyText": "テキストが空です", + "mermaidTypeRequired": "ダイアグラムタイプを選択してください", + "mermaidGenerationFailed": "生成中にエラーが発生しました", + "apiKeyInvalid": "APIキーが無効です", + "apiKeyRequired": "APIキーを入力してください", + "apiConnectionFailed": "接続に失敗しました", + "anthropicConnectionSuccess": "接続成功! Anthropic APIが利用可能です", + "connectionSuccessWithModels": "接続成功! {{count}}個のモデルが利用可能です", + "unknownProvider": "不明なプロバイダー: {{provider}}", + "unexpectedResponseFormat": "予期しないレスポンス形式です", + "claudeCodeConnectionTestUnsupported": "Claude Code は API キー接続テスト非対応です。設定画面で利用可否を確認してください。", + "aiApiCallError": "AI API呼び出しエラー", + "claudeCodeCallError": "Claude Code 呼び出しエラー", + "claudeCodeUnavailable": "Claude Code が利用できません。デスクトップアプリで Claude Code がインストールされていることを確認してください。", + "aiApiServerUrlMissing": "AI APIサーバーのURLが設定されていません", + "streamingResponseUnavailable": "ストリーミングレスポンスが取得できません", + "streamingResponseEmpty": "ストリーミングレスポンスが空のまま切断されました", + "apiUrlNotConfigured": "APIのURLが設定されていません", + "imageSaveFailed": "画像の保存に失敗しました: {{status}}", + "imageSaveTimeout": "画像保存のリクエストがタイムアウトしました", + "loginRequired": "ログインが必要です", + "imageUrlNotReturned": "画像のURLが取得できませんでした", + "apiError": "API エラーが発生しました", + "networkUnreachable": "ネットワークエラー: {{url}} に接続できません。CORS または URL を確認してください。", + "requestFailed": "リクエスト失敗: {{message}}", + "responseReadFailed": "レスポンスの読み取りに失敗しました: {{message}}", + "apiResponseNotJson": "レスポンスが JSON ではありません", + "apiResponseInvalid": "API のレスポンス形式が不正です", + "apiBaseUrlMissing": "VITE_API_BASE_URL が設定されていません。.env に API サーバーの URL を設定してください。", + "contentInvalid": "コンテンツに問題がありました。", + "contentUnsupportedNode": "未対応のノード: {{types}}", + "contentUnsupportedMark": "未対応のマーク: {{types}}", + "migrationDataIssue": "移行データに問題があります。{{fields}}が含まれていたため自動的に修正されました。", + "webClipInvalidUrl": "有効なURLを入力してください。", + "webClipNetworkError": "ネットワークエラーが発生しました。接続を確認してください。", + "webClipTimeout": "取得がタイムアウトしました。しばらくしてから再試行してください。", + "webClipExtractFailed": "本文の抽出に失敗しました。このページは対応していない可能性があります。", + "webClipFetchFailed": "ページの取得に失敗しました。URLを確認してください。", + "webClipGenericError": "エラーが発生しました。しばらくしてから再試行してください。", + "webClipUnknownError": "予期しないエラーが発生しました。" } diff --git a/src/lib/aiClient.ts b/src/lib/aiClient.ts index e07d465c..84ba3dcd 100644 --- a/src/lib/aiClient.ts +++ b/src/lib/aiClient.ts @@ -3,6 +3,7 @@ import OpenAI from "openai"; import Anthropic from "@anthropic-ai/sdk"; import { GoogleGenAI } from "@google/genai"; +import i18n from "@/i18n"; import { AISettings, AIProviderType, CachedModels, getDefaultModels } from "@/types/ai"; /** Union of SDK client types used for user-API-key mode. */ @@ -197,7 +198,7 @@ async function testOpenAIConnection(apiKey: string): Promise { const apiBaseUrl = getAIAPIBaseUrl(); if (!apiBaseUrl) { - throw new Error("AI APIサーバーのURLが設定されていません"); + throw new Error(i18n.t("errors.aiApiServerUrlMissing")); } const response = await fetch(`${apiBaseUrl}/api/ai/chat`, { method: "POST", @@ -125,7 +126,7 @@ async function fetchAIChatResponse( } if (!response.ok) { const errorBody = await response.json().catch(() => null); - const message = errorBody?.error || response.statusText || "AI API呼び出しエラー"; + const message = errorBody?.error || response.statusText || i18n.t("errors.aiApiCallError"); throw new Error(message); } return response; @@ -140,7 +141,7 @@ async function handleAIChatHttpResponse( if (request.options?.stream) { const reader = response.body?.getReader(); if (!reader) { - throw new Error("ストリーミングレスポンスが取得できません"); + throw new Error(i18n.t("errors.streamingResponseUnavailable")); } const state = { fullContent: "", @@ -155,7 +156,7 @@ async function handleAIChatHttpResponse( usage: state.lastUsage, }); } else if (!streamCompleted && !state.fullContent) { - callbacks.onError?.(new Error("ストリーミングレスポンスが空のまま切断されました")); + callbacks.onError?.(new Error(i18n.t("errors.streamingResponseEmpty"))); } return; } @@ -182,6 +183,8 @@ export async function callAIWithServer( const response = await fetchAIChatResponse(request, abortSignal); await handleAIChatHttpResponse(response, request, callbacks, abortSignal); } catch (error) { - callbacks.onError?.(error instanceof Error ? error : new Error("AI API呼び出しエラー")); + callbacks.onError?.( + error instanceof Error ? error : new Error(i18n.t("errors.aiApiCallError")), + ); } } diff --git a/src/lib/aiSettings.ts b/src/lib/aiSettings.ts index 6d6b1463..a40418e8 100644 --- a/src/lib/aiSettings.ts +++ b/src/lib/aiSettings.ts @@ -1,6 +1,7 @@ // AI設定の保存/読み込み機能 import { encrypt, decrypt } from "./encryption"; +import i18n from "@/i18n"; import { AISettings, DEFAULT_AI_SETTINGS } from "@/types/ai"; import { isTauriDesktop } from "@/lib/platform"; @@ -33,7 +34,7 @@ export async function saveAISettings(settings: AISettings): Promise { dispatchAISettingsChanged(); } catch (error) { console.error("Failed to save AI settings:", error); - throw new Error("AI設定の保存に失敗しました"); + throw new Error(i18n.t("errors.aiSettingsSaveFailed")); } } diff --git a/src/lib/contentUtils.ts b/src/lib/contentUtils.ts index b7b9b334..12257c71 100644 --- a/src/lib/contentUtils.ts +++ b/src/lib/contentUtils.ts @@ -1,3 +1,5 @@ +import i18n from "@/i18n"; + // Supported node types in zedi's Tiptap schema const SUPPORTED_NODE_TYPES = new Set([ "doc", @@ -468,7 +470,7 @@ export function generateAutoTitle(content: string): string { const plainText = extractPlainText(content); const firstLine = plainText.split("\n")[0]?.trim() || ""; - if (!firstLine) return "無題のページ"; + if (!firstLine) return i18n.t("common.untitledPage"); // Use first 40 characters of the first line if (firstLine.length <= 40) return firstLine; @@ -483,17 +485,23 @@ export function buildContentErrorMessage(result: SanitizeResult): string { const parts: string[] = []; if (result.removedNodeTypes.length > 0) { - parts.push(`未対応のノード: ${result.removedNodeTypes.join(", ")}`); + parts.push( + i18n.t("errors.contentUnsupportedNode", { types: result.removedNodeTypes.join(", ") }), + ); } if (result.removedMarkTypes.length > 0) { - parts.push(`未対応のマーク: ${result.removedMarkTypes.join(", ")}`); + parts.push( + i18n.t("errors.contentUnsupportedMark", { types: result.removedMarkTypes.join(", ") }), + ); } if (parts.length === 0) { - return "コンテンツに問題がありました。"; + return i18n.t("errors.contentInvalid"); } - return `移行データに問題があります。${parts.join("、")}が含まれていたため自動的に修正されました。`; + return i18n.t("errors.migrationDataIssue", { + fields: parts.join(i18n.t("common.listSeparator")), + }); } /** diff --git a/src/lib/dateUtils.ts b/src/lib/dateUtils.ts index 12821ae9..3f9d23bf 100644 --- a/src/lib/dateUtils.ts +++ b/src/lib/dateUtils.ts @@ -1,23 +1,42 @@ import { format, isToday, isYesterday, parseISO } from "date-fns"; -import { ja } from "date-fns/locale"; +import { ja, enUS } from "date-fns/locale"; +import type { Locale } from "date-fns/locale"; +import i18n from "@/i18n"; import type { Page, DateGroup } from "@/types/page"; /** - * Human-readable Japanese label for a calendar date (e.g. `今日(4月19日・日)` / - * `4月19日(日)`). Uses "今日" / "昨日" for today and yesterday, otherwise the - * short month-day-weekday form. + * Returns the active date-fns locale matching the current i18n language. + * Falls back to en-US for any non-`ja` language so adding new locales later + * does not silently render Japanese. * - * カレンダー日を日本語の表示用ラベルに整形する。今日 / 昨日は「今日」「昨日」を - * 前置し、それ以外は「M月d日(曜)」を返す。 + * 現在の i18n 言語に対応する date-fns ロケールを返す。 + * 日本語以外は en-US にフォールバック(将来言語追加時の保守性のため)。 + */ +export function getActiveLocale(): Locale { + const lang = i18n.language?.split("-")[0]; + if (lang === "ja") return ja; + return enUS; +} + +/** + * Human-readable label for a calendar date. Today / Yesterday are prefixed; + * otherwise a short month-day-weekday form. Both the prefix template and the + * underlying date-fns pattern come from the locale file. + * + * カレンダー日を表示用ラベルに整形する。今日 / 昨日は前置し、それ以外は短縮形式。 + * 接頭辞テンプレートと date-fns パターンの双方を locale ファイルから取得する。 */ export function formatDateLabel(date: Date): string { + const locale = getActiveLocale(); if (isToday(date)) { - return `今日(${format(date, "M月d日・E", { locale: ja })})`; + const inner = format(date, i18n.t("common.date.format.labelToday"), { locale }); + return i18n.t("common.date.today", { date: inner }); } if (isYesterday(date)) { - return `昨日(${format(date, "M月d日・E", { locale: ja })})`; + const inner = format(date, i18n.t("common.date.format.labelToday"), { locale }); + return i18n.t("common.date.yesterday", { date: inner }); } - return format(date, "M月d日(E)", { locale: ja }); + return format(date, i18n.t("common.date.format.labelOther"), { locale }); } /** @@ -73,19 +92,22 @@ export function groupPagesByDate(pages: Page[]): DateGroup[] { } /** - * Compact relative-time label in Japanese (e.g. `たった今` / `5分前` / `2時間前` / - * `3日前`). Falls back to an `M/d` date for anything older than a week. + * Compact relative-time label (e.g. "Just now" / "5 min ago" / "2 hr ago" / + * "3 days ago"). Falls back to an `M/d` date for anything older than a week. * - * 日本語の相対時刻ラベル(「たった今」「5分前」「2時間前」「3日前」など)。 + * 相対時刻ラベル(「たった今」「5分前」「2時間前」「3日前」など)。 * 1 週間を超える場合は `M/d` 形式の日付表記にフォールバックする。 */ export function formatTimeAgo(timestamp: number): string { const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 60) return "たった今"; - if (seconds < 3600) return `${Math.floor(seconds / 60)}分前`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)}時間前`; - if (seconds < 604800) return `${Math.floor(seconds / 86400)}日前`; + if (seconds < 60) return i18n.t("common.date.justNow"); + if (seconds < 3600) return i18n.t("common.date.minutesAgo", { count: Math.floor(seconds / 60) }); + if (seconds < 86400) return i18n.t("common.date.hoursAgo", { count: Math.floor(seconds / 3600) }); + if (seconds < 604800) + return i18n.t("common.date.daysAgo", { count: Math.floor(seconds / 86400) }); - return format(new Date(timestamp), "M/d", { locale: ja }); + return format(new Date(timestamp), i18n.t("common.date.format.shortMonthDay"), { + locale: getActiveLocale(), + }); } diff --git a/src/lib/storageSettings.ts b/src/lib/storageSettings.ts index 088dda93..728e6ed8 100644 --- a/src/lib/storageSettings.ts +++ b/src/lib/storageSettings.ts @@ -1,6 +1,7 @@ // ストレージ設定の保存/読み込み機能 import { encrypt, decrypt } from "./encryption"; +import i18n from "@/i18n"; import { StorageSettings, DEFAULT_STORAGE_SETTINGS } from "@/types/storage"; import { isStorageConfiguredForUpload } from "@/lib/storage"; @@ -43,7 +44,7 @@ export async function saveStorageSettings(settings: StorageSettings): Promise { const { baseUrl } = options; if (!baseUrl) { - throw new Error("APIのURLが設定されていません"); + throw new Error(i18n.t("errors.apiUrlNotConfigured")); } const controller = new AbortController(); @@ -78,7 +80,7 @@ export async function commitThumbnailFromUrl( }); } catch (error) { if (error && typeof error === "object" && "name" in error && error.name === "AbortError") { - throw new Error("画像保存のリクエストがタイムアウトしました"); + throw new Error(i18n.t("errors.imageSaveTimeout")); } throw error; } finally { @@ -86,11 +88,11 @@ export async function commitThumbnailFromUrl( } if (response.status === 401) { - throw new AuthRedirectError("ログインが必要です"); + throw new AuthRedirectError(i18n.t("errors.loginRequired")); } if (!response.ok) { - let message = `画像の保存に失敗しました: ${response.status}`; + let message = i18n.t("errors.imageSaveFailed", { status: response.status }); try { const data = (await response.json()) as { error?: string; message?: string }; if (data?.message) message = data.message; @@ -102,6 +104,6 @@ export async function commitThumbnailFromUrl( } const data = (await response.json()) as { imageUrl?: string; provider?: string }; - if (!data.imageUrl) throw new Error("画像のURLが取得できませんでした"); + if (!data.imageUrl) throw new Error(i18n.t("errors.imageUrlNotReturned")); return { imageUrl: data.imageUrl, provider: data.provider ?? "s3" }; } diff --git a/src/lib/webClipper/getClipErrorMessage.ts b/src/lib/webClipper/getClipErrorMessage.ts index 548219b2..e41115e3 100644 --- a/src/lib/webClipper/getClipErrorMessage.ts +++ b/src/lib/webClipper/getClipErrorMessage.ts @@ -1,30 +1,42 @@ /** * クリップエラーをユーザー向けメッセージに変換する。 * Converts clip errors to user-friendly messages. + * + * 上流のエラー文言は呼び出し元のロケールに依存しないよう、日本語・英語の + * 両方の代表的なフラグメントとマシン可読トークンの双方をマッチングする。 + * Match both Japanese / English fragments and machine-readable tokens so the + * upstream message language does not change the classification. */ +import i18n from "@/i18n"; + /** * クリップエラーをユーザーフレンドリーなメッセージに変換する。 * Converts clip error to a user-friendly message. */ export function getClipErrorMessage(error: unknown): string { if (error instanceof Error) { - if (error.message.includes("有効なURL")) { - return "有効なURLを入力してください。"; + const msg = error.message; + if (msg.includes("有効なURL") || /valid URL/i.test(msg)) { + return i18n.t("errors.webClipInvalidUrl"); } - if (error.message.includes("Failed to fetch") || error.message.includes("NetworkError")) { - return "ネットワークエラーが発生しました。接続を確認してください。"; + if (msg.includes("Failed to fetch") || msg.includes("NetworkError")) { + return i18n.t("errors.webClipNetworkError"); } - if (error.message.includes("Request timed out") || error.message.includes("TIMEOUT")) { - return "取得がタイムアウトしました。しばらくしてから再試行してください。"; + if (msg.includes("Request timed out") || msg.includes("TIMEOUT") || /timed? out/i.test(msg)) { + return i18n.t("errors.webClipTimeout"); } - if (error.message.includes("本文の抽出")) { - return "本文の抽出に失敗しました。このページは対応していない可能性があります。"; + if (msg.includes("本文の抽出") || /extract (content|body)/i.test(msg)) { + return i18n.t("errors.webClipExtractFailed"); } - if (error.message.includes("プロキシ") || error.message.includes("FETCH_FAILED")) { - return "ページの取得に失敗しました。URLを確認してください。"; + if ( + msg.includes("プロキシ") || + msg.includes("FETCH_FAILED") || + /failed to fetch (the )?page|proxy/i.test(msg) + ) { + return i18n.t("errors.webClipFetchFailed"); } - return "エラーが発生しました。しばらくしてから再試行してください。"; + return i18n.t("errors.webClipGenericError"); } - return "予期しないエラーが発生しました。"; + return i18n.t("errors.webClipUnknownError"); } diff --git a/src/pages/IndexPage.tsx b/src/pages/IndexPage.tsx index e0321773..b2a5bd4f 100644 --- a/src/pages/IndexPage.tsx +++ b/src/pages/IndexPage.tsx @@ -14,9 +14,12 @@ import React, { useCallback, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { Loader2, RefreshCw } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { format } from "date-fns"; import { Button, useToast } from "@zedi/ui"; import Container from "@/components/layout/Container"; import { PageHeader } from "@/components/layout/PageHeader"; +import { getActiveLocale } from "@/lib/dateUtils"; /** * Read-only response from GET /api/activity/index. @@ -95,6 +98,7 @@ async function rebuildIndex(): Promise { */ const IndexPage: React.FC = () => { const { toast } = useToast(); + const { t } = useTranslation(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [rebuilding, setRebuilding] = useState(false); @@ -131,11 +135,11 @@ const IndexPage: React.FC = () => { }); } catch (e) { setError(e instanceof Error ? e.message : String(e)); - toast({ title: "Index の再構築に失敗しました", variant: "destructive" }); + toast({ title: t("errors.indexRebuildFailed"), variant: "destructive" }); } finally { setRebuilding(false); } - }, [toast]); + }, [toast, t]); useEffect(() => { void load(); @@ -146,9 +150,9 @@ const IndexPage: React.FC = () => { return (
void rebuild()} disabled={busy} size="sm"> {rebuilding ? ( @@ -156,7 +160,7 @@ const IndexPage: React.FC = () => { ) : ( )} - 再構築 / Rebuild + {t("common.wikiIndex.rebuild")} } /> @@ -164,11 +168,7 @@ const IndexPage: React.FC = () => {
-

- AI が生成するカテゴリ別目次(Karpathy LLM Wiki の - index.md - 相当)。ページを追加・更新したあとに再構築するとカテゴリが更新されます。 -

+

{t("common.wikiIndex.description")}

{error && (
{error}
@@ -184,20 +184,30 @@ const IndexPage: React.FC = () => { <>
- 対象ページ数:{" "} + + {t("common.wikiIndex.totalPagesLabel")} + {" "} {data.totalPages}
- カテゴリ数:{" "} + + {t("common.wikiIndex.categoryCountLabel")} + {" "} {data.categories.length}
{data.timestamp ? (
- Last built at {new Date(data.timestamp).toLocaleString()} + {t("common.wikiIndex.lastBuiltAt", { + date: format( + new Date(data.timestamp), + t("common.date.format.fullDateTime"), + { locale: getActiveLocale() }, + ), + })}
) : (
- まだ再構築されていません / Not built yet + {t("common.wikiIndex.notBuiltYet")}
)} {data.pageId && ( @@ -206,14 +216,14 @@ const IndexPage: React.FC = () => { to={`/pages/${data.pageId}`} className="text-primary text-sm underline underline-offset-2" > - __index__ ページを開く / Open __index__ page + {t("common.wikiIndex.openIndexPage")}
)}
{data.categories.length === 0 ? ( -

まだページがありません。 / No pages yet.

+

{t("common.wikiIndex.noPagesYet")}

) : (
    {data.categories.map((cat) => ( @@ -222,7 +232,9 @@ const IndexPage: React.FC = () => { className="flex items-center justify-between px-4 py-2 text-sm" > {cat.label} - {cat.count} ページ + + {t("common.wikiIndex.pageCount", { count: cat.count })} + ))}
diff --git a/src/pages/NotePageView.test.tsx b/src/pages/NotePageView.test.tsx index 79b20b62..4b974fc5 100644 --- a/src/pages/NotePageView.test.tsx +++ b/src/pages/NotePageView.test.tsx @@ -213,7 +213,7 @@ describe("NotePageView", () => { renderNotePageView(); - expect(screen.getByText("読み込み中...")).toBeInTheDocument(); + expect(screen.getByText("common.loading")).toBeInTheDocument(); }); it("shows not found message when note or page is not found", () => { @@ -331,7 +331,11 @@ describe("NotePageView", () => { expect(screen.getByTestId("page-title")).toHaveTextContent("Original title"); }); expect(mockToast).toHaveBeenCalledWith( - expect.objectContaining({ title: "タイトルの保存に失敗しました", variant: "destructive" }), + expect.objectContaining({ + title: "errors.titleSaveFailedTitle", + description: "errors.titleSaveFailedDescription", + variant: "destructive", + }), ); consoleError.mockRestore(); }); diff --git a/src/pages/NotePageView.tsx b/src/pages/NotePageView.tsx index 93dbcbcd..4974f77c 100644 --- a/src/pages/NotePageView.tsx +++ b/src/pages/NotePageView.tsx @@ -89,6 +89,7 @@ function NotePageEditorEditable({ const updatePageMutation = useUpdatePage(); const queryClient = useQueryClient(); const { toast } = useToast(); + const { t } = useTranslation(); const titleSaveTimerRef = useRef | null>(null); const pendingTitleRef = useRef(null); const isSavingTitleRef = useRef(false); @@ -175,8 +176,8 @@ function NotePageEditorEditable({ } console.error("Failed to save page title:", error); toast({ - title: "タイトルの保存に失敗しました", - description: "通信環境を確認し、再度お試しください。", + title: t("errors.titleSaveFailedTitle"), + description: t("errors.titleSaveFailedDescription"), variant: "destructive", }); throw error; @@ -332,7 +333,7 @@ const NotePageView: React.FC = () => { title: t("notes.pageCopiedToPersonal"), action: result.localImported ? ( ) : undefined, }); @@ -350,7 +351,7 @@ const NotePageView: React.FC = () => { if (isLoading) { return ( -

読み込み中...

+

{t("common.loading")}

); } diff --git a/src/pages/Notes.tsx b/src/pages/Notes.tsx index 879bcee8..90d710bd 100644 --- a/src/pages/Notes.tsx +++ b/src/pages/Notes.tsx @@ -186,9 +186,7 @@ const Notes: React.FC = () => {

{t("notes.title")}

-

- {t("notes.signInRequired", "ノートを利用するにはサインインが必要です")} -

+

{t("notes.signInRequired")}

diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index e399e766..65c4d87e 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -1,5 +1,6 @@ import { useMemo, useEffect } from "react"; import { useSearchParams, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import Container from "@/components/layout/Container"; import { SearchResultCard } from "@/components/search/SearchResultCard"; import type { SearchResultCardItem } from "@/components/search/SearchResultCard"; @@ -31,6 +32,7 @@ interface SearchResultItem extends SearchResultCardItem { export default function SearchResults() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); + const { t } = useTranslation(); const { setQuery } = useGlobalSearchContext(); const searchQuery = (searchParams.get("q") ?? "").trim(); @@ -58,7 +60,7 @@ export default function SearchResults() { const highlightedSnippet = highlightKeywords(snippet, keywords); return { pageId: page.id, - title: page.title || "無題のページ", + title: page.title || t("common.untitledPage"), snippet, highlightedSnippet, matchType, @@ -85,11 +87,14 @@ export default function SearchResults() { ).map((r) => { const preview = r.content_preview ?? ""; const snippet = extractSmartSnippet(preview, keywords, 200); - const highlightedSnippet = highlightKeywords(snippet || "(共有ノート)", keywords); + const highlightedSnippet = highlightKeywords( + snippet || t("common.sharedNoteContext"), + keywords, + ); return { pageId: r.id, noteId: r.note_id ?? undefined, - title: r.title ?? "無題のページ", + title: r.title?.trim() ? r.title : t("common.untitledPage"), snippet, highlightedSnippet, matchType: "content" as MatchType, @@ -101,7 +106,7 @@ export default function SearchResults() { }); return [...personal, ...shared].sort((a, b) => b.score - a.score); - }, [personalResults, sharedResults, searchQuery, keywords]); + }, [personalResults, sharedResults, searchQuery, keywords, t]); /** * Navigates to the note page or standalone page for the clicked result. @@ -130,16 +135,16 @@ export default function SearchResults() {
{searchQuery ? (

- 「{searchQuery}」の検索結果 + {t("common.search.resultsHeading", { query: searchQuery })} {!isLoading && ( - {results.length}件 + {t("common.search.resultsCount", { count: results.length })} )}

) : (

- 検索キーワードを入力してください + {t("common.search.promptHeading")}

)}
@@ -147,13 +152,13 @@ export default function SearchResults() { {isLoading && searchQuery.length >= 3 && } {searchQuery.length > 0 && searchQuery.length < 3 && ( - + )} {!isLoading && searchQuery.length >= 3 && results.length === 0 && ( )} @@ -169,9 +174,7 @@ export default function SearchResults() {
)} - {!searchQuery && ( - - )} + {!searchQuery && }
); From 688099f54896d1462b92227683f0b5f20955137e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:22:49 +0900 Subject: [PATCH 04/22] chore(deps): bump googleapis/release-please-action from 4 to 5 (#770) Bumps [googleapis/release-please-action](https://github.com/googleapis/release-please-action) from 4 to 5. - [Release notes](https://github.com/googleapis/release-please-action/releases) - [Changelog](https://github.com/googleapis/release-please-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/release-please-action/compare/v4...v5) --- updated-dependencies: - dependency-name: googleapis/release-please-action dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-please.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b2e593ec..83dbaf49 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -29,7 +29,7 @@ jobs: - name: Run Release Please id: rp - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@v5 with: token: ${{ secrets.GITHUB_TOKEN }} config-file: .release-please-config.json From e617010102b3ecaec7754436731b21fd8fd06102 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:22:58 +0900 Subject: [PATCH 05/22] chore(deps): bump @hocuspocus/server in /server/hocuspocus (#771) Bumps [@hocuspocus/server](https://github.com/ueberdosis/hocuspocus) from 3.4.4 to 4.0.0. - [Release notes](https://github.com/ueberdosis/hocuspocus/releases) - [Changelog](https://github.com/ueberdosis/hocuspocus/blob/main/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/hocuspocus/compare/v3.4.4...v4.0.0) --- updated-dependencies: - dependency-name: "@hocuspocus/server" dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- server/hocuspocus/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/hocuspocus/package.json b/server/hocuspocus/package.json index 1e77f5d5..f6ae44cf 100644 --- a/server/hocuspocus/package.json +++ b/server/hocuspocus/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@hocuspocus/extension-redis": "^3.4.4", - "@hocuspocus/server": "^3.4.4", + "@hocuspocus/server": "^4.0.0", "aws-jwt-verify": "^5.1.1", "ioredis": "^5.9.3", "pg": "^8.19.0", From d535d259736f6228f554e0af553283749f0a5e19 Mon Sep 17 00:00:00 2001 From: Akimasa Sugai <119780981+otomatty@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:43:14 +0900 Subject: [PATCH 06/22] chore(deps): bump @hono/node-server to ^2.0.0 in /server/api (#782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual replacement for dependabot #774, which only updated package.json and left server/api/bun.lock out of sync, causing `bun install --frozen-lockfile` (and hence "API Type Check") to fail in CI. This commit bumps both package.json and the lockfile together. v2 only drops Node.js 18 support and removes the unused `@hono/node-server/vercel` adapter; the public `serve` API is unchanged, so no application code needs to be modified. 依存関係更新の手動反映: dependabot #774 は package.json のみを 更新し bun.lock が同期されないため CI が落ちていた。本コミット では両者をまとめて更新する。v2 の破壊的変更は Node.js 18 の サポート終了と未使用の Vercel アダプタ削除のみで、`serve` API は互換のためアプリ側のコード変更は不要。 Co-authored-by: Claude --- server/api/bun.lock | 4 ++-- server/api/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/api/bun.lock b/server/api/bun.lock index 0bade961..7bb8b1f3 100644 --- a/server/api/bun.lock +++ b/server/api/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1002.0", "@aws-sdk/s3-request-presigner": "^3.1002.0", - "@hono/node-server": "^1.19.11", + "@hono/node-server": "^2.0.0", "@mozilla/readability": "^0.6.0", "@polar-sh/sdk": "^0.47.0", "@react-email/components": "^1.0.11", @@ -242,7 +242,7 @@ "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], - "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + "@hono/node-server": ["@hono/node-server@2.0.0", "", { "peerDependencies": { "hono": "^4" } }, "sha512-n3GfHwwCvHCkGmOwKfxUPOlbfzuO64Sbc5XC4NGPIXxkuOnJrdgExdRKmHfF924r914WRJPT397GdqLvdYTeyQ=="], "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], diff --git a/server/api/package.json b/server/api/package.json index 7e1ce492..f4a3c9ba 100644 --- a/server/api/package.json +++ b/server/api/package.json @@ -19,7 +19,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1002.0", "@aws-sdk/s3-request-presigner": "^3.1002.0", - "@hono/node-server": "^1.19.11", + "@hono/node-server": "^2.0.0", "@mozilla/readability": "^0.6.0", "@polar-sh/sdk": "^0.47.0", "@react-email/components": "^1.0.11", From 05b8c0dc9e4948d2bab9abed240cf884072fdfba Mon Sep 17 00:00:00 2001 From: Akimasa Sugai <119780981+otomatty@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:44:53 +0900 Subject: [PATCH 07/22] fix(editor,layout): page heading hierarchy (one h1, body h2+) (#777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor,layout): single page h1; body headings h2–h4; migrate legacy h1 - Demote header logo from h1; wrap page title input in h1 - StarterKit heading levels 2–4; slash commands map to 2/3/4 - markdownToTiptap: #/##/### → levels 2/3/4; sanitize + HeadingLevelClamp for old data - Fix aiChatActionHelpers test for ## → level 3 Made-with: Cursor * fix: address PR #777 review comments - HeadingLevelClamp: skip inline descendants once a heading is reached (Gemini #1) and clean up empty JSDoc blocks left by editor auto-fix. - markdownToTiptap: drop empty JSDoc blocks scattered through the file and add a real TSDoc on the converter (Gemini #2/#3). - Extend body heading hierarchy to four levels: StarterKit levels [2,3,4,5] and Markdown #/##/###/#### -> 2/3/4/5; add tiptap-editor h4/h5 styles to src/index.css so deeper headings stay visually distinct (Codex #4). - htmlToTiptap: revert parsing levels to [1,2,3] to match server-side articleExtractor.ts so clipped

survives generateJSON; the client-side clamp (HeadingLevelClamp + sanitizeTiptapContent) handles level 1 -> 2 demotion (Devin #5). Adds a regression test. Made-with: Cursor * refactor(editor): leave `# X` markdown lines as literal paragraphs Previously `# X` was mapped to a body heading at level 2, which (a) collided semantically with the page title input that already owns the document's only `

`, and (b) broke round-trip with `markdownExport.ts` (`"#".repeat(level)` emits `##` for level 2, so re-importing shifted the hierarchy down by one). New mapping (`convertMarkdownToTiptapContent`): - `# X` → preserved verbatim as a `# X` text paragraph (no heading conversion) - `## X` → heading level 2 - `### X` → heading level 3 - `#### X` → heading level 4 - `##### X` → heading level 5 `markdownExport.ts` now falls back to `##` (the minimum body heading level on the editor schema) when a heading node has no/legacy `level: 1`, keeping the exporter symmetric with the importer. Tests updated: - `markdownToTiptap.test.ts`: assert `# X` stays as a literal paragraph and `##/###/####/#####` map to levels 2–5. - `markdownExport.test.ts`: replace the level-1 case with the level 2–5 set and add a regression for the legacy/missing-level fallback to `##`. - `aiChatActionHelpers.test.ts`: realign `## Heading` expectation to level 2. Made-with: Cursor --- .../editor/PageEditor/PageTitleBlock.test.tsx | 8 ++- .../editor/PageEditor/PageTitleBlock.tsx | 36 +++++++----- .../editor/TiptapEditor/editorConfig.ts | 7 ++- .../headingLevelClampExtension.test.ts | 44 ++++++++++++++ .../headingLevelClampExtension.ts | 58 +++++++++++++++++++ .../editor/TiptapEditor/slashCommandItems.ts | 6 +- .../extensions/MarkdownPasteExtension.test.ts | 2 +- .../transformWikiLinksInContent.test.ts | 2 +- src/components/layout/Header/HeaderLogo.tsx | 12 ++-- src/index.css | 24 ++++++++ src/lib/aiChatActionHelpers.test.ts | 2 + src/lib/contentUtils.test.ts | 16 +++++ src/lib/contentUtils.ts | 10 ++++ src/lib/htmlToTiptap.test.ts | 13 +++++ src/lib/htmlToTiptap.ts | 7 +++ src/lib/markdownExport.test.ts | 31 +++++++++- src/lib/markdownExport.ts | 9 ++- src/lib/markdownToTiptap.test.ts | 15 +++-- src/lib/markdownToTiptap.ts | 51 +++++++++++++--- 19 files changed, 305 insertions(+), 48 deletions(-) create mode 100644 src/components/editor/TiptapEditor/headingLevelClampExtension.test.ts create mode 100644 src/components/editor/TiptapEditor/headingLevelClampExtension.ts diff --git a/src/components/editor/PageEditor/PageTitleBlock.test.tsx b/src/components/editor/PageEditor/PageTitleBlock.test.tsx index 7679a887..e2be543c 100644 --- a/src/components/editor/PageEditor/PageTitleBlock.test.tsx +++ b/src/components/editor/PageEditor/PageTitleBlock.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { PageTitleBlock } from "./PageTitleBlock"; @@ -10,6 +10,12 @@ describe("PageTitleBlock", () => { expect(screen.getByPlaceholderText("タイトル")).toBeInTheDocument(); }); + it("編集時も h1 として扱いアクセシビリティ上の見出し1になる", () => { + render(); + const heading = screen.getByRole("heading", { level: 1 }); + expect(within(heading).getByRole("textbox")).toHaveValue("編集中タイトル"); + }); + it("テキストを変更すると onTitleChange が呼ばれる", async () => { const user = userEvent.setup(); const onTitleChange = vi.fn(); diff --git a/src/components/editor/PageEditor/PageTitleBlock.tsx b/src/components/editor/PageEditor/PageTitleBlock.tsx index 658ca1cc..e1ac3df4 100644 --- a/src/components/editor/PageEditor/PageTitleBlock.tsx +++ b/src/components/editor/PageEditor/PageTitleBlock.tsx @@ -68,24 +68,28 @@ export const PageTitleBlock: React.FC = ({ ); } + // 編集タイトルは当該ページの h1。本文は h2 起点(editor)と揃えて 1 ページ 1 見出しにする + // Editable page title is the only <h1>; body headings start at h2 in the editor return (
- +

+ +

{errorMessage && (

{errorMessage} diff --git a/src/components/editor/TiptapEditor/editorConfig.ts b/src/components/editor/TiptapEditor/editorConfig.ts index 7d1852c2..051c9116 100644 --- a/src/components/editor/TiptapEditor/editorConfig.ts +++ b/src/components/editor/TiptapEditor/editorConfig.ts @@ -36,6 +36,7 @@ import { SlashSuggestionPlugin, type SlashSuggestionState, } from "../extensions/slashSuggestionPlugin"; +import { HeadingLevelClamp } from "./headingLevelClampExtension"; import type { Extension } from "@tiptap/core"; import type * as Y from "yjs"; import type { Awareness } from "y-protocols/awareness"; @@ -146,7 +147,9 @@ function createCommonEditorExtensions(options: CommonEditorExtensionsOptions): E return [ StarterKit.configure({ heading: { - levels: [1, 2, 3], + // Body headings span h2–h5 (Markdown `#`/`##`/`###`/`####` map to 2/3/4/5). + // The page title is the only h1 and lives outside the editor document. + levels: [2, 3, 4, 5], }, // Y.js が履歴を管理するためコラボ時は無効 undoRedo: useCollaboration ? false : undefined, @@ -157,6 +160,8 @@ function createCommonEditorExtensions(options: CommonEditorExtensionsOptions): E // 下で個別に Underline を追加するため StarterKit の underline は無効 underline: false, }), + // Legacy h1 + collab: normalize to h2 after each change / 旧 h1 や Y.Doc 取り込みを h2 に揃える + HeadingLevelClamp, Markdown.configure({ markedOptions: { gfm: true }, }), diff --git a/src/components/editor/TiptapEditor/headingLevelClampExtension.test.ts b/src/components/editor/TiptapEditor/headingLevelClampExtension.test.ts new file mode 100644 index 00000000..0e94bfba --- /dev/null +++ b/src/components/editor/TiptapEditor/headingLevelClampExtension.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { HeadingLevelClamp } from "./headingLevelClampExtension"; + +/** + * ロード直後、appendTransaction により h1 相当 (level:1) が h2 へ上書きされることを検証する + * / Verifies legacy Tiptap JSON (heading level:1) becomes level:2 on load + */ +describe("HeadingLevelClamp", () => { + const editors: Editor[] = []; + afterEach(() => { + for (const ed of editors) { + ed.destroy(); + } + editors.length = 0; + }); + + it("migrates stored heading level 1 to 2 for schema levels 2-5", async () => { + const el = document.createElement("div"); + const editor = new Editor({ + element: el, + extensions: [ + StarterKit.configure({ + heading: { levels: [2, 3, 4, 5] }, + }), + HeadingLevelClamp, + ], + content: { + type: "doc", + content: [ + { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Legacy" }] }, + ], + }, + }); + editors.push(editor); + await new Promise((resolve) => { + queueMicrotask(() => resolve()); + }); + const first = editor.state.doc.firstChild; + expect(first?.type.name).toBe("heading"); + expect(first?.attrs.level).toBe(2); + }); +}); diff --git a/src/components/editor/TiptapEditor/headingLevelClampExtension.ts b/src/components/editor/TiptapEditor/headingLevelClampExtension.ts new file mode 100644 index 00000000..abf3baf0 --- /dev/null +++ b/src/components/editor/TiptapEditor/headingLevelClampExtension.ts @@ -0,0 +1,58 @@ +import { Extension } from "@tiptap/core"; +import type { EditorState, Transaction } from "@tiptap/pm/state"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +const headingLevelClampKey = new PluginKey("headingLevelClamp"); + +/** + * 本文 h1 相当 (level:1) を h2 へ。初期 doc が appendTransaction を通らないこともあるため + * 初回は view マウント直後の queueMicrotask でも反映する + * / Body h1 (level:1) → h2. Initial Tiptap content may not run appendTransaction; + * also run once in queueMicrotask after the plugin view is mounted + */ +function buildHeadingClampTr(state: EditorState): Transaction | null { + const tr = state.tr; + let modified = false; + state.doc.descendants((node, pos) => { + if (node.type.name !== "heading") return; + const level = typeof node.attrs.level === "number" ? node.attrs.level : 1; + if (level < 2) { + tr.setNodeMarkup(pos, undefined, { ...node.attrs, level: 2 }); + modified = true; + } + // headings only contain inline content; no need to scan descendants further + return false; + }); + return modified ? tr : null; +} + +/** + * 旧 h1(level:1)や Y.Doc から取り込んだ低レベル見出しを h2 にクランプする Tiptap 拡張。 + * `appendTransaction` で各変更後に正規化し、初回のみ `view` マウント直後の `queueMicrotask` でも反映する。 + * Tiptap extension that clamps stray heading nodes (level < 2) up to level 2. + * Runs on every transaction via `appendTransaction`, plus once on view mount via `queueMicrotask` + * because the initial document does not always pass through `appendTransaction`. + */ +export const HeadingLevelClamp = Extension.create({ + name: "headingLevelClamp", + addProseMirrorPlugins() { + return [ + new Plugin({ + key: headingLevelClampKey, + view(view) { + queueMicrotask(() => { + if (view.isDestroyed) return; + const tr = buildHeadingClampTr(view.state); + if (tr) { + view.dispatch(tr); + } + }); + return {}; + }, + appendTransaction(_transactions, _oldState, newState) { + return buildHeadingClampTr(newState); + }, + }), + ]; + }, +}); diff --git a/src/components/editor/TiptapEditor/slashCommandItems.ts b/src/components/editor/TiptapEditor/slashCommandItems.ts index bd4339ed..80d1e4b3 100644 --- a/src/components/editor/TiptapEditor/slashCommandItems.ts +++ b/src/components/editor/TiptapEditor/slashCommandItems.ts @@ -40,19 +40,19 @@ export const slashCommandItems: SlashCommandItem[] = [ id: "heading1", icon: "Heading1", action: (editor, range) => - editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run(), + editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run(), }, { id: "heading2", icon: "Heading2", action: (editor, range) => - editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run(), + editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run(), }, { id: "heading3", icon: "Heading3", action: (editor, range) => - editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run(), + editor.chain().focus().deleteRange(range).setHeading({ level: 4 }).run(), }, { id: "bulletList", diff --git a/src/components/editor/extensions/MarkdownPasteExtension.test.ts b/src/components/editor/extensions/MarkdownPasteExtension.test.ts index f3bc48ad..ab562d99 100644 --- a/src/components/editor/extensions/MarkdownPasteExtension.test.ts +++ b/src/components/editor/extensions/MarkdownPasteExtension.test.ts @@ -124,7 +124,7 @@ describe("MarkdownPaste extension", () => { const parsedDoc = { type: "doc", content: [ - { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Hello" }] }, + { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Hello" }] }, ], }; const { handlePaste, mockEditor } = getHandlePaste({ diff --git a/src/components/editor/extensions/transformWikiLinksInContent.test.ts b/src/components/editor/extensions/transformWikiLinksInContent.test.ts index d51492a5..318496d0 100644 --- a/src/components/editor/extensions/transformWikiLinksInContent.test.ts +++ b/src/components/editor/extensions/transformWikiLinksInContent.test.ts @@ -154,7 +154,7 @@ describe("transformWikiLinksInContent", () => { content: [ { type: "heading", - attrs: { level: 1 }, + attrs: { level: 2 }, content: [{ type: "text", text: "Title with [[Link]]" }], }, ], diff --git a/src/components/layout/Header/HeaderLogo.tsx b/src/components/layout/Header/HeaderLogo.tsx index be67d108..2b3ce322 100644 --- a/src/components/layout/Header/HeaderLogo.tsx +++ b/src/components/layout/Header/HeaderLogo.tsx @@ -2,15 +2,13 @@ import React from "react"; import { Link } from "react-router-dom"; /** - * + * アプリ名ロゴ。ページの h1 ではないため見出し要素にしない(ブランド用テキスト) + * / App brand text in the global header. Not a page <h1>; keep as non-heading */ -export /** - * - */ -const HeaderLogo: React.FC = () => ( +export const HeaderLogo: React.FC = () => ( -

+ Zedi -

+ ); diff --git a/src/index.css b/src/index.css index b443929b..00a1fd4c 100644 --- a/src/index.css +++ b/src/index.css @@ -258,6 +258,30 @@ margin-top: 0; } + .tiptap-editor h4 { + font-size: 15px; + font-weight: 600; + line-height: 1.55; + margin: 22px 0 10px; + color: hsl(var(--foreground)); + } + + .tiptap-editor h4:first-child { + margin-top: 0; + } + + .tiptap-editor h5 { + font-size: 14px; + font-weight: 600; + line-height: 1.6; + margin: 18px 0 8px; + color: hsl(var(--muted-foreground)); + } + + .tiptap-editor h5:first-child { + margin-top: 0; + } + .tiptap-editor p { margin: 0 0 18px; line-height: 1.8; diff --git a/src/lib/aiChatActionHelpers.test.ts b/src/lib/aiChatActionHelpers.test.ts index 2f125cd5..ab9aea77 100644 --- a/src/lib/aiChatActionHelpers.test.ts +++ b/src/lib/aiChatActionHelpers.test.ts @@ -137,6 +137,8 @@ describe("appendMarkdownToTiptapContent", () => { type: "paragraph", content: [{ type: "text", text: "Existing" }], }); + // `##` → Tiptap level 2 (body: ##/###/####/##### = 2/3/4/5; `# X` stays literal because + // the page h1 lives in the title field) expect(parsed.content[1]).toMatchObject({ type: "heading", attrs: { level: 2 }, diff --git a/src/lib/contentUtils.test.ts b/src/lib/contentUtils.test.ts index a7e253f5..f007b5e8 100644 --- a/src/lib/contentUtils.test.ts +++ b/src/lib/contentUtils.test.ts @@ -39,6 +39,22 @@ describe("sanitizeTiptapContent", () => { expect(parsed.content[0].content[0].text).toBe("Hello world"); }); + it("clamps stored heading level 1 to 2 (body top = h2)", () => { + const content = JSON.stringify({ + type: "doc", + content: [ + { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Legacy" }] }, + ], + }); + const result = sanitizeTiptapContent(content); + expect(result.hadErrors).toBe(false); + const parsed = JSON.parse(result.content) as { + content: Array<{ type: string; attrs?: { level: number } }>; + }; + expect(parsed.content[0].type).toBe("heading"); + expect(parsed.content[0].attrs?.level).toBe(2); + }); + it("should remove unsupported node types", () => { const contentWithUnsupportedNode = JSON.stringify({ type: "doc", diff --git a/src/lib/contentUtils.ts b/src/lib/contentUtils.ts index 12257c71..2f5f802e 100644 --- a/src/lib/contentUtils.ts +++ b/src/lib/contentUtils.ts @@ -247,6 +247,16 @@ function sanitizeNode( // Create a copy of the node const sanitizedNode: Record = { ...node }; + // 本文最上位を h2 に揃え、古い Tiptap JSON(level:1 や欠損=1 相当)を 2 へ + if (nodeType === "heading") { + const attrs = { ...((node.attrs as Record | undefined) ?? {}) }; + const level = typeof attrs.level === "number" ? attrs.level : 1; + if (level < 2) { + attrs.level = 2; + sanitizedNode.attrs = attrs; + } + } + // Sanitize marks if present if (node.marks && Array.isArray(node.marks)) { const sanitizedMarks = (node.marks as Array>).filter((mark) => { diff --git a/src/lib/htmlToTiptap.test.ts b/src/lib/htmlToTiptap.test.ts index 77180a4e..c7989d80 100644 --- a/src/lib/htmlToTiptap.test.ts +++ b/src/lib/htmlToTiptap.test.ts @@ -144,4 +144,17 @@ describe("htmlToTiptapJSON", () => { expect(imageNodes.length).toBe(1); expect(imageNodes[0]?.attrs).toMatchObject({ src: "https://example.com/fig.webp" }); }); + + // Regression for PR #777 review (Devin): keep

as a heading node so that + // editor-time clamping (HeadingLevelClamp / sanitizeTiptapContent) can demote + // it to level 2. Dropping

at parse time would silently lose semantics. + it("preserves

..

as heading nodes (clamping happens later)", () => { + const html = "

Top

Sub

Detail

"; + const result = htmlToTiptapJSON(html); + const headings = result.content?.filter((n) => n.type === "heading") ?? []; + expect(headings).toHaveLength(3); + expect(headings[0]?.attrs).toMatchObject({ level: 1 }); + expect(headings[1]?.attrs).toMatchObject({ level: 2 }); + expect(headings[2]?.attrs).toMatchObject({ level: 3 }); + }); }); diff --git a/src/lib/htmlToTiptap.ts b/src/lib/htmlToTiptap.ts index d2b67d50..631b8601 100644 --- a/src/lib/htmlToTiptap.ts +++ b/src/lib/htmlToTiptap.ts @@ -28,6 +28,13 @@ function isClipLinkUriAllowed(url: string | undefined): boolean { // Web クリップ用の最小スキーマ(サーバー `clipAndCreate` と同系)。 // Table / TaskList 等は含めない — 拡張は別 PR で検討する。 // generateJSON に Image がないと がドロップされる。 +// 外部 HTML の `

`〜`

` をパース時にドロップしないため、ここでは server 側 +// (`server/api/src/lib/articleExtractor.ts`) と同じ `[1, 2, 3]` を維持する。 +// 取り込み後の正規化(h1 → h2)はエディタ装着時の `HeadingLevelClamp` と +// `sanitizeTiptapContent` が行う(PR #777)。 +// Keep parsing levels aligned with the server extractor so external `

`〜`

` survive +// `generateJSON`; client/runtime clamping (HeadingLevelClamp + sanitizeTiptapContent) demotes +// any level-1 headings to level 2 before they reach the editor body. const extensions = [ StarterKit.configure({ heading: { diff --git a/src/lib/markdownExport.test.ts b/src/lib/markdownExport.test.ts index 08e98c7a..4d742a7a 100644 --- a/src/lib/markdownExport.test.ts +++ b/src/lib/markdownExport.test.ts @@ -15,19 +15,44 @@ describe("tiptapToMarkdown", () => { expect(md).toContain("Second line"); }); - it("converts headings with # prefix", () => { + // 本文の見出しは body schema 上 h2–h5(level 2–5)で、`#` 1 個の見出しはページタイトル + // 入力欄が担う。export ではそれぞれ `##/###/####/#####` を出す。 + // Body headings span schema levels 2–5; the page h1 lives in the title field, so the + // exporter emits `##/###/####/#####` for the four body levels. + it("converts body headings (levels 2–5) with matching # prefixes", () => { const content = JSON.stringify({ type: "doc", content: [ - { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "H1" }] }, { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "H2" }] }, { type: "heading", attrs: { level: 3 }, content: [{ type: "text", text: "H3" }] }, + { type: "heading", attrs: { level: 4 }, content: [{ type: "text", text: "H4" }] }, + { type: "heading", attrs: { level: 5 }, content: [{ type: "text", text: "H5" }] }, ], }); const md = tiptapToMarkdown(content); - expect(md).toContain("# H1"); expect(md).toContain("## H2"); expect(md).toContain("### H3"); + expect(md).toContain("#### H4"); + expect(md).toContain("##### H5"); + }); + + // 旧データに残っている level 1 / 欠損 level は最小の本文見出し `##` にフォールバックさせ、 + // ページタイトルの `#` と衝突しないようにする。 + // Legacy heading nodes with level 1 (or a missing level attribute) fall back to `##`, + // the minimum body-heading level, so they never collide with the page title's `#`. + it("falls back to `##` when level is missing or below 2 (legacy data)", () => { + const content = JSON.stringify({ + type: "doc", + content: [ + { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Legacy" }] }, + { type: "heading", content: [{ type: "text", text: "NoLevel" }] }, + ], + }); + const md = tiptapToMarkdown(content); + expect(md).toContain("## Legacy"); + expect(md).toContain("## NoLevel"); + expect(md).not.toMatch(/^# Legacy/m); + expect(md).not.toMatch(/^# NoLevel/m); }); it("converts bullet list with - markers", () => { diff --git a/src/lib/markdownExport.ts b/src/lib/markdownExport.ts index 406b0e93..ce294a67 100644 --- a/src/lib/markdownExport.ts +++ b/src/lib/markdownExport.ts @@ -42,7 +42,14 @@ Object.assign(nodeHandlers, { doc: (n) => convertChildren(n), paragraph: (n) => convertChildren(n) + "\n\n", heading: (n) => { - const level = (n.attrs?.level as number) || 1; + // 本文の見出しは body schema 上 h2–h5(level 2–5)。level が欠落している旧データでも + // ページタイトルと衝突する `#` 1 個に潰さず、最小の本文見出しレベル `##` にフォールバック + // する。これにより `convertMarkdownToTiptapContent` との round-trip が対称に保たれる。 + // Body headings span schema levels 2–5. If the level attribute is missing on legacy data, + // fall back to `##` (the minimum body heading) instead of `#`, which would clash with the + // page title and round-trip back to a literal `# X` paragraph in `convertMarkdownToTiptapContent`. + const rawLevel = n.attrs?.level; + const level = typeof rawLevel === "number" && rawLevel >= 2 ? rawLevel : 2; const prefix = "#".repeat(level); return `${prefix} ${convertChildren(n)}\n\n`; }, diff --git a/src/lib/markdownToTiptap.test.ts b/src/lib/markdownToTiptap.test.ts index 0c52024d..116a9c41 100644 --- a/src/lib/markdownToTiptap.test.ts +++ b/src/lib/markdownToTiptap.test.ts @@ -10,31 +10,34 @@ describe("convertMarkdownToTiptapContent", () => { expect(parsed.content[0]).toMatchObject({ type: "paragraph" }); }); - it("converts # heading to heading level 1", () => { + // `# X` は本文 h1 として変換しないため、テキストのまま paragraph として残る。 + // The page h1 lives in the title field, so `# X` survives verbatim as a paragraph. + it("preserves `# X` as a literal paragraph (no heading conversion)", () => { const result = convertMarkdownToTiptapContent("# Title"); const parsed = JSON.parse(result) as { content: Array<{ type: string; attrs?: { level: number }; content?: unknown[] }>; }; expect(parsed.content).toHaveLength(1); expect(parsed.content[0]).toMatchObject({ - type: "heading", - attrs: { level: 1 }, + type: "paragraph", }); const firstContent = parsed.content[0].content; expect(firstContent).toHaveLength(1); expect(firstContent?.[0]).toMatchObject({ type: "text", - text: "Title", + text: "# Title", }); }); - it("converts ## and ### headings", () => { - const result = convertMarkdownToTiptapContent("## Section\n### Sub"); + it("converts ##, ###, ####, ##### headings to levels 2, 3, 4, 5", () => { + const result = convertMarkdownToTiptapContent("## Section\n### Sub\n#### Detail\n##### Note"); const parsed = JSON.parse(result) as { content: Array<{ type: string; attrs?: { level: number } }>; }; expect(parsed.content[0]).toMatchObject({ type: "heading", attrs: { level: 2 } }); expect(parsed.content[1]).toMatchObject({ type: "heading", attrs: { level: 3 } }); + expect(parsed.content[2]).toMatchObject({ type: "heading", attrs: { level: 4 } }); + expect(parsed.content[3]).toMatchObject({ type: "heading", attrs: { level: 5 } }); }); it("converts bullet list items", () => { diff --git a/src/lib/markdownToTiptap.ts b/src/lib/markdownToTiptap.ts index 177c9d40..53ab4d26 100644 --- a/src/lib/markdownToTiptap.ts +++ b/src/lib/markdownToTiptap.ts @@ -1,6 +1,18 @@ /** * Markdown → Tiptap JSON 変換の共通モジュール。 * wikiGenerator と aiChatActionHelpers の両方で利用。 + * + * 見出しの方針: + * - `# X` は **本文の見出しに変換しない**(ページの h1 はタイトル input が担うため、`# X` を + * 書いた行はそのまま `# X` というテキストの paragraph として残す)。これにより、 + * `markdownExport.ts` の `"#".repeat(level)` と round-trip が対称になる。 + * - `## / ### / #### / #####` → Tiptap level 2/3/4/5 にそれぞれ対応する。 + * + * Heading policy: + * - `# X` is **not** converted into a body heading; the page h1 lives in the title field, so + * any line starting with `# ` is preserved verbatim as a `# X` paragraph. This keeps the + * import side symmetric with `markdownExport.ts`'s `"#".repeat(level)` output. + * - `## / ### / #### / #####` map to Tiptap heading levels 2/3/4/5 respectively. */ import { parseInlineContent, type TiptapTextNode } from "./markdownToTiptapHelpers"; @@ -11,6 +23,13 @@ type TiptapBlockNode = { content?: Array; }; +/** + * Markdown 文字列を Tiptap JSON(`doc`)へ変換し、文字列化して返す。 + * Convert a Markdown string to a Tiptap `doc` JSON and return its serialized form. + * + * @param markdown - 入力 Markdown / Source Markdown text. + * @returns Tiptap doc JSON を `JSON.stringify` した文字列 / Serialized Tiptap doc JSON. + */ export function convertMarkdownToTiptapContent(markdown: string): string { const normalized = markdown.replace(/\r\n?/g, "\n"); const lines = normalized.endsWith("\n") @@ -27,6 +46,24 @@ export function convertMarkdownToTiptapContent(markdown: string): string { continue; } + if (line.startsWith("##### ")) { + doc.content.push({ + type: "heading", + attrs: { level: 5 }, + content: parseInlineContent(line.slice(6)), + }); + continue; + } + + if (line.startsWith("#### ")) { + doc.content.push({ + type: "heading", + attrs: { level: 4 }, + content: parseInlineContent(line.slice(5)), + }); + continue; + } + if (line.startsWith("### ")) { doc.content.push({ type: "heading", @@ -45,14 +82,12 @@ export function convertMarkdownToTiptapContent(markdown: string): string { continue; } - if (line.startsWith("# ")) { - doc.content.push({ - type: "heading", - attrs: { level: 1 }, - content: parseInlineContent(line.slice(2)), - }); - continue; - } + // `# X` は本文 h1 として変換しない: ページのタイトル input が h1 を担うため、 + // 該当行はそのまま `# X` というテキストを含む paragraph として残し、後段の + // 既定パラグラフ分岐に委ねる。 + // `# X` is intentionally NOT converted into a body heading; the page title field is the + // canonical h1, so the line falls through to the default paragraph branch and survives + // verbatim as a `# X` text paragraph. if (line.startsWith("- ") || line.startsWith("* ")) { const listItem: TiptapBlockNode = { From c398ad65b955cec2a336af6811d52df412fda99f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:45:22 +0900 Subject: [PATCH 08/22] chore(deps): bump @hocuspocus/extension-redis in /server/hocuspocus (#772) Bumps [@hocuspocus/extension-redis](https://github.com/ueberdosis/hocuspocus) from 3.4.4 to 4.0.0. - [Release notes](https://github.com/ueberdosis/hocuspocus/releases) - [Changelog](https://github.com/ueberdosis/hocuspocus/blob/main/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/hocuspocus/compare/v3.4.4...v4.0.0) --- updated-dependencies: - dependency-name: "@hocuspocus/extension-redis" dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- server/hocuspocus/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/hocuspocus/package.json b/server/hocuspocus/package.json index f6ae44cf..81765dd5 100644 --- a/server/hocuspocus/package.json +++ b/server/hocuspocus/package.json @@ -9,7 +9,7 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { - "@hocuspocus/extension-redis": "^3.4.4", + "@hocuspocus/extension-redis": "^4.0.0", "@hocuspocus/server": "^4.0.0", "aws-jwt-verify": "^5.1.1", "ioredis": "^5.9.3", From afb1fba4738e587131f09f6f197b7506a9eaa065 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:18:30 +0900 Subject: [PATCH 09/22] chore(deps): bump @anthropic-ai/sdk from 0.90.0 to 0.91.1 in the minor-and-patch group (#773) * chore(deps): bump @anthropic-ai/sdk in the minor-and-patch group Bumps the minor-and-patch group with 1 update: [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript). Updates `@anthropic-ai/sdk` from 0.90.0 to 0.91.1 - [Release notes](https://github.com/anthropics/anthropic-sdk-typescript/releases) - [Changelog](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md) - [Commits](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.90.0...sdk-v0.91.1) --- updated-dependencies: - dependency-name: "@anthropic-ai/sdk" dependency-version: 0.91.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] * chore(deps): sync bun.lock with @anthropic-ai/sdk 0.91.1 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 333bb258..26e83593 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "vite_react_shadcn_ts", "dependencies": { - "@anthropic-ai/sdk": "^0.90.0", + "@anthropic-ai/sdk": "^0.91.1", "@dagrejs/dagre": "^3.0.0", "@google/genai": "^1.34.0", "@google/generative-ai": "^0.24.1", @@ -286,7 +286,7 @@ "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.90", "", { "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "@modelcontextprotocol/sdk": "^1.27.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-up5bK0pUbthKIZtNE18WDrIYi0KNpZUhdgjGbkfH/mFQJxI6W/uE3mTiLrCX3UF0SqNl0fMtojBTZPJr2b3O4g=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.90.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="], diff --git a/package.json b/package.json index 300d3a74..15ce8dc6 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "lodash-es": ">=4.18.0" }, "dependencies": { - "@anthropic-ai/sdk": "^0.90.0", + "@anthropic-ai/sdk": "^0.91.1", "@dagrejs/dagre": "^3.0.0", "@google/genai": "^1.34.0", "@google/generative-ai": "^0.24.1", From 3d23eb786ae4554d311c7826414e6b72b2d43565 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:18:46 +0900 Subject: [PATCH 10/22] chore(deps): bump @hocuspocus/provider from 3.4.4 to 4.0.0 (#775) * chore(deps): bump @hocuspocus/provider from 3.4.4 to 4.0.0 Bumps [@hocuspocus/provider](https://github.com/ueberdosis/hocuspocus) from 3.4.4 to 4.0.0. - [Release notes](https://github.com/ueberdosis/hocuspocus/releases) - [Changelog](https://github.com/ueberdosis/hocuspocus/blob/main/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/hocuspocus/compare/v3.4.4...v4.0.0) --- updated-dependencies: - dependency-name: "@hocuspocus/provider" dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * chore(deps): sync bun.lock with @hocuspocus/provider 4.0.0 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude --- bun.lock | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 26e83593..88f3ef70 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@dagrejs/dagre": "^3.0.0", "@google/genai": "^1.34.0", "@google/generative-ai": "^0.24.1", - "@hocuspocus/provider": "^3.4.4", + "@hocuspocus/provider": "^4.0.0", "@hookform/resolvers": "^5.2.2", "@mozilla/readability": "^0.6.0", "@radix-ui/react-accordion": "^1.2.11", @@ -554,9 +554,9 @@ "@google/generative-ai": ["@google/generative-ai@0.24.1", "", {}, "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q=="], - "@hocuspocus/common": ["@hocuspocus/common@3.4.4", "", { "dependencies": { "lib0": "^0.2.87" } }, "sha512-RykIJ0tsHHMP4Xk+4UCbc7SO5LgGxGUSTdbh6anJEsaALAyqinf1Nn5HYuMjLPolAmsar1v++m9zufR09NLpXA=="], + "@hocuspocus/common": ["@hocuspocus/common@4.0.0", "", { "dependencies": { "lib0": "^0.2.87" } }, "sha512-7BE8TsKBkdiOZO6tfm3ny6bIHPbxkIZb3hsYdVn/X5xbXI8n8w9pnE6pXgEMKQhJm6zsWsa9IDRJIp/c9u+DmA=="], - "@hocuspocus/provider": ["@hocuspocus/provider@3.4.4", "", { "dependencies": { "@hocuspocus/common": "^3.4.4", "@lifeomic/attempt": "^3.0.2", "lib0": "^0.2.87", "ws": "^8.17.1" }, "peerDependencies": { "y-protocols": "^1.0.6", "yjs": "^13.6.8" } }, "sha512-KbsMAfdYcIJD8eMU/5QnpXcSOvIWAcCNI33FSRSaKCIpYBFtAwkYIwWnZJmPZ8a1BMAtqQc+uvy9+UQf7GHnGQ=="], + "@hocuspocus/provider": ["@hocuspocus/provider@4.0.0", "", { "dependencies": { "@hocuspocus/common": "^4.0.0", "@lifeomic/attempt": "^3.0.2", "lib0": "^0.2.87", "ws": "^8.17.1" }, "peerDependencies": { "y-protocols": "^1.0.6", "yjs": "^13.6.8" } }, "sha512-08gpeNZ6x2pmRD6m4XwRD52yQKnTl32a0HS9VSXZ5A1dIBVqxMz/x8Z06XbkKM2X8sp6vWEUCZCtzAGFSsofgg=="], "@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], diff --git a/package.json b/package.json index 15ce8dc6..c6cb93e7 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "@dagrejs/dagre": "^3.0.0", "@google/genai": "^1.34.0", "@google/generative-ai": "^0.24.1", - "@hocuspocus/provider": "^3.4.4", + "@hocuspocus/provider": "^4.0.0", "@hookform/resolvers": "^5.2.2", "@mozilla/readability": "^0.6.0", "@radix-ui/react-accordion": "^1.2.11", From 1a01bbc9a25014bb74a14c1c6a14eaaa9bf2b953 Mon Sep 17 00:00:00 2001 From: Akimasa Sugai <119780981+otomatty@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:23:44 +0900 Subject: [PATCH 11/22] feat: implement tag suggestion popover (issue #767 Phase 2) (#778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editor): add tag suggestion (#name) plugin (Phase 2) Adds a hashtag autocomplete experience comparable to the existing WikiLink suggestion plugin: typing `#` opens an in-place picker scoped to the same namespace as page titles, and confirming inserts a fully-formed `tag` mark with resolved `exists` / `targetId` attributes. - New `TagSuggestionPlugin` mirrors `WikiLinkSuggestionPlugin`'s shape: query detection, `.tag-typing` decoration (CSS already lived in `src/index.css`), and Esc / explicit close handling. - Candidate sources: scope-aware page titles via `useWikiLinkCandidates` plus existing ghost tag names from `getGhostLinks` filtered by `linkType === 'tag'`. Combined and case-insensitively de-duplicated by `useTagCandidates`. The `/api/tags` endpoint stays out of scope per issue #767. - `TagSuggestion` React popover handles arrow keys / Enter / Tab / Esc, showing existing tags with `Hash` and an inline create option for new ones (parallels `WikiLinkSuggestion`). - Coordinated with `TagExtension.addInputRules` (#766): while the popover is active the input rule short-circuits to avoid double-marking on confirm. After Esc the rule fires normally — the documented Esc-then-terminator fallback contract. - Wired through `editorConfig`, `useSuggestionControllers`, `useSuggestionEffects`, `useEditorSetup`, `useTiptapEditorController`, and `TiptapEditor` so `` renders alongside the existing WikiLink / Slash layers. Tests cover plugin trigger / non-trigger conditions, code-block + inline code suppression, the deferral contract with the input rule, the fallback Esc-then-terminator path, and the new state utility comparator. Refs: #767, parent #725, depends on #766 * fix(editor): address PR #778 tag suggestion review feedback Addresses review feedback on issue #767 (Phase 2): - Fix bug where an exact-match candidate that fell outside the first MAX_VISIBLE substring matches got dropped from the visible list while still suppressing the "create new" fallback, leaving the user unable to select the exact tag (gemini-code-assist HIGH). Items now sort exact matches to the front before slicing. - Wrap items computation in useMemo so it only recalculates when candidates / query change (gemini-code-assist). - Drop unused `editor` prop from `TagSuggestionProps` (coderabbitai nit). - Gate `useTagCandidates`' ghost-tag react-query call with an `enabled` option; `TagSuggestionLayer` only enables it while the popover is active so we do not hit IndexedDB until the user types `#` (gemini-code-assist). - Fix stale doc reference in `useTagCandidates` (composes `useWikiLinkCandidates`, not `useWikiLinkExistsChecker`) (coderabbitai). - Replace empty JSDoc blocks (auto-inserted by lint-staged --fix on the previous commit) on `isSameSuggestionRange`, `isSameWikiLinkSuggestionState`, and `isSameSlashSuggestionState` with bilingual descriptions explaining each function's contract (gemini-code-assist + coderabbitai). Adds `TagSuggestion.test.tsx` pinning the items-list contract — in particular regression coverage for the exact-match-outside-MAX_VISIBLE bug — and the keyboard handle (Enter / Tab confirm, Escape closes, Escape works even when the items list is empty). * fix(editor): scope tag ghost-link probe + add arrow-key tests Addresses two follow-up review comments on PR #778: - devin: `handleTagSuggestionSelect` was calling `checkReferenced(item.name, pageId)` which delegates to `useCheckGhostLinkReferenced`. That hook in turn called `repo.getGhostLinkSources(linkText)` without a `linkType` argument, so the lookup defaulted to `"wiki"` and never found tag ghost-link entries (which are stored with `linkType: "tag"`). The resulting `referenced` value was always `false`, so ghost tags rendered with `tag-ghost` styling instead of the correct `tag-referenced` styling whenever the same `#name` already appeared on other pages. Extends `checkReferenced` with an optional `linkType` (default `"wiki"` for backward compatibility) and forwards it to `getGhostLinkSources`. The tag confirm path now passes `"tag"` explicitly. The WikiLink confirm path is unchanged (uses the default). - coderabbit nit: extends `TagSuggestion.test.tsx` with arrow-key navigation coverage — ArrowUp wrap from first to last, ArrowDown wrap from last to first, single-step ArrowDown, and a single-candidate no-op test. A small `pressKey` helper wraps `act` so consecutive key presses see the up-to-date imperative handle, and a `settleInitialMicrotasks` helper drains the component's `queueMicrotask` reset before navigation tests start. Tests pinning the linkType contract (`useSuggestionEffects.test.ts`) assert `checkReferenced` is invoked with `("newtag", "page-1", "tag")`. --------- Co-authored-by: Claude --- src/components/editor/TiptapEditor.tsx | 15 + .../TiptapEditor/TagSuggestionLayer.tsx | 76 ++++ .../editor/TiptapEditor/editorConfig.ts | 23 ++ .../TiptapEditor/suggestionStateUtils.test.ts | 44 ++ .../TiptapEditor/suggestionStateUtils.ts | 64 +++ .../editor/TiptapEditor/useEditorSetup.ts | 20 +- .../TiptapEditor/useSuggestionControllers.ts | 22 +- .../TiptapEditor/useSuggestionEffects.test.ts | 112 +++++ .../TiptapEditor/useSuggestionEffects.ts | 103 +++++ .../TiptapEditor/useTiptapEditorController.ts | 17 + .../editor/extensions/TagExtension.test.ts | 62 +++ .../editor/extensions/TagExtension.ts | 18 + .../editor/extensions/TagSuggestion.test.tsx | 311 ++++++++++++++ .../editor/extensions/TagSuggestion.tsx | 200 +++++++++ .../extensions/tagSuggestionPlugin.test.ts | 382 ++++++++++++++++++ .../editor/extensions/tagSuggestionPlugin.ts | 287 +++++++++++++ src/hooks/usePageQueries.ts | 21 +- src/hooks/useTagCandidates.ts | 136 +++++++ 18 files changed, 1904 insertions(+), 9 deletions(-) create mode 100644 src/components/editor/TiptapEditor/TagSuggestionLayer.tsx create mode 100644 src/components/editor/extensions/TagSuggestion.test.tsx create mode 100644 src/components/editor/extensions/TagSuggestion.tsx create mode 100644 src/components/editor/extensions/tagSuggestionPlugin.test.ts create mode 100644 src/components/editor/extensions/tagSuggestionPlugin.ts create mode 100644 src/hooks/useTagCandidates.ts diff --git a/src/components/editor/TiptapEditor.tsx b/src/components/editor/TiptapEditor.tsx index d13dedc1..b703032e 100644 --- a/src/components/editor/TiptapEditor.tsx +++ b/src/components/editor/TiptapEditor.tsx @@ -9,6 +9,7 @@ import { StorageSetupDialog } from "./TiptapEditor/StorageSetupDialog"; import { DragOverlay } from "./TiptapEditor/DragOverlay"; import { WikiLinkSuggestionLayer } from "./TiptapEditor/WikiLinkSuggestionLayer"; import { WikiLinkHoverCardLayer } from "./TiptapEditor/WikiLinkHoverCardLayer"; +import { TagSuggestionLayer } from "./TiptapEditor/TagSuggestionLayer"; import { SlashSuggestionLayer } from "./TiptapEditor/SlashSuggestionLayer"; import { EditorBubbleMenu } from "./TiptapEditor/EditorBubbleMenu"; import { TableBubbleMenu } from "./TiptapEditor/TableBubbleMenu"; @@ -67,6 +68,11 @@ const TiptapEditor: React.FC = ({ slashPos, slashRef, handleSlashClose, + tagSuggestionState, + tagSuggestionPos, + tagSuggestionRef, + handleTagSuggestionSelect, + handleTagSuggestionClose, mermaidDialogOpen, setMermaidDialogOpen, handleInsertMermaid, @@ -147,6 +153,15 @@ const TiptapEditor: React.FC = ({ onClose={handleSuggestionClose} pageNoteId={resolvedPageNoteId} /> + ; + onSelect: (item: TagSuggestionItem) => void; + onClose: () => void; + /** + * 編集中ページの noteId。タグサジェストの候補スコープを WikiLink と同じ + * 規則で個人 (`null`) / 同一ノート (`string`) に絞るために使う。 + * + * Owning note id of the page being edited. Scopes tag candidates the same + * way `WikiLinkSuggestionLayer` scopes WikiLink candidates. + */ + pageNoteId: string | null; +} + +/** + * タグサジェスト UI の絶対配置層。`useTagCandidates` でスコープ別の候補を + * 取得し、`TagSuggestion` に渡す。WikiLink 用 `WikiLinkSuggestionLayer` と + * 同じ構造(描画・データ取得は層で吸収し、本体コンポーネントは純表示)。 + * + * Floating layer for the `#name` suggestion popup. Fetches scope-aware tag + * candidates via `useTagCandidates` and forwards them to `TagSuggestion`, + * mirroring `WikiLinkSuggestionLayer`. See issue #767 (Phase 2). + */ +export const TagSuggestionLayer: React.FC = ({ + editor, + suggestionState, + position, + suggestionRef, + onSelect, + onClose, + pageNoteId, +}) => { + // ポップオーバーが open のときだけ候補(特にゴーストタグの fetch)を実行する。 + // `useTagCandidates` は `enabled` でクエリを抑止するので、`#` が打鍵されるまで + // IndexedDB / API を叩かない(gemini-code-assist のレビュー指摘)。 + // Only fetch candidates while the popover is active so the ghost-tag query + // does not hit IndexedDB / the network until the user types `#`. The hook + // now accepts an `enabled` flag to gate its react-query call (review + // feedback from gemini-code-assist). + const isActive = Boolean(suggestionState?.active); + const { candidates } = useTagCandidates(pageNoteId, { enabled: isActive }); + + if (!suggestionState?.active || !suggestionState.range || !position || !editor) return null; + + return ( +
+ +
+ ); +}; diff --git a/src/components/editor/TiptapEditor/editorConfig.ts b/src/components/editor/TiptapEditor/editorConfig.ts index 051c9116..8dc4c091 100644 --- a/src/components/editor/TiptapEditor/editorConfig.ts +++ b/src/components/editor/TiptapEditor/editorConfig.ts @@ -36,6 +36,7 @@ import { SlashSuggestionPlugin, type SlashSuggestionState, } from "../extensions/slashSuggestionPlugin"; +import { TagSuggestionPlugin, type TagSuggestionState } from "../extensions/tagSuggestionPlugin"; import { HeadingLevelClamp } from "./headingLevelClampExtension"; import type { Extension } from "@tiptap/core"; import type * as Y from "yjs"; @@ -113,6 +114,15 @@ export interface EditorExtensionsOptions { onTagClick?: (name: string) => void; onStateChange: (state: WikiLinkSuggestionState) => void; onSlashStateChange: (state: SlashSuggestionState) => void; + /** + * `#name` タグサジェストの状態通知。`TagSuggestionPlugin` が状態変化のたび + * に呼ぶ。Issue #767 (Phase 2)。 + * + * Tag (`#name`) suggestion state callback. Invoked by + * `TagSuggestionPlugin` whenever the popover opens, closes, or the query + * changes. See issue #767 (Phase 2). + */ + onTagSuggestionStateChange: (state: TagSuggestionState) => void; imageUploadOptions: Partial; imageOptions: Partial; /** When set, enables Y.js collaboration and caret; StarterKit history is disabled */ @@ -133,6 +143,7 @@ interface CommonEditorExtensionsOptions { onTagClick?: (name: string) => void; onStateChange?: (state: WikiLinkSuggestionState) => void; onSlashStateChange?: (state: SlashSuggestionState) => void; + onTagSuggestionStateChange?: (state: TagSuggestionState) => void; imageUploadOptions?: Partial; imageOptions?: Partial; fileReference?: EditorExtensionsOptions["fileReference"]; @@ -250,6 +261,17 @@ function createCommonEditorExtensions(options: CommonEditorExtensionsOptions): E SlashSuggestionPlugin.configure({ onStateChange: options.onSlashStateChange ?? (() => undefined), }), + // --- Phase 2: Tag (`#name`) suggestion (issue #767) --- + // 入力規則 (#766) との二重マーク化を避けるため、`TagExtension` の + // `addInputRules` ハンドラがこのプラグインの active 状態を見て発火を + // 抑止する。Esc で閉じた後の終端文字は通常どおり入力規則経路で + // マーク化される(フォールバック契約)。 + // Coordinated with `TagExtension.addInputRules`: while this plugin is + // active the input rule short-circuits to avoid double-marking; after + // Esc the rule fires normally (Esc-then-terminator fallback). + TagSuggestionPlugin.configure({ + onStateChange: options.onTagSuggestionStateChange ?? (() => undefined), + }), ] : []), // --- Image --- @@ -316,6 +338,7 @@ export function createEditorExtensions(options: EditorExtensionsOptions): Extens onTagClick: options.onTagClick, onStateChange: options.onStateChange, onSlashStateChange: options.onSlashStateChange, + onTagSuggestionStateChange: options.onTagSuggestionStateChange, imageUploadOptions: options.imageUploadOptions, imageOptions: options.imageOptions, fileReference: options.fileReference, diff --git a/src/components/editor/TiptapEditor/suggestionStateUtils.test.ts b/src/components/editor/TiptapEditor/suggestionStateUtils.test.ts index bf3c8beb..e89d221a 100644 --- a/src/components/editor/TiptapEditor/suggestionStateUtils.test.ts +++ b/src/components/editor/TiptapEditor/suggestionStateUtils.test.ts @@ -3,9 +3,11 @@ import { isSameSuggestionRange, isSameWikiLinkSuggestionState, isSameSlashSuggestionState, + isSameTagSuggestionState, } from "./suggestionStateUtils"; import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin"; import type { SlashSuggestionState } from "../extensions/slashSuggestionPlugin"; +import type { TagSuggestionState } from "../extensions/tagSuggestionPlugin"; function createWikiLinkState(overrides: Partial): WikiLinkSuggestionState { return { @@ -27,6 +29,16 @@ function createSlashState(overrides: Partial): SlashSugges }; } +function createTagState(overrides: Partial): TagSuggestionState { + return { + active: false, + query: "", + range: null, + decorations: {} as TagSuggestionState["decorations"], + ...overrides, + }; +} + describe("isSameSuggestionRange", () => { it("returns true when both are null", () => { expect(isSameSuggestionRange(null, null)).toBe(true); @@ -121,3 +133,35 @@ describe("isSameSlashSuggestionState", () => { expect(isSameSlashSuggestionState(a, b)).toBe(false); }); }); + +describe("isSameTagSuggestionState", () => { + it("returns false when first argument is null", () => { + const b = createTagState({ active: true, query: "tec", range: { from: 0, to: 4 } }); + expect(isSameTagSuggestionState(null, b)).toBe(false); + }); + + it("returns true when active, query and range match", () => { + const state = createTagState({ active: true, query: "tec", range: { from: 1, to: 5 } }); + expect(isSameTagSuggestionState(state, state)).toBe(true); + const b = createTagState({ active: true, query: "tec", range: { from: 1, to: 5 } }); + expect(isSameTagSuggestionState(state, b)).toBe(true); + }); + + it("returns false when active differs", () => { + const a = createTagState({ active: true, query: "q", range: null }); + const b = createTagState({ active: false, query: "q", range: null }); + expect(isSameTagSuggestionState(a, b)).toBe(false); + }); + + it("returns false when query differs", () => { + const a = createTagState({ active: true, query: "a", range: null }); + const b = createTagState({ active: true, query: "b", range: null }); + expect(isSameTagSuggestionState(a, b)).toBe(false); + }); + + it("returns false when range differs", () => { + const a = createTagState({ active: true, query: "q", range: { from: 0, to: 1 } }); + const b = createTagState({ active: true, query: "q", range: { from: 2, to: 3 } }); + expect(isSameTagSuggestionState(a, b)).toBe(false); + }); +}); diff --git a/src/components/editor/TiptapEditor/suggestionStateUtils.ts b/src/components/editor/TiptapEditor/suggestionStateUtils.ts index 5f5a3baa..0df2905d 100644 --- a/src/components/editor/TiptapEditor/suggestionStateUtils.ts +++ b/src/components/editor/TiptapEditor/suggestionStateUtils.ts @@ -1,6 +1,22 @@ import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin"; import type { SlashSuggestionState } from "../extensions/slashSuggestionPlugin"; +import type { TagSuggestionState } from "../extensions/tagSuggestionPlugin"; +/** + * サジェスト範囲(`{ from, to }`)の値による浅比較。両方 `null` のとき同値、 + * 片方だけ `null` のときは不一致と判定する。`isSame*SuggestionState` から + * 呼ばれる補助関数で、state 等価判定経由で setState のスキップに使う。 + * + * Shallow value comparison for a suggestion range (`{ from, to }`). Two + * `null`s compare equal; a `null` against a value compares unequal. Used by + * the per-suggestion-kind comparators below to gate setState calls and + * avoid redundant re-renders. + * + * @param a - 比較対象 A / left-hand range, may be `null` + * @param b - 比較対象 B / right-hand range, may be `null` + * @returns `from` と `to` が両方一致すれば `true`、両方 `null` でも `true`。 + * / `true` when both `from` and `to` match (or both args are `null`). + */ export function isSameSuggestionRange( a: { from: number; to: number } | null, b: { from: number; to: number } | null, @@ -10,6 +26,24 @@ export function isSameSuggestionRange( return a.from === b.from && a.to === b.to; } +/** + * WikiLink (`[[...]]`) サジェスト state の浅比較。`active` / `query` / `range` + * の 3 値だけを見る(`decorations` は `DecorationSet` の参照が毎回変わるので + * 比較対象から外す)。`a` が `null` のときは常に不一致扱いで `setState` の + * 初期化を許す。 + * + * Shallow comparator for the WikiLink (`[[...]]`) suggestion state. Only + * inspects `active`, `query`, and `range`; `decorations` is excluded because + * its `DecorationSet` reference changes every transaction even when no + * meaningful change occurred. When `a` is `null` the comparator returns + * `false` so the very first state lands. + * + * @param a - 直前の state または `null` / previous state, or `null` for + * first-render + * @param b - 新しい state / new state to compare against + * @returns 3 つの値がすべて一致すれば `true`。/ `true` when all three + * tracked fields match. + */ export function isSameWikiLinkSuggestionState( a: WikiLinkSuggestionState | null, b: WikiLinkSuggestionState, @@ -18,6 +52,20 @@ export function isSameWikiLinkSuggestionState( return a.active === b.active && a.query === b.query && isSameSuggestionRange(a.range, b.range); } +/** + * スラッシュコマンド (`/...`) サジェスト state の浅比較。比較対象は WikiLink と + * 同じ 3 値(`active` / `query` / `range`)。`decorations` を除外する理由も + * 同じ。 + * + * Shallow comparator for the slash-command (`/...`) suggestion state. Same + * three-field model as the WikiLink variant; `decorations` is excluded for + * the same reason (reference identity changes per-transaction). + * + * @param a - 直前の state または `null` / previous state, or `null` + * @param b - 新しい state / new state to compare against + * @returns 3 つの値がすべて一致すれば `true`。/ `true` when all three + * tracked fields match. + */ export function isSameSlashSuggestionState( a: SlashSuggestionState | null, b: SlashSuggestionState, @@ -25,3 +73,19 @@ export function isSameSlashSuggestionState( if (!a) return false; return a.active === b.active && a.query === b.query && isSameSuggestionRange(a.range, b.range); } + +/** + * `#name` タグサジェスト用の浅比較ヘルパー。WikiLink / Slash と同じく + * `active`/`query`/`range` だけを見て、不要な再レンダーを防ぐ。 + * + * Shallow comparator for the tag suggestion state. Mirrors the WikiLink / + * slash variants — only `active`, `query`, and `range` matter for re-render + * gating. See issue #767 (Phase 2). + */ +export function isSameTagSuggestionState( + a: TagSuggestionState | null, + b: TagSuggestionState, +): boolean { + if (!a) return false; + return a.active === b.active && a.query === b.query && isSameSuggestionRange(a.range, b.range); +} diff --git a/src/components/editor/TiptapEditor/useEditorSetup.ts b/src/components/editor/TiptapEditor/useEditorSetup.ts index 850377e0..a792d706 100644 --- a/src/components/editor/TiptapEditor/useEditorSetup.ts +++ b/src/components/editor/TiptapEditor/useEditorSetup.ts @@ -11,7 +11,9 @@ import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/core"; import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin"; import type { SlashSuggestionState } from "../extensions/slashSuggestionPlugin"; +import type { TagSuggestionState } from "../extensions/tagSuggestionPlugin"; import type { WikiLinkSuggestionHandle } from "../extensions/WikiLinkSuggestion"; +import type { TagSuggestionHandle } from "../extensions/TagSuggestion"; import type { SlashSuggestionHandle } from "./SlashSuggestionLayer"; import { createEditorExtensions, defaultEditorProps } from "./editorConfig"; import type { TiptapEditorProps } from "./types"; @@ -54,6 +56,7 @@ interface UseEditorSetupOptions { handleLinkClick: (title: string) => void; handleStateChange: (state: WikiLinkSuggestionState) => void; handleSlashStateChange: (state: SlashSuggestionState) => void; + handleTagSuggestionStateChange: (state: TagSuggestionState) => void; handleRetryUpload: (nodeId: string) => void; handleRemoveUpload: (nodeId: string) => void; getProviderLabel: (providerId?: string | null) => string; @@ -62,8 +65,10 @@ interface UseEditorSetupOptions { handleCopyImageUrl: (src: string) => void; suggestionState: WikiLinkSuggestionState | null; slashState: SlashSuggestionState | null; + tagSuggestionState: TagSuggestionState | null; suggestionRef: RefObject; slashRef: RefObject; + tagSuggestionRef: RefObject; /** Note-linked workspace root for `@file:` (Issue #461). / `@file:` 用ワークスペースルート */ workspaceRoot: string | null; /** Current note id for Tauri workspace registry reads (Issue #461). */ @@ -89,6 +94,7 @@ export function useEditorSetup(options: UseEditorSetupOptions) { handleLinkClick, handleStateChange, handleSlashStateChange, + handleTagSuggestionStateChange, handleRetryUpload, handleRemoveUpload, getProviderLabel, @@ -97,8 +103,10 @@ export function useEditorSetup(options: UseEditorSetupOptions) { handleCopyImageUrl, suggestionState, slashState, + tagSuggestionState, suggestionRef, slashRef, + tagSuggestionRef, workspaceRoot, noteId, } = options; @@ -134,10 +142,12 @@ export function useEditorSetup(options: UseEditorSetupOptions) { const slashStateRef = useRef(slashState); const suggestionStateRef = useRef(suggestionState); + const tagSuggestionStateRef = useRef(tagSuggestionState); useEffect(() => { slashStateRef.current = slashState; suggestionStateRef.current = suggestionState; - }, [slashState, suggestionState]); + tagSuggestionStateRef.current = tagSuggestionState; + }, [slashState, suggestionState, tagSuggestionState]); const workspaceRootRef = useWorkspaceRootRef(workspaceRoot); const noteIdRef = useNoteIdRef(noteId); @@ -150,6 +160,7 @@ export function useEditorSetup(options: UseEditorSetupOptions) { onLinkClick: handleLinkClick, onStateChange: handleStateChange, onSlashStateChange: handleSlashStateChange, + onTagSuggestionStateChange: handleTagSuggestionStateChange, imageUploadOptions: { onRetry: handleRetryUpload, onRemove: handleRemoveUpload, @@ -198,6 +209,13 @@ export function useEditorSetup(options: UseEditorSetupOptions) { return slashRef.current.onKeyDown(event); if (suggestionStateRef.current?.active && suggestionRef.current) return suggestionRef.current.onKeyDown(event); + // タグサジェストは最後にチェックする。WikiLink / Slash と排他で + // active になる想定(`#` と `[[` / `/` は同時にトリガしない)。 + // Tag suggestion comes last; in practice it cannot overlap with + // WikiLink (`[[`) or Slash (`/`) because their triggers are + // mutually exclusive characters. + if (tagSuggestionStateRef.current?.active && tagSuggestionRef.current) + return tagSuggestionRef.current.onKeyDown(event); return false; }, }, diff --git a/src/components/editor/TiptapEditor/useSuggestionControllers.ts b/src/components/editor/TiptapEditor/useSuggestionControllers.ts index 361661dd..883f78a6 100644 --- a/src/components/editor/TiptapEditor/useSuggestionControllers.ts +++ b/src/components/editor/TiptapEditor/useSuggestionControllers.ts @@ -1,22 +1,30 @@ import { useCallback, useRef, useState } from "react"; import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin"; import type { SlashSuggestionState } from "../extensions/slashSuggestionPlugin"; +import type { TagSuggestionState } from "../extensions/tagSuggestionPlugin"; import type { WikiLinkSuggestionHandle } from "../extensions/WikiLinkSuggestion"; +import type { TagSuggestionHandle } from "../extensions/TagSuggestion"; import type { SlashSuggestionHandle } from "./SlashSuggestionLayer"; -import { isSameSlashSuggestionState, isSameWikiLinkSuggestionState } from "./suggestionStateUtils"; +import { + isSameSlashSuggestionState, + isSameTagSuggestionState, + isSameWikiLinkSuggestionState, +} from "./suggestionStateUtils"; /** - * WikiLink / Slash サジェストの state と ref を管理する。 + * WikiLink / Slash / Tag サジェストの state と ref を管理する。 * useTiptapEditorController の行数削減のため切り出し。 * - * Manages suggestion state and refs for WikiLink and Slash. - * Extracted from useTiptapEditorController to reduce file length. + * Manages suggestion state and refs for WikiLink, Slash, and Tag (`#name`). + * Extracted from useTiptapEditorController to keep that hook compact. */ export function useSuggestionControllers() { const [suggestionState, setSuggestionState] = useState(null); const [slashState, setSlashState] = useState(null); + const [tagSuggestionState, setTagSuggestionState] = useState(null); const suggestionRef = useRef(null); const slashRef = useRef(null); + const tagSuggestionRef = useRef(null); const handleStateChange = useCallback((state: WikiLinkSuggestionState) => { setSuggestionState((prev) => (isSameWikiLinkSuggestionState(prev, state) ? prev : state)); @@ -24,13 +32,19 @@ export function useSuggestionControllers() { const handleSlashStateChange = useCallback((state: SlashSuggestionState) => { setSlashState((prev) => (isSameSlashSuggestionState(prev, state) ? prev : state)); }, []); + const handleTagSuggestionStateChange = useCallback((state: TagSuggestionState) => { + setTagSuggestionState((prev) => (isSameTagSuggestionState(prev, state) ? prev : state)); + }, []); return { suggestionState, slashState, + tagSuggestionState, suggestionRef, slashRef, + tagSuggestionRef, handleStateChange, handleSlashStateChange, + handleTagSuggestionStateChange, }; } diff --git a/src/components/editor/TiptapEditor/useSuggestionEffects.test.ts b/src/components/editor/TiptapEditor/useSuggestionEffects.test.ts index a1a3ec0b..3d0f7cd2 100644 --- a/src/components/editor/TiptapEditor/useSuggestionEffects.test.ts +++ b/src/components/editor/TiptapEditor/useSuggestionEffects.test.ts @@ -4,6 +4,7 @@ import { useSuggestionEffects } from "./useSuggestionEffects"; import type { Editor } from "@tiptap/core"; import { wikiLinkSuggestionPluginKey } from "../extensions/wikiLinkSuggestionPlugin"; import { slashSuggestionPluginKey } from "../extensions/slashSuggestionPlugin"; +import { tagSuggestionPluginKey } from "../extensions/tagSuggestionPlugin"; const mockCheckReferenced = vi.fn(); vi.mock("@/hooks/usePageQueries", () => ({ @@ -45,6 +46,10 @@ describe("useSuggestionEffects", () => { editor: null as Editor | null, suggestionState: null as { active: boolean; range: { from: number; to: number } | null } | null, slashState: null as { active: boolean; range: { from: number; to: number } | null } | null, + tagSuggestionState: null as { + active: boolean; + range: { from: number; to: number } | null; + } | null, editorContainerRef, pageId: "page-1", handleInsertImageClick: vi.fn(), @@ -182,4 +187,111 @@ describe("useSuggestionEffects", () => { expect(handleInsertImageClick).toHaveBeenCalledTimes(1); }); + + // --- Tag suggestion (issue #767, Phase 2) --- + // タグサジェストの確定 / クローズ動線が WikiLink と同じ契約で動くことを固定。 + // Pin the tag suggestion confirm / close path against the same shape as + // WikiLink so both popovers stay behavior-equivalent. + + it("handleTagSuggestionSelect inserts a tag mark via the editor chain (issue #767)", async () => { + const mockEditor = createMockEditor(); + mockCheckReferenced.mockResolvedValue(false); + + const { result } = renderHook(() => + useSuggestionEffects({ + ...defaultOptions, + editor: mockEditor, + tagSuggestionState: { active: true, range: { from: 1, to: 5 } }, + }), + ); + + await act(async () => { + await result.current.handleTagSuggestionSelect({ + name: "tech", + exists: true, + targetId: "page-uuid-1", + }); + }); + + // 既存ページなので referenced の確認はスキップされる(exists=true)。 + // exists=true → no ghost-link probe. + expect(mockCheckReferenced).not.toHaveBeenCalled(); + expect(mockEditor.chain).toHaveBeenCalled(); + expect(mockEditor.chainRun).toHaveBeenCalled(); + // 確定後はサジェストプラグインに close メタを流して閉じる。 + // Confirms always close the popover via the plugin's `close` meta. + expect(mockEditor.view.state.tr.setMeta).toHaveBeenCalledWith(tagSuggestionPluginKey, { + close: true, + }); + expect(mockEditor.dispatchSpy).toHaveBeenCalled(); + }); + + it("handleTagSuggestionSelect probes ghost references when item.exists is false", async () => { + // 既存ページが無いタグでは ghost_links を見て他ページでの利用有無を判定し + // `referenced` 属性に反映する(WikiLink と同じ規則)。 + // Ghost tags need the referenced check so the mark renders the + // `referenced` style consistently with WikiLink ghosts. + mockCheckReferenced.mockResolvedValue(true); + const mockEditor = createMockEditor(); + + const { result } = renderHook(() => + useSuggestionEffects({ + ...defaultOptions, + editor: mockEditor, + tagSuggestionState: { active: true, range: { from: 1, to: 5 } }, + }), + ); + + await act(async () => { + await result.current.handleTagSuggestionSelect({ + name: "newtag", + exists: false, + targetId: null, + }); + }); + + // checkReferenced は `linkType: "tag"` を明示的に渡す必要がある。さもないと + // `getGhostLinkSources` が既定の `"wiki"` バケットを検索してしまい、タグ用 + // ゴーストリンクが拾えず常に false になる(PR #778 devin レビュー指摘)。 + // The third argument MUST be `"tag"` so `getGhostLinkSources` searches the + // tag bucket; without it, tag ghosts go undiscovered and `referenced` + // would always resolve to false (PR #778 devin review). + expect(mockCheckReferenced).toHaveBeenCalledWith("newtag", "page-1", "tag"); + expect(mockEditor.chainRun).toHaveBeenCalled(); + }); + + it("handleTagSuggestionSelect does nothing when editor is null", async () => { + const { result } = renderHook(() => + useSuggestionEffects({ + ...defaultOptions, + tagSuggestionState: { active: true, range: { from: 0, to: 5 } }, + }), + ); + + await act(async () => { + await result.current.handleTagSuggestionSelect({ + name: "tech", + exists: false, + targetId: null, + }); + }); + + expect(mockCheckReferenced).not.toHaveBeenCalled(); + }); + + it("handleTagSuggestionClose dispatches with tagSuggestionPluginKey when editor exists", () => { + const mockEditor = createMockEditor(); + const { result } = renderHook(() => + useSuggestionEffects({ ...defaultOptions, editor: mockEditor }), + ); + + act(() => { + result.current.handleTagSuggestionClose(); + }); + + expect(mockEditor.view.state.tr.setMeta).toHaveBeenCalledWith(tagSuggestionPluginKey, { + close: true, + }); + expect(mockEditor.dispatchSpy).toHaveBeenCalled(); + }); }); diff --git a/src/components/editor/TiptapEditor/useSuggestionEffects.ts b/src/components/editor/TiptapEditor/useSuggestionEffects.ts index 1b6309d7..f270e883 100644 --- a/src/components/editor/TiptapEditor/useSuggestionEffects.ts +++ b/src/components/editor/TiptapEditor/useSuggestionEffects.ts @@ -8,13 +8,16 @@ import { slashSuggestionPluginKey, type SlashSuggestionState, } from "../extensions/slashSuggestionPlugin"; +import { tagSuggestionPluginKey, type TagSuggestionState } from "../extensions/tagSuggestionPlugin"; import type { SuggestionItem } from "../extensions/WikiLinkSuggestion"; +import type { TagSuggestionItem } from "../extensions/TagSuggestion"; import { useCheckGhostLinkReferenced } from "@/hooks/usePageQueries"; interface UseSuggestionEffectsOptions { editor: Editor | null; suggestionState: WikiLinkSuggestionState | null; slashState: SlashSuggestionState | null; + tagSuggestionState: TagSuggestionState | null; editorContainerRef: React.RefObject; pageId: string; handleInsertImageClick: () => void; @@ -28,6 +31,7 @@ export function useSuggestionEffects({ editor, suggestionState, slashState, + tagSuggestionState, editorContainerRef, pageId, handleInsertImageClick, @@ -45,6 +49,14 @@ export function useSuggestionEffects({ * */ const [slashPos, setSlashPos] = useState<{ top: number; left: number } | null>(null); + /** + * Floating popover position for the `#name` tag suggestion. Issue #767 (Phase 2). + * `#name` タグサジェスト用のフローティング表示位置(issue #767 Phase 2)。 + */ + const [tagSuggestionPos, setTagSuggestionPos] = useState<{ + top: number; + left: number; + } | null>(null); // 依存はプリミティブに限定。suggestionState 自体は毎レンダーで新しい参照になるため // オブジェクトを依存にすると setState → 再レンダ → effect 再実行の無限ループになる。 @@ -120,6 +132,29 @@ export function useSuggestionEffects({ } }, [editor, slashActive, slashFrom, slashTo, editorContainerRef]); + // Tag suggestion position. WikiLink / Slash と同じプリミティブ依存パターン。 + // Tag suggestion popover position; same primitive-dependency pattern. + const tagActive = tagSuggestionState?.active ?? false; + const tagFrom = tagSuggestionState?.range?.from ?? null; + const tagTo = tagSuggestionState?.range?.to ?? null; + + useEffect(() => { + if (!editor || !tagActive || tagFrom === null) { + queueMicrotask(() => setTagSuggestionPos(null)); + return; + } + const coords = editor.view.coordsAtPos(tagFrom); + const containerRect = editorContainerRef.current?.getBoundingClientRect(); + if (containerRect) { + queueMicrotask(() => + setTagSuggestionPos({ + top: coords.bottom - containerRect.top + 4, + left: coords.left - containerRect.left, + }), + ); + } + }, [editor, tagActive, tagFrom, tagTo, editorContainerRef]); + useEffect(() => { /** * @@ -197,11 +232,79 @@ export function useSuggestionEffects({ editor.view.dispatch(editor.view.state.tr.setMeta(slashSuggestionPluginKey, { close: true })); }, [editor]); + /** + * Tag (`#name`) サジェスト確定。範囲を `#name` に置換し、`tag` Mark を直接 + * 付与する。`exists` / `targetId` は候補側で解決済みの値を流す(issue #767)。 + * 解決失敗時は `referenced` を ghost_links から取得して埋める(WikiLink と + * 同じ手順)。 + * + * Confirm a tag (`#name`) suggestion: replace the typed range with the + * styled mark and use the resolved attrs from the candidate. For ghost + * (non-existing) tags, also probe `ghost_links` for a `referenced` flag, + * the same shape the WikiLink suggestion uses. + */ + const handleTagSuggestionSelect = useCallback( + async (item: TagSuggestionItem) => { + if (!editor || !tagSuggestionState?.range) return; + const { from, to } = tagSuggestionState.range; + let referenced = false; + if (!item.exists) { + // 既存ページが無いタグでも、別ページで `#name` として登場していれば + // `referenced=true`(WikiLink ゴーストと同じ判定)。`linkType: "tag"` + // を明示しないと `getGhostLinkSources` が既定の `"wiki"` バケットを + // 検索してしまい、タグ用ゴーストリンクが拾えず常に false になる + // (PR #778 devin-ai-integration レビュー指摘)。 + // No real page yet, but the tag may already appear on other pages — + // mirror the WikiLink ghost referenced-check. We must pass + // `linkType: "tag"` explicitly: without it, `getGhostLinkSources` + // searches the default `"wiki"` bucket and tag ghosts are never + // found, so `referenced` stays false (PR #778 devin review). + referenced = await checkReferenced(item.name, pageId, "tag"); + } + editor + .chain() + .focus() + .deleteRange({ from, to }) + .insertContent([ + { + type: "text", + marks: [ + { + type: "tag", + attrs: { + name: item.name, + exists: item.exists, + referenced, + targetId: item.targetId, + }, + }, + ], + text: `#${item.name}`, + }, + ]) + .run(); + // 確定後はサジェストを必ず閉じ、後続のキーストロークで新規入力規則が + // 自然に効くようにする。 + // Always close the popover after confirm so subsequent typing flows + // through the input-rule path normally. + editor.view.dispatch(editor.view.state.tr.setMeta(tagSuggestionPluginKey, { close: true })); + }, + [editor, tagSuggestionState, checkReferenced, pageId], + ); + + const handleTagSuggestionClose = useCallback(() => { + if (!editor) return; + editor.view.dispatch(editor.view.state.tr.setMeta(tagSuggestionPluginKey, { close: true })); + }, [editor]); + return { suggestionPos, slashPos, + tagSuggestionPos, handleSuggestionSelect, handleSuggestionClose, handleSlashClose, + handleTagSuggestionSelect, + handleTagSuggestionClose, }; } diff --git a/src/components/editor/TiptapEditor/useTiptapEditorController.ts b/src/components/editor/TiptapEditor/useTiptapEditorController.ts index ad75199c..fe2eeb40 100644 --- a/src/components/editor/TiptapEditor/useTiptapEditorController.ts +++ b/src/components/editor/TiptapEditor/useTiptapEditorController.ts @@ -2,7 +2,9 @@ import { useRef, useState, type MutableRefObject, type RefObject } from "react"; import type { Editor } from "@tiptap/core"; import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin"; import type { SlashSuggestionState } from "../extensions/slashSuggestionPlugin"; +import type { TagSuggestionState } from "../extensions/tagSuggestionPlugin"; import type { WikiLinkSuggestionHandle } from "../extensions/WikiLinkSuggestion"; +import type { TagSuggestionHandle } from "../extensions/TagSuggestion"; import type { SlashSuggestionHandle } from "./SlashSuggestionLayer"; import { useGeneralSettings } from "@/hooks/useGeneralSettings"; import { useWikiLinkNavigation } from "./useWikiLinkNavigation"; @@ -39,6 +41,7 @@ function useEditorControllers(args: { handleLinkClick: (title: string) => void; handleStateChange: (state: WikiLinkSuggestionState) => void; handleSlashStateChange: (state: SlashSuggestionState) => void; + handleTagSuggestionStateChange: (state: TagSuggestionState) => void; handleRetryUpload: (nodeId: string) => void; handleRemoveUpload: (nodeId: string) => void; getProviderLabel: (providerId?: string | null) => string; @@ -47,8 +50,10 @@ function useEditorControllers(args: { handleCopyImageUrl: (src: string) => void; suggestionState: WikiLinkSuggestionState | null; slashState: SlashSuggestionState | null; + tagSuggestionState: TagSuggestionState | null; suggestionRef: RefObject; slashRef: RefObject; + tagSuggestionRef: RefObject; handleInsertImageClick: () => void; handleInsertCameraImageClick: () => void; handleImageUpload: (files: File[]) => Promise; @@ -78,6 +83,7 @@ function useEditorControllers(args: { handleLinkClick: args.handleLinkClick, handleStateChange: args.handleStateChange, handleSlashStateChange: args.handleSlashStateChange, + handleTagSuggestionStateChange: args.handleTagSuggestionStateChange, handleRetryUpload: args.handleRetryUpload, handleRemoveUpload: args.handleRemoveUpload, getProviderLabel: args.getProviderLabel, @@ -86,8 +92,10 @@ function useEditorControllers(args: { handleCopyImageUrl: args.handleCopyImageUrl, suggestionState: args.suggestionState, slashState: args.slashState, + tagSuggestionState: args.tagSuggestionState, suggestionRef: args.suggestionRef, slashRef: args.slashRef, + tagSuggestionRef: args.tagSuggestionRef, workspaceRoot: args.workspaceRoot, noteId: args.noteId, }); @@ -96,6 +104,7 @@ function useEditorControllers(args: { editor, suggestionState: args.suggestionState, slashState: args.slashState, + tagSuggestionState: args.tagSuggestionState, editorContainerRef: args.editorContainerRef, pageId: args.pageId, handleInsertImageClick: args.handleInsertImageClick, @@ -214,6 +223,7 @@ export function useTiptapEditorController({ handleLinkClick, handleStateChange: suggestionControllers.handleStateChange, handleSlashStateChange: suggestionControllers.handleSlashStateChange, + handleTagSuggestionStateChange: suggestionControllers.handleTagSuggestionStateChange, handleRetryUpload: imageUpload.handleRetryUpload, handleRemoveUpload: imageUpload.handleRemoveUpload, getProviderLabel, @@ -222,8 +232,10 @@ export function useTiptapEditorController({ handleCopyImageUrl, suggestionState: suggestionControllers.suggestionState, slashState: suggestionControllers.slashState, + tagSuggestionState: suggestionControllers.tagSuggestionState, suggestionRef: suggestionControllers.suggestionRef, slashRef: suggestionControllers.slashRef, + tagSuggestionRef: suggestionControllers.tagSuggestionRef, handleInsertImageClick: imageUpload.handleInsertImageClick, handleInsertCameraImageClick: imageUpload.handleInsertCameraImageClick, handleImageUpload: imageUpload.handleImageUpload, @@ -258,6 +270,11 @@ export function useTiptapEditorController({ slashPos: editorControllers.slashPos, slashRef: suggestionControllers.slashRef, handleSlashClose: editorControllers.handleSlashClose, + tagSuggestionState: suggestionControllers.tagSuggestionState, + tagSuggestionPos: editorControllers.tagSuggestionPos, + tagSuggestionRef: suggestionControllers.tagSuggestionRef, + handleTagSuggestionSelect: editorControllers.handleTagSuggestionSelect, + handleTagSuggestionClose: editorControllers.handleTagSuggestionClose, mermaidDialogOpen, setMermaidDialogOpen, handleInsertMermaid: editorControllers.handleInsertMermaid, diff --git a/src/components/editor/extensions/TagExtension.test.ts b/src/components/editor/extensions/TagExtension.test.ts index f406a189..a2e671b3 100644 --- a/src/components/editor/extensions/TagExtension.test.ts +++ b/src/components/editor/extensions/TagExtension.test.ts @@ -519,6 +519,68 @@ describe("TagExtension input rule", () => { editor.destroy(); } }); + + it("defers to the suggestion popover while it is active (issue #767)", async () => { + // Phase 2: タグサジェストが open の間は入力規則を発火させない。確定操作は + // サジェスト側が `tag` Mark を直接挿入するため、入力規則経由で重複マーク化 + // すると `exists` / `targetId` などの解決済み属性を上書きしてしまう。 + // Phase 2: while the suggestion popover is open, the input rule must + // defer so the suggestion's confirm path retains its resolved attrs + // (issue #767). We install both extensions, place the caret at the end + // of `#tech` (which makes the suggestion plugin go active), then + // type a space. The rule must NOT fire — the popover owns the confirm + // path. + const { TagSuggestionPlugin, tagSuggestionPluginKey } = await import("./tagSuggestionPlugin"); + const editor = new Editor({ + extensions: [StarterKit, Tag, TagSuggestionPlugin], + content: "

#tech

", + }); + try { + // カーソル末尾セットでサジェストの `apply()` が走り active になる。 + // Moving the caret runs the suggestion plugin's `apply()` and + // activates it (the regex sees `#tech` as text-before-cursor). + editor.commands.setTextSelection(6); + expect(tagSuggestionPluginKey.getState(editor.state)?.active).toBe(true); + + const handled = typeAt(editor, 6, " "); + // 入力規則は null を返し、Tiptap デフォルトの空白挿入に委ねる。 + // The rule returns null and lets Tiptap insert the space normally. + expect(handled).toBeFalsy(); + const html = editor.getHTML(); + expect(html).not.toContain("data-tag"); + } finally { + editor.destroy(); + } + }); + + it("falls through to the input rule after the suggestion is closed (issue #767 fallback)", async () => { + // 受け入れ条件のフォールバック契約: Esc で閉じた後に空白終端を打つと + // 入力規則経由でマーク化される。サジェストプラグインに `close` メタを + // 流して閉じてから空白を投入する。 + // Acceptance-criteria fallback: closing the suggestion (Esc) then typing + // a terminator goes through the input-rule path. We activate the + // suggestion via cursor placement, dispatch the `close` meta to + // simulate Esc, then type a space. + const { TagSuggestionPlugin, tagSuggestionPluginKey } = await import("./tagSuggestionPlugin"); + const editor = new Editor({ + extensions: [StarterKit, Tag, TagSuggestionPlugin], + content: "

#tech

", + }); + try { + editor.commands.setTextSelection(6); + expect(tagSuggestionPluginKey.getState(editor.state)?.active).toBe(true); + editor.view.dispatch(editor.state.tr.setMeta(tagSuggestionPluginKey, { close: true })); + expect(tagSuggestionPluginKey.getState(editor.state)?.active).toBe(false); + + const handled = typeAt(editor, 6, " "); + expect(handled).toBe(true); + const html = editor.getHTML(); + expect(html).toContain('data-name="tech"'); + expect(html).toContain("data-tag"); + } finally { + editor.destroy(); + } + }); }); }); diff --git a/src/components/editor/extensions/TagExtension.ts b/src/components/editor/extensions/TagExtension.ts index 6a39b4da..4b3bfcdc 100644 --- a/src/components/editor/extensions/TagExtension.ts +++ b/src/components/editor/extensions/TagExtension.ts @@ -1,6 +1,7 @@ import { InputRule, Mark, markPasteRule, mergeAttributes } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { TAG_NAME_CHAR_CLASS } from "@zedi/shared/tagCharacterClass"; +import { tagSuggestionPluginKey } from "./tagSuggestionPlugin"; /** * Regex matching hashtag patterns `#name` in pasted text. @@ -285,6 +286,23 @@ export const Tag = Mark.create({ const fullMatch = match[0]; if (!tagText || !fullMatch) return null; + // タグサジェスト (`TagSuggestionPlugin`, issue #767 Phase 2) が + // open の間は入力規則を発火させない。確定操作はサジェスト側が + // `tag` Mark の挿入を担当するため、ここで二重にマーク化すると + // `exists` / `targetId` 等の解決済み属性を上書きしてしまう。 + // Esc でサジェストを閉じた後の空白終端は inactive 状態になるので + // 通常どおり入力規則経路でマーク化される(フォールバック契約)。 + // + // While the tag suggestion popover is open (`TagSuggestionPlugin`, + // issue #767 Phase 2) the input rule must defer: confirming via the + // popover applies the mark with resolved `exists` / `targetId`, + // and re-running the input rule here would clobber those attrs + // with default values. After Esc the suggestion is inactive, so + // typing a terminator falls through to the input-rule path — the + // documented Esc-then-terminator fallback. + const suggestionState = tagSuggestionPluginKey.getState(state); + if (suggestionState?.active) return null; + // Re-use the same exclusion contract as the paste rule so typed and // pasted input share their reject reasons (numeric-only / 6/8-hex). // 貼り付け規則と同じ除外ルールに合わせる(数字のみ・6/8 桁 hex)。 diff --git a/src/components/editor/extensions/TagSuggestion.test.tsx b/src/components/editor/extensions/TagSuggestion.test.tsx new file mode 100644 index 00000000..47372de5 --- /dev/null +++ b/src/components/editor/extensions/TagSuggestion.test.tsx @@ -0,0 +1,311 @@ +/** + * Tests for the `#name` suggestion popover (issue #767 Phase 2). + * `#name` 用サジェストポップオーバーのテスト(issue #767 Phase 2)。 + * + * Pins the popup's items-list contract — especially the regression captured + * in the gemini-code-assist review on PR #778: an exact match must remain + * selectable even when more than `MAX_VISIBLE` candidates contain the query + * substring. Without the prioritising sort, the exact match was sliced off + * the visible list while still suppressing the "create new" fallback. + * + * gemini-code-assist の PR #778 レビュー指摘(完全一致候補が `MAX_VISIBLE` を + * 超えると表示から漏れる一方で「新規作成」項目も追加されず、ユーザが選べない + * 状態になる)を再発させないテストを置く。 + */ + +import { describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { createRef, type RefObject } from "react"; +import { TagSuggestion, type TagSuggestionHandle } from "./TagSuggestion"; +import type { TagSuggestionCandidate } from "@/hooks/useTagCandidates"; + +/** + * 矢印キーの状態更新後に ref.current を最新の handle に追従させるためのヘルパー。 + * `setSelectedIndex` は再レンダーをスケジュールし、その後に + * `useImperativeHandle` が ref を差し替える。`act` で同期 flush することで + * 次の pressKey が新しいクロージャに当たる。 + * + * Helper that flushes the re-render scheduled by `setSelectedIndex` so the + * next `ref.current?.onKeyDown` call sees the updated imperative handle. + */ +function pressKey(ref: RefObject, key: string): boolean | undefined { + let handled: boolean | undefined; + act(() => { + handled = ref.current?.onKeyDown(new KeyboardEvent("keydown", { key })); + }); + return handled; +} + +/** + * 初期マウント時に `useEffect` が `queueMicrotask` で予約した + * `setSelectedIndex(0)` を flush するためのヘルパー。これを呼ばないと最初の + * 矢印キー押下時に「リセット → 矢印反映」の順で state が更新されて、 + * テストの期待値と矛盾する。 + * + * Drain the microtask that the component queues during initial mount via + * `useEffect` (the `setSelectedIndex(0)` reset). Without this, the first + * arrow-key press races the reset and ends up at index 0. + */ +async function settleInitialMicrotasks(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +function renderTagSuggestion(props: { + query: string; + candidates: TagSuggestionCandidate[]; + onSelect?: (item: { name: string; exists: boolean; targetId: string | null }) => void; + onClose?: () => void; +}) { + const ref: RefObject = createRef(); + const onSelect = props.onSelect ?? vi.fn(); + const onClose = props.onClose ?? vi.fn(); + const utils = render( + , + ); + return { ...utils, ref, onSelect, onClose }; +} + +describe("TagSuggestion — items list", () => { + // 6 件以上の候補が部分一致するシナリオを再現するための雛形。`tec` を含む + // 候補を 7 件並べ、6 番目 (`tec`) がちょうど完全一致になる構成。 + // Builds a candidate list of 7 entries that all match `tec`, with the 6th + // entry being the exact match — designed to reproduce the original bug. + function makeOverflowingCandidates(): TagSuggestionCandidate[] { + return [ + { name: "techDeep", exists: true, targetId: "p1" }, + { name: "technique", exists: true, targetId: "p2" }, + { name: "techEarly", exists: true, targetId: "p3" }, + { name: "technician", exists: true, targetId: "p4" }, + { name: "techlead", exists: true, targetId: "p5" }, + { name: "tec", exists: true, targetId: "p6" }, // exact match + { name: "tecArchive", exists: true, targetId: "p7" }, + ]; + } + + it("keeps the exact match visible even when it sits beyond MAX_VISIBLE substring matches (issue #767 review)", () => { + // バグ前: `tec` 完全一致が 6 番目だったため `slice(0, 5)` で消え、なおかつ + // `exactMatch` が truthy だったため「新規作成」項目も追加されず選択不能。 + // Pre-fix: with `tec` at position 6, the slice dropped it from the visible + // list and the "create" fallback was suppressed (exactMatch was truthy). + renderTagSuggestion({ query: "tec", candidates: makeOverflowingCandidates() }); + + const buttons = screen.getAllByRole("button"); + // 完全一致 `#tec` が表示されている。 + // The exact-match `#tec` row is rendered. + const tecButton = buttons.find((b) => b.textContent === "#tec"); + expect(tecButton).toBeDefined(); + }); + + it("places the exact match at the top of the visible list", () => { + // 並べ替えの安定性を固定: 完全一致が先頭に来る(残り順序は元のまま)。 + // Pin sort stability — exact match leads, the rest keep insertion order. + renderTagSuggestion({ query: "tec", candidates: makeOverflowingCandidates() }); + const buttons = screen.getAllByRole("button"); + expect(buttons[0]?.textContent).toBe("#tec"); + }); + + it("shows the create-new fallback only when no exact match exists", () => { + // 完全一致が無いクエリでは「新規作成」項目を出す。完全一致があるクエリでは + // 既存の挙動どおり出さない。 + // Without an exact match the create option appears; with one, it does not. + const candidates: TagSuggestionCandidate[] = [ + { name: "techDeep", exists: true, targetId: "p1" }, + ]; + renderTagSuggestion({ query: "tec", candidates }); + + expect(screen.getByText('"#tec" を作成')).toBeInTheDocument(); + cleanup(); + + const withExact: TagSuggestionCandidate[] = [ + ...candidates, + { name: "tec", exists: true, targetId: "p2" }, + ]; + renderTagSuggestion({ query: "tec", candidates: withExact }); + expect(screen.queryByText('"#tec" を作成')).toBeNull(); + }); + + it("returns null (renders nothing) when the candidate list is empty and the query is empty", () => { + const { container } = renderTagSuggestion({ query: "", candidates: [] }); + expect(container.firstChild).toBeNull(); + }); + + it("invokes onSelect with the chosen item when a row is clicked", () => { + const onSelect = vi.fn(); + const candidates: TagSuggestionCandidate[] = [ + { name: "tech", exists: true, targetId: "p-uuid-1" }, + { name: "design", exists: true, targetId: "p-uuid-2" }, + ]; + renderTagSuggestion({ query: "des", candidates, onSelect }); + + const button = screen.getByRole("button", { name: /#design/ }); + fireEvent.click(button); + + expect(onSelect).toHaveBeenCalledWith({ + name: "design", + exists: true, + targetId: "p-uuid-2", + }); + }); +}); + +describe("TagSuggestion — keyboard navigation via imperative handle", () => { + it("Enter confirms the highlighted item", () => { + const onSelect = vi.fn(); + const candidates: TagSuggestionCandidate[] = [ + { name: "tech", exists: true, targetId: "p1" }, + { name: "tea", exists: true, targetId: "p2" }, + ]; + const { ref } = renderTagSuggestion({ query: "te", candidates, onSelect }); + + const enterEvent = new KeyboardEvent("keydown", { key: "Enter" }); + expect(ref.current?.onKeyDown(enterEvent)).toBe(true); + expect(onSelect).toHaveBeenCalledWith({ + name: "tech", + exists: true, + targetId: "p1", + }); + }); + + it("Tab confirms the highlighted item (acceptance criteria)", () => { + // 受け入れ条件で Tab も Enter と同じく確定操作として扱う。 + // Acceptance criteria: Tab confirms just like Enter. + const onSelect = vi.fn(); + const candidates: TagSuggestionCandidate[] = [{ name: "tech", exists: true, targetId: "p1" }]; + const { ref } = renderTagSuggestion({ query: "tec", candidates, onSelect }); + + const tabEvent = new KeyboardEvent("keydown", { key: "Tab" }); + expect(ref.current?.onKeyDown(tabEvent)).toBe(true); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it("Escape calls onClose", () => { + const onClose = vi.fn(); + const candidates: TagSuggestionCandidate[] = [{ name: "tech", exists: true, targetId: "p1" }]; + const { ref } = renderTagSuggestion({ query: "tec", candidates, onClose }); + + const escEvent = new KeyboardEvent("keydown", { key: "Escape" }); + expect(ref.current?.onKeyDown(escEvent)).toBe(true); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("Escape still closes when the items list is empty (no matches yet)", () => { + // 候補ゼロでも Esc で閉じる経路を保証する(プラグインの close メタ送出への + // 入口)。それ以外のキーは素通し(タイピング継続を妨げない)。 + // Esc must still close even when there are no items so the host can + // dispatch the close meta on the plugin. Other keys fall through to keep + // typing alive. + const onClose = vi.fn(); + const { ref } = renderTagSuggestion({ query: "", candidates: [], onClose }); + + const escEvent = new KeyboardEvent("keydown", { key: "Escape" }); + expect(ref.current?.onKeyDown(escEvent)).toBe(true); + expect(onClose).toHaveBeenCalledTimes(1); + + const otherEvent = new KeyboardEvent("keydown", { key: "Enter" }); + expect(ref.current?.onKeyDown(otherEvent)).toBe(false); + }); + + // 矢印キー / ラップアラウンド契約を固定する(受け入れ条件 + PR #778 レビュー反映)。 + // Pin the arrow-key + wrap-around contract listed in the issue acceptance + // criteria (review feedback on PR #778 from coderabbitai). + it("ArrowUp from the first item wraps to the last and Enter confirms it", async () => { + // クエリ `tea` が `tea` と完全一致 + `team` の部分一致になり、`tech` には + // マッチしないため items は [tea (exact, 並び替え先頭), team] の 2 件、 + // 「新規作成」項目は混ざらない。ArrowUp が末尾の `team` にラップする挙動を + // 純粋に検証できる。 + // `tea` exactly matches one candidate and substring-matches `team`, so + // items is exactly [tea, team] without any "create new" suffix — + // letting us test ArrowUp wrap to the last real candidate cleanly. + const onSelect = vi.fn(); + const candidates: TagSuggestionCandidate[] = [ + { name: "tea", exists: true, targetId: "p2" }, + { name: "team", exists: true, targetId: "p3" }, + { name: "tech", exists: true, targetId: "p1" }, // does NOT match "tea" + ]; + const { ref } = renderTagSuggestion({ query: "tea", candidates, onSelect }); + await settleInitialMicrotasks(); + + expect(pressKey(ref, "ArrowUp")).toBe(true); + expect(pressKey(ref, "Enter")).toBe(true); + expect(onSelect).toHaveBeenLastCalledWith({ + name: "team", + exists: true, + targetId: "p3", + }); + }); + + it("ArrowDown from the last item wraps back to the first and Enter confirms it", async () => { + const onSelect = vi.fn(); + const candidates: TagSuggestionCandidate[] = [ + { name: "tea", exists: true, targetId: "p2" }, + { name: "team", exists: true, targetId: "p3" }, + ]; + // query が完全一致するため "create new" は混ざらず items.length = 2。 + // Exact match → no "create new" suffix, items.length is exactly 2. + const { ref } = renderTagSuggestion({ query: "tea", candidates, onSelect }); + await settleInitialMicrotasks(); + + // 0 → 1 → 0(末尾を通り越して先頭へ巻き戻る)。 + // 0 → 1 → 0 (wrap from last back to first). + expect(pressKey(ref, "ArrowDown")).toBe(true); + expect(pressKey(ref, "ArrowDown")).toBe(true); + expect(pressKey(ref, "Enter")).toBe(true); + expect(onSelect).toHaveBeenLastCalledWith({ + name: "tea", + exists: true, + targetId: "p2", + }); + }); + + it("ArrowDown advances the highlight one step at a time", async () => { + // 単発の ArrowDown でも次の候補にハイライトが進むことを Enter 経由で確認。 + // A single ArrowDown advances by one — verified via Enter confirming the + // next candidate. + const onSelect = vi.fn(); + const candidates: TagSuggestionCandidate[] = [ + { name: "tech", exists: true, targetId: "p1" }, + { name: "tea", exists: true, targetId: "p2" }, + { name: "team", exists: true, targetId: "p3" }, + ]; + const { ref } = renderTagSuggestion({ query: "te", candidates, onSelect }); + await settleInitialMicrotasks(); + + expect(pressKey(ref, "ArrowDown")).toBe(true); + expect(pressKey(ref, "Enter")).toBe(true); + expect(onSelect).toHaveBeenLastCalledWith({ + name: "tea", + exists: true, + targetId: "p2", + }); + }); + + it("Arrow keys are no-ops with a single candidate (no out-of-range index)", async () => { + // 候補が 1 件のとき、矢印キーで配列範囲外に飛ばないことを保証する。 + // クエリは `tech` 完全一致にして「新規作成」項目が混ざらないようにする。 + // With a single matching item (and an exact-match query so no "create + // new" suffix is added) the ref must not push the highlight out of range. + const onSelect = vi.fn(); + const candidates: TagSuggestionCandidate[] = [{ name: "tech", exists: true, targetId: "p1" }]; + const { ref } = renderTagSuggestion({ query: "tech", candidates, onSelect }); + await settleInitialMicrotasks(); + + expect(pressKey(ref, "ArrowUp")).toBe(true); + expect(pressKey(ref, "ArrowDown")).toBe(true); + expect(pressKey(ref, "Enter")).toBe(true); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenLastCalledWith({ + name: "tech", + exists: true, + targetId: "p1", + }); + }); +}); diff --git a/src/components/editor/extensions/TagSuggestion.tsx b/src/components/editor/extensions/TagSuggestion.tsx new file mode 100644 index 00000000..80217b3d --- /dev/null +++ b/src/components/editor/extensions/TagSuggestion.tsx @@ -0,0 +1,200 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from "react"; +import { cn } from "@zedi/ui"; +import { Hash, Plus } from "lucide-react"; +import type { TagSuggestionCandidate } from "@/hooks/useTagCandidates"; + +/** + * 表示用の 1 アイテム。`exists=true` は既存ページに対応するタグ、`exists=false` + * は「このタグ名で確定する(新規 / ゴースト)」を示す。 + * + * One row in the tag suggestion popover. `exists=false` represents the + * "create / accept as ghost" path — the same UX shape as + * `WikiLinkSuggestion`'s "create new" item. + */ +export interface TagSuggestionItem { + /** Display + insertion name (no leading `#`). 表示と挿入用の名前。 */ + name: string; + /** 同名ページがスコープ内に存在するか / page with this name exists in scope */ + exists: boolean; + /** + * 解決済みターゲットページ id。`exists === true` のときのみ非 null。 + * Resolved target page id; non-null only when `exists === true`. + */ + targetId: string | null; +} + +interface TagSuggestionProps { + query: string; + /** + * `#name` 全体のドキュメント内範囲。確定時の置換に使うが、本コンポーネントは + * 表示のみ担当するためここでは保持しない(呼び出し側が `onSelect` で利用)。 + * + * Document range covering `#name`. Consumed by the caller via `onSelect`; + * this component doesn't read it directly but keeps the prop so the parent + * layer matches `WikiLinkSuggestion`'s shape (issue #767 review feedback). + */ + range: { from: number; to: number }; + onSelect: (item: TagSuggestionItem) => void; + onClose: () => void; + /** + * 呼び出し側がスコープ別に絞り込んだ候補。`useTagCandidates` の出力を + * そのまま渡す。 + * + * Pre-scoped candidates supplied by the caller (typically the result of + * `useTagCandidates`). The component stays pure-presentation so scope + * decisions live in the host. + */ + candidates: TagSuggestionCandidate[]; +} + +/** + * `onKeyDown` が `true` を返すと呼び出し元は既定のキーハンドリングを抑止する。 + * Imperative handle exposing `onKeyDown`; returning `true` tells the editor + * to suppress default key handling. + */ +export interface TagSuggestionHandle { + onKeyDown: (event: KeyboardEvent) => boolean; +} + +const MAX_VISIBLE = 5; + +/** + * `#name` 用のサジェストポップアップ。WikiLink 用 `WikiLinkSuggestion` と + * 同じ操作モデル(矢印キーで移動、Enter / Tab で確定、Esc で閉じる)。 + * 候補は呼び出し側で大文字小文字無視のスコープ絞り込み済みのものを受け取り、 + * 本コンポーネントは表示と確定だけを担当する。 + * + * Tag (`#name`) suggestion popup. Mirrors `WikiLinkSuggestion`'s key model + * (arrows to move, Enter / Tab to confirm, Esc to close). Candidates are + * pre-scoped + de-duplicated by the caller; this component only renders and + * forwards the confirm signal. See issue #767 (Phase 2). + */ +export const TagSuggestion = forwardRef( + ({ query, candidates, onSelect, onClose }, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const items = useMemo(() => { + const normalized = query.trim().toLowerCase(); + // `MAX_VISIBLE` のスライス前に「完全一致を先頭」へ並べ替えるのが要点。 + // これをやらないと、6 件目以降に完全一致がいた場合に表示から漏れたうえで + // `exactMatch` が truthy になり「新規作成」項目も追加されないため、ユーザが + // 完全一致候補を選択できなくなる(gemini-code-assist のレビュー指摘)。 + // Sort exact-match candidates to the front BEFORE slicing to MAX_VISIBLE. + // Without this, a candidate matching the query exactly but sitting outside + // the first 5 includes-matches gets dropped from the visible list while + // `exactMatch` is still truthy, so the "create new" fallback also doesn't + // fire — leaving the user unable to select the exact tag (gemini review). + const filtered = normalized + ? candidates.filter((c) => c.name.toLowerCase().includes(normalized)) + : candidates; + const sorted = [...filtered].sort((a, b) => { + const aExact = a.name.toLowerCase() === normalized; + const bExact = b.name.toLowerCase() === normalized; + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + return 0; + }); + const matching = sorted.slice(0, MAX_VISIBLE).map((c) => ({ + name: c.name, + exists: c.exists, + targetId: c.targetId, + })); + + const exactMatch = candidates.find((c) => c.name.toLowerCase() === normalized); + const result = [...matching]; + if (query.trim() && !exactMatch) { + result.push({ name: query.trim(), exists: false, targetId: null }); + } + return result; + }, [query, candidates]); + + // クエリ変更ごとに選択位置をリセット。再描画後に走るよう microtask で + // 倒すのは `WikiLinkSuggestion` と同じ手法(ちらつき防止)。 + // Reset highlight on every query change; same microtask trick as + // `WikiLinkSuggestion` to avoid intermediate flicker. + useEffect(() => { + queueMicrotask(() => setSelectedIndex(0)); + }, [query]); + + const selectItem = useCallback( + (index: number) => { + const item = items[index]; + if (item) onSelect(item); + }, + [items, onSelect], + ); + + useImperativeHandle(ref, () => ({ + onKeyDown: (event: KeyboardEvent) => { + if (items.length === 0) { + // Esc は常に閉じる、それ以外は素通し(タイピング継続を妨げない)。 + // Esc still closes; everything else falls through to keep typing. + if (event.key === "Escape") { + onClose(); + return true; + } + return false; + } + + if (event.key === "ArrowUp") { + setSelectedIndex((prev) => (prev <= 0 ? items.length - 1 : prev - 1)); + return true; + } + + if (event.key === "ArrowDown") { + setSelectedIndex((prev) => (prev >= items.length - 1 ? 0 : prev + 1)); + return true; + } + + if (event.key === "Enter" || event.key === "Tab") { + // Enter / Tab どちらでも確定する(受け入れ条件)。 + // Both Enter and Tab confirm — matches the issue's acceptance criteria. + selectItem(selectedIndex); + return true; + } + + if (event.key === "Escape") { + onClose(); + return true; + } + + return false; + }, + })); + + if (items.length === 0) { + return null; + } + + return ( +
+
+ {items.map((item, index) => ( + + ))} +
+
+ ); + }, +); + +TagSuggestion.displayName = "TagSuggestion"; diff --git a/src/components/editor/extensions/tagSuggestionPlugin.test.ts b/src/components/editor/extensions/tagSuggestionPlugin.test.ts new file mode 100644 index 00000000..9c4f8906 --- /dev/null +++ b/src/components/editor/extensions/tagSuggestionPlugin.test.ts @@ -0,0 +1,382 @@ +/** + * Tests for the `#name` tag suggestion ProseMirror plugin (issue #767, Phase 2). + * `#name` タグサジェスト用 ProseMirror プラグインのテスト(issue #767, Phase 2)。 + * + * The plugin tracks `#`-triggered queries the same way `wikiLinkSuggestionPlugin` + * tracks `[[...]]` queries, but with the boundary rules and character class of + * `TagExtension` so behaviour stays in lock-step with the input/paste rules. + * These tests build a minimal ProseMirror schema + state and feed transactions + * directly so we can pin trigger conditions without spinning up a full Tiptap + * editor (the same pattern as `slashSuggestionPlugin.test.ts`). + * + * `wikiLinkSuggestionPlugin` の `[[...]]` 検出と同じ構造で `#name` クエリを + * 追跡する。`TagExtension` の境界ルール・文字クラスと整合させ、入力規則 / + * 貼り付け規則とブレが出ないように固定する。最小スキーマと直接トランザクション + * 適用で振る舞いを固定する(`slashSuggestionPlugin.test.ts` と同じ手法)。 + */ + +import type { Plugin } from "@tiptap/pm/state"; +import { EditorState, TextSelection } from "@tiptap/pm/state"; +import { Schema } from "@tiptap/pm/model"; +import { describe, expect, it, vi } from "vitest"; +import { + TagSuggestionPlugin, + tagSuggestionPluginKey, + type TagSuggestionState, +} from "./tagSuggestionPlugin"; + +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { + group: "block", + content: "text*", + toDOM: () => ["p", 0], + }, + code_block: { + group: "block", + content: "text*", + code: true, + defining: true, + marks: "", + toDOM: () => ["pre", ["code", 0]], + }, + text: { group: "inline" }, + }, + marks: { + code: { + // ProseMirror's spec uses `code: true` to mark the schema-spec for inline + // code; mirrors what StarterKit installs in production. Used by the + // plugin to skip activation inside inline code. + // ProseMirror で `code: true` のマークが付くスキーマ。プラグインは + // インラインコード内ではサジェストを起動しない仕様の検証に使う。 + code: true, + toDOM: () => ["code", 0], + }, + }, +}); + +/** + * Pulls the bare ProseMirror plugin out of the Tiptap extension wrapper so we + * can install it on a hand-built `EditorState`. + * Tiptap 拡張ラッパーから素の ProseMirror プラグインを取り出す。 + */ +function getPlugin(onStateChange?: (s: TagSuggestionState) => void): Plugin { + const extension = TagSuggestionPlugin.configure({ onStateChange }); + const addPlugins = extension.config.addProseMirrorPlugins; + if (!addPlugins) throw new Error("addProseMirrorPlugins missing"); + const plugins = addPlugins.call({ options: { onStateChange } } as never); + return plugins[0]; +} + +/** + * Creates a state with `text` inside a single paragraph and a collapsed cursor + * placed `caretFromEnd` characters before the end of the text (default 0). + * + * 1 段落のドキュメントを生成し、キャレットをテキスト末尾から + * `caretFromEnd` 文字戻した位置に置く(既定 0 = 末尾)。 + */ +function makeParagraphState(text: string, plugin: Plugin, caretFromEnd = 0): EditorState { + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, text ? [schema.text(text)] : []), + ]); + const state = EditorState.create({ doc, schema, plugins: [plugin] }); + const caret = 1 + text.length - caretFromEnd; + const tr = state.tr.setSelection(TextSelection.create(state.doc, caret)); + return state.apply(tr); +} + +/** + * Creates a state where `text` lives inside a `code_block` node. Used to + * verify the plugin does not fire while the cursor is in a code block. + * + * `text` を `code_block` 内に置いた状態を作る。コードブロック内では + * サジェストが起動しないことを検証する。 + */ +function makeCodeBlockState(text: string, plugin: Plugin): EditorState { + const doc = schema.node("doc", null, [ + schema.node("code_block", null, text ? [schema.text(text)] : []), + ]); + const state = EditorState.create({ doc, schema, plugins: [plugin] }); + const tr = state.tr.setSelection(TextSelection.create(state.doc, 1 + text.length)); + return state.apply(tr); +} + +/** + * Creates a state with a single paragraph whose entire content is wrapped in + * an inline `code` mark, then puts the cursor at the end. Used to verify + * inline-code suppression. + * + * 段落の全文に inline `code` マークを掛けた状態を作る。インラインコード内 + * では起動しない仕様の検証に使う。 + */ +function makeInlineCodeState(text: string, plugin: Plugin): EditorState { + const codeMark = schema.marks.code.create(); + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, text ? [schema.text(text, [codeMark])] : []), + ]); + const state = EditorState.create({ doc, schema, plugins: [plugin] }); + const tr = state.tr.setSelection(TextSelection.create(state.doc, 1 + text.length)); + return state.apply(tr); +} + +describe("tagSuggestionPlugin — initial state", () => { + it("initialises with no active suggestion", () => { + const plugin = getPlugin(); + const state = makeParagraphState("", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState).toEqual({ + active: false, + query: "", + range: null, + decorations: expect.any(Object), + }); + }); +}); + +describe("tagSuggestionPlugin — activation triggers", () => { + it("activates the moment `#` is typed at the start of a paragraph", () => { + // 段落先頭で `#` を打鍵した瞬間にサジェスト UI を出す(受け入れ条件)。 + // The popup must appear immediately after the user types `#`. + const onStateChange = vi.fn(); + const plugin = getPlugin(onStateChange); + const state = makeParagraphState("#", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + + expect(pluginState?.active).toBe(true); + expect(pluginState?.query).toBe(""); + // `#` 自体は pos 1〜2 の範囲。range は確定時に削除→挿入の範囲として使う。 + // The hash itself sits at positions 1..2; range is later used to replace + // the typed `#name` with the styled mark on confirm. + expect(pluginState?.range).toEqual({ from: 1, to: 2 }); + expect(onStateChange).toHaveBeenCalledWith(pluginState); + }); + + it("captures the query text after `#`", () => { + const plugin = getPlugin(); + const state = makeParagraphState("#tec", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + + expect(pluginState?.active).toBe(true); + expect(pluginState?.query).toBe("tec"); + expect(pluginState?.range).toEqual({ from: 1, to: 5 }); + }); + + it("activates on a `#` preceded by a non-word boundary (space)", () => { + const plugin = getPlugin(); + const state = makeParagraphState("hello #tec", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + + expect(pluginState?.active).toBe(true); + expect(pluginState?.query).toBe("tec"); + // `hello ` (6 chars) → `#` at pos 7, query ends at pos 11. + expect(pluginState?.range).toEqual({ from: 7, to: 11 }); + }); + + it("captures CJK characters in the query", () => { + const plugin = getPlugin(); + const state = makeParagraphState("#技術", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(true); + expect(pluginState?.query).toBe("技術"); + }); + + it("captures hyphens and underscores in the query", () => { + const plugin = getPlugin(); + const state = makeParagraphState("#front-end_v2", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(true); + expect(pluginState?.query).toBe("front-end_v2"); + }); +}); + +describe("tagSuggestionPlugin — non-trigger inputs", () => { + it("does not activate when `#` is embedded in a word (`abc#tec`)", () => { + // 単語境界違反は paste rule / input rule と同じ扱いにする(受け入れ条件)。 + // Word-boundary violation matches the paste/input rule contract. + const plugin = getPlugin(); + const state = makeParagraphState("abc#tec", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + }); + + it("does not activate after a `/` (URL-fragment style boundary)", () => { + const plugin = getPlugin(); + const state = makeParagraphState("/page#anchor", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + }); + + it("does not activate after a second `#` (`##heading`-like input)", () => { + // `##` の 2 つ目の `#` 位置でも、直前が `#` のため起動しない。 + // `##` keeps the menu off — second `#` is preceded by another `#`. + const plugin = getPlugin(); + const state = makeParagraphState("##heading", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + }); + + it("does not activate without any `#`", () => { + const plugin = getPlugin(); + const state = makeParagraphState("plain text", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + }); + + it("does not activate inside an inline `code` mark", () => { + // `excludes: "code"` をプラグイン側でも担保する。コード内で `#` を打っても + // サジェスト UI は出ない。 + // Plugin must mirror Tag mark's `excludes: "code"`: never activate inside + // inline code so we don't tease an action that addMark would block. + const plugin = getPlugin(); + const state = makeInlineCodeState("#tec", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + }); + + it("does not activate inside a code_block", () => { + const plugin = getPlugin(); + const state = makeCodeBlockState("#tec", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + }); + + it("does not activate when the candidate name is excluded (numeric `#1`)", () => { + // 入力規則と同じ除外(数字のみ・6/8 桁 hex)をサジェスト側でも適用する。 + // 数字オンリーのクエリではポップアップを出さない。 + // Mirror the input/paste rule exclusions (numeric-only / 6/8-char hex). + // `#1` should not raise the picker. + const plugin = getPlugin(); + const state = makeParagraphState("#1", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + }); + + it("does not activate when the candidate name is a 6-char pure hex (`#aabbcc`)", () => { + const plugin = getPlugin(); + const state = makeParagraphState("#aabbcc", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + }); + + it("does not activate when the candidate name is an 8-char pure hex (`#aabbccdd`)", () => { + const plugin = getPlugin(); + const state = makeParagraphState("#aabbccdd", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + }); + + it("activates while the user is still typing toward an excluded value (e.g. `#aabbc`)", () => { + // 5 桁時点では除外ルール対象外(除外は 6/8 桁完成時のみ)。 + // The exclusion only kicks in at 6/8 chars; intermediate states still show. + const plugin = getPlugin(); + const state = makeParagraphState("#aabbc", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(true); + expect(pluginState?.query).toBe("aabbc"); + }); +}); + +describe("tagSuggestionPlugin — deactivation", () => { + it("deactivates when the selection becomes a non-empty range", () => { + const onStateChange = vi.fn(); + const plugin = getPlugin(onStateChange); + + let state = makeParagraphState("#tec", plugin); + expect(tagSuggestionPluginKey.getState(state)?.active).toBe(true); + onStateChange.mockClear(); + + const tr = state.tr.setSelection(TextSelection.create(state.doc, 1, 5)); + state = state.apply(tr); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + expect(pluginState?.range).toBeNull(); + expect(pluginState?.query).toBe(""); + expect(onStateChange).toHaveBeenCalledWith(pluginState); + }); + + it("turns off when the user types past the trigger and the regex no longer matches", () => { + const plugin = getPlugin(); + + let state = makeParagraphState("#tec", plugin); + expect(tagSuggestionPluginKey.getState(state)?.active).toBe(true); + + // Replace the entire paragraph contents with text that no longer matches. + // 段落全体を非トリガなテキストに差し替える(pos 1〜5 が段落内 4 文字 + 末尾位置)。 + const tr = state.tr.replaceWith(1, 5, schema.text("plain")); + state = state.apply(tr); + expect(tagSuggestionPluginKey.getState(state)?.active).toBe(false); + }); + + it("turns off when a terminator (space) is typed after the query", () => { + // Esc を押さずに空白を打鍵すると、regex が末尾 `$` でマッチしなくなり + // サジェストは閉じる(その後の確定は入力規則に任せる)。 + // Typing a terminator (space) breaks the `$`-anchored regex and closes the + // popup. The input rule then takes over (Esc-then-terminator contract). + const plugin = getPlugin(); + let state = makeParagraphState("#tec", plugin); + expect(tagSuggestionPluginKey.getState(state)?.active).toBe(true); + + const tr = state.tr.insertText(" ", 5); + state = state.apply(tr); + expect(tagSuggestionPluginKey.getState(state)?.active).toBe(false); + }); + + it("explicit close meta clears the active state and notifies subscribers", () => { + const onStateChange = vi.fn(); + const plugin = getPlugin(onStateChange); + + let state = makeParagraphState("#tec", plugin); + expect(tagSuggestionPluginKey.getState(state)?.active).toBe(true); + onStateChange.mockClear(); + + const tr = state.tr.setMeta(tagSuggestionPluginKey, { close: true }); + state = state.apply(tr); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState).toEqual({ + active: false, + query: "", + range: null, + decorations: expect.any(Object), + }); + expect(onStateChange).toHaveBeenCalledWith(pluginState); + }); +}); + +describe("tagSuggestionPlugin — decorations", () => { + it("creates a single `.tag-typing` decoration over the `#name` range when active", () => { + // CSS の `.tag-typing` を生かすため、active 中は `#name` 範囲に inline + // decoration を置く(issue #767 受け入れ条件、CSS は src/index.css に既存)。 + // Decorate the live `#name` range with `.tag-typing` so the existing CSS + // colours the typing state (the class lives in src/index.css). + const plugin = getPlugin(); + const state = makeParagraphState("#tec", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(true); + + const decos = pluginState?.decorations.find(0, state.doc.content.size) ?? []; + expect(decos).toHaveLength(1); + expect(decos[0].from).toBe(1); + expect(decos[0].to).toBe(5); + }); + + it("returns an empty decoration set when inactive", () => { + const plugin = getPlugin(); + const state = makeParagraphState("plain", plugin); + const pluginState = tagSuggestionPluginKey.getState(state); + expect(pluginState?.active).toBe(false); + const decos = pluginState?.decorations.find(0, state.doc.content.size) ?? []; + expect(decos).toHaveLength(0); + }); +}); + +describe("tagSuggestionPlugin — extension wiring", () => { + it("declares the expected extension name", () => { + expect(TagSuggestionPlugin.name).toBe("tagSuggestion"); + }); + + it("exposes a default options object with no callback", () => { + const ext = TagSuggestionPlugin.configure(); + expect(ext.options.onStateChange).toBeUndefined(); + }); +}); diff --git a/src/components/editor/extensions/tagSuggestionPlugin.ts b/src/components/editor/extensions/tagSuggestionPlugin.ts new file mode 100644 index 00000000..95196a86 --- /dev/null +++ b/src/components/editor/extensions/tagSuggestionPlugin.ts @@ -0,0 +1,287 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import type { EditorView } from "@tiptap/pm/view"; +import { TAG_NAME_CHAR_CLASS } from "@zedi/shared/tagCharacterClass"; +import { isExcludedTagName } from "./TagExtension"; + +/** + * UI state for the `#name` tag suggestion picker. + * + * `#name` タグサジェスト UI の状態。 + * + * - `active`: ポップアップを表示中か / popup is visible + * - `query`: `#` 直後のクエリ文字列(先頭 `#` は含めない)/ raw query after `#` + * - `range`: `#name` 全体のドキュメント内範囲(確定時に置換に使う)/ + * document range covering `#name` (used to replace on confirm) + * - `decorations`: `.tag-typing` クラスを乗せる inline decoration セット / + * inline decoration set that paints the `.tag-typing` style + */ +export interface TagSuggestionState { + active: boolean; + query: string; + range: { from: number; to: number } | null; + decorations: DecorationSet; +} + +/** + * ProseMirror plugin key for {@link TagSuggestionPlugin}. + * `TagSuggestionPlugin` 用の ProseMirror プラグインキー。 + */ +export const tagSuggestionPluginKey = new PluginKey("tagSuggestion"); + +/** + * Options for {@link TagSuggestionPlugin}. + * `TagSuggestionPlugin` のオプション。 + */ +export interface TagSuggestionOptions { + /** + * Called whenever the plugin state changes (open / close / query update). + * The host React component drives the popover from this callback, mirroring + * how `WikiLinkSuggestionPlugin.onStateChange` feeds the WikiLink popup. + * + * 状態変化(オープン・クローズ・クエリ更新)のたびに呼ばれる。React 側で + * このコールバックを使ってポップオーバー UI を駆動する(WikiLink 同等)。 + */ + onStateChange?: (state: TagSuggestionState) => void; +} + +/** + * Regex that detects a live `#name` query immediately before the cursor. + * + * - `(?:^|[^\w/#])` — same boundary contract as `TAG_PASTE_REGEX` and + * `TAG_INPUT_REGEX` so `abc#tec`, `/page#anchor`, `##` never trigger. + * - `#([${TAG_NAME_CHAR_CLASS}]*)` — captures the (possibly empty) name body + * so the popover can show suggestions the moment `#` is typed. + * - `$` — anchored to the end of the inspected text-before-cursor slice; the + * plugin re-evaluates on every transaction so this fires only when the + * caret is sitting right after `#name`. + * + * 入力規則 / 貼り付け規則と同じ境界条件で `#name` を検出するための正規表現。 + * 「カーソル直前のテキスト」の末尾 (`$`) に `#name` がある瞬間だけマッチする。 + * 空クエリ (`#` のみ) も拾うため、`#` を打鍵した瞬間からポップアップを出せる。 + */ +const TAG_QUERY_REGEX = new RegExp(`(?:^|[^\\w/#])#([${TAG_NAME_CHAR_CLASS}]*)$`); + +/** + * Walk the resolved position to detect whether the caret sits inside an inline + * `code` mark. Tag marks are configured with `excludes: "code"`, so opening + * the popover here would offer an action the editor cannot perform. + * + * カーソル位置がインライン `code` マーク内にあるかを判定する。Tag マークは + * `excludes: "code"` のためコード内では確定不可。サジェストも出さない。 + */ +function isInsideInlineCode( + $from: import("@tiptap/pm/model").ResolvedPos, + schemaMarks: Record, +): boolean { + const codeMark = schemaMarks.code; + if (!codeMark) return false; + // `marks()` returns the marks active at the cursor. `isInSet` handles the + // empty-cursor case (no stored marks) by returning the literal mark or + // null; both mean "no code mark". + // `marks()` はカーソル位置に作用するマーク列を返す。`isInSet` は空カーソル + // 時にも安全に動き、null/値を返す。 + return Boolean(codeMark.isInSet($from.marks())); +} + +/** + * ProseMirror plugin that surfaces a `#name` autocomplete popover comparable + * to `WikiLinkSuggestionPlugin` for `[[...]]`. The host renders the actual + * UI and reacts to `onStateChange`; the plugin only owns query detection, + * the typing decoration (`.tag-typing`), and the close meta. + * + * `#name` 用の ProseMirror プラグイン。`WikiLinkSuggestionPlugin` と + * 同じ役割分担で、UI 描画は React 側に任せ、本プラグインはクエリ検出と + * `.tag-typing` 装飾、`close` メタの処理だけを担う。 + * + * 入力規則 (`TagExtension.addInputRules`) との二重マーク化を避けるため、 + * `TagExtension` 側のハンドラが `tagSuggestionPluginKey.getState(state).active` + * を見て発火を抑止する契約。Esc → 空白終端の順で打鍵された場合は本プラグインが + * 既に閉じているので入力規則経路でマーク化される(フォールバック)。 + * + * Coordinated with `TagExtension.addInputRules`: while this plugin is active + * the input rule short-circuits to avoid double-marking on confirm. After Esc + * the suggestion state is `inactive`, so a typed terminator falls through to + * the input rule path (the documented fallback). + * + * See issue #767 (Phase 2) and parent #725 (Phase 1). + */ +export const TagSuggestionPlugin = Extension.create({ + name: "tagSuggestion", + + addOptions() { + return { + onStateChange: undefined, + }; + }, + + addProseMirrorPlugins() { + const { onStateChange } = this.options; + + return [ + new Plugin({ + key: tagSuggestionPluginKey, + + state: { + init() { + return { + active: false, + query: "", + range: null, + decorations: DecorationSet.empty, + }; + }, + + apply(tr, prev, _oldState, newState) { + const meta = tr.getMeta(tagSuggestionPluginKey); + + // Handle explicit close (Esc, suggestion confirm, etc.) + // 明示的なクローズ(Esc、確定後のリセットなど)。 + if (meta?.close) { + const nextState: TagSuggestionState = { + active: false, + query: "", + range: null, + decorations: DecorationSet.empty, + }; + onStateChange?.(nextState); + return nextState; + } + + const { selection, schema } = newState; + const { $from } = selection; + + // Range selections do not represent a typing caret, so collapse to + // inactive (matches WikiLink/Slash plugin behaviour). 範囲選択中は + // タイピングではないので非アクティブに倒す(WikiLink / Slash と同じ)。 + if (!selection.empty) { + if (prev.active) { + const nextState: TagSuggestionState = { + active: false, + query: "", + range: null, + decorations: DecorationSet.empty, + }; + onStateChange?.(nextState); + return nextState; + } + return prev; + } + + // Code-block / inline-code suppression. タグマーク自体が + // `excludes: "code"` のためコード周辺ではサジェストを出さない。 + // Inside code blocks / inline code the Tag mark cannot be applied + // (`excludes: "code"`), so the popover is hidden too. + if ($from.parent.type.spec.code || isInsideInlineCode($from, schema.marks)) { + if (prev.active) { + const nextState: TagSuggestionState = { + active: false, + query: "", + range: null, + decorations: DecorationSet.empty, + }; + onStateChange?.(nextState); + return nextState; + } + return prev; + } + + //  = ProseMirror's object replacement char for non-text nodes. + // `` は ProseMirror が非テキストノードを 1 文字で置く位置合わせ用。 + const textBefore = $from.parent.textBetween(0, $from.parentOffset, null, ""); + const match = textBefore.match(TAG_QUERY_REGEX); + + if (match) { + const query = match[1]; + + // Mirror the input/paste rule's `isExcludedTagName` so we don't + // surface the popover for values the rule would silently reject + // (numeric-only, 6/8-char hex). Empty query is allowed — typing + // `#` alone still opens the menu. + // 入力規則 / 貼り付け規則の `isExcludedTagName` と同じ除外を適用し、 + // 規則側がリジェクトする値(数字のみ・6/8 桁 hex)でポップアップを + // 出さない。空クエリ(`#` のみ)は許容してメニュー表示を開始する。 + if (query.length > 0 && isExcludedTagName(query)) { + if (prev.active) { + const nextState: TagSuggestionState = { + active: false, + query: "", + range: null, + decorations: DecorationSet.empty, + }; + onStateChange?.(nextState); + return nextState; + } + return prev; + } + + // `#name` のドキュメント内範囲。境界文字(先頭 1 文字)はキャプチャに + // 含まれないので、`from` は単純に「#」位置 = `$from.pos - 1 - query.length`。 + // The boundary char (if any) is non-capturing, so `from` is the + // position of `#`, computed without inspecting `match[0].length`. + const from = $from.pos - 1 - query.length; + const to = $from.pos; + + const decorations = DecorationSet.create(newState.doc, [ + Decoration.inline(from, to, { + class: "tag-typing", + }), + ]); + + const nextState: TagSuggestionState = { + active: true, + query, + range: { from, to }, + decorations, + }; + + onStateChange?.(nextState); + return nextState; + } + + // No match — collapse to inactive if previously open. + // マッチしないので前回開いていたら閉じる。 + if (prev.active) { + const nextState: TagSuggestionState = { + active: false, + query: "", + range: null, + decorations: DecorationSet.empty, + }; + onStateChange?.(nextState); + return nextState; + } + + return prev; + }, + }, + + props: { + decorations(state) { + return this.getState(state)?.decorations ?? DecorationSet.empty; + }, + + handleKeyDown(view: EditorView, event: KeyboardEvent) { + const pluginState = tagSuggestionPluginKey.getState(view.state); + + if (!pluginState?.active) { + return false; + } + + // Close on Escape — the React popover handles arrow keys / Enter / + // Tab via its imperative ref, so we only own the lifecycle event + // that hides the menu without inserting anything. + // Esc は閉じるだけ。矢印 / Enter / Tab は React 側のハンドルで処理する。 + if (event.key === "Escape") { + view.dispatch(view.state.tr.setMeta(tagSuggestionPluginKey, { close: true })); + return true; + } + + return false; + }, + }, + }), + ]; + }, +}); diff --git a/src/hooks/usePageQueries.ts b/src/hooks/usePageQueries.ts index 5b06a09e..774228a7 100644 --- a/src/hooks/usePageQueries.ts +++ b/src/hooks/usePageQueries.ts @@ -14,7 +14,7 @@ import { StorageAdapterPageRepository } from "@/lib/pageRepository/StorageAdapte import type { IPageRepository } from "@/lib/pageRepository"; import { syncLinksWithRepo } from "@/lib/syncWikiLinks"; import { getPageListPreview } from "@/lib/contentUtils"; -import type { Page, PageSummary } from "@/types/page"; +import type { LinkType, Page, PageSummary } from "@/types/page"; // Local user ID for unauthenticated users const LOCAL_USER_ID = "local-user"; @@ -635,16 +635,29 @@ export function useRemoveLink() { // --- Ghost Link hooks --- /** - * Hook to check if a link text is referenced in ghost_links table from OTHER pages + * Hook to check if a link text is referenced in ghost_links table from OTHER + * pages. The optional `linkType` argument scopes the lookup so tag suggestion + * confirms (`"tag"`) hit the tag bucket instead of falling back to the + * default `"wiki"` lookup — without it, tag ghost references would always + * resolve to `referenced: false`. Issue #767 / PR #778 review feedback. + * + * 指定リンクテキストが他ページの ghost_links に登場するかを判定するフック。 + * `linkType` を渡すと検索バケットを切り替えられる(タグサジェスト確定経路で + * `"tag"` を渡し、タグ用ゴーストリンクを正しく拾う)。デフォルトの `"wiki"` は + * 既存呼び出しを壊さないためのフォールバック。Issue #767 / PR #778 レビュー反映。 */ export function useCheckGhostLinkReferenced() { const { getRepository } = useRepository(); const checkReferenced = useCallback( - async (linkText: string, currentPageId?: string): Promise => { + async ( + linkText: string, + currentPageId?: string, + linkType: LinkType = "wiki", + ): Promise => { try { const repo = await getRepository(); - const sources = await repo.getGhostLinkSources(linkText); + const sources = await repo.getGhostLinkSources(linkText, linkType); // Referenced if at least one OTHER page has this ghost link const otherSources = currentPageId ? sources.filter((id) => id !== currentPageId) : sources; return otherSources.length > 0; diff --git a/src/hooks/useTagCandidates.ts b/src/hooks/useTagCandidates.ts new file mode 100644 index 00000000..d67366c4 --- /dev/null +++ b/src/hooks/useTagCandidates.ts @@ -0,0 +1,136 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useRepository } from "@/hooks/usePageQueries"; +import { useWikiLinkCandidates } from "@/hooks/useWikiLinkCandidates"; + +/** + * タグサジェスト用 1 候補。`exists` はクエリ時点でスコープ内に同名ページが + * 存在するかを示す(true なら確定時の `tag` Mark に `exists: true` を付け、 + * `targetId` も解決済みの値で埋める)。 + * + * One tag suggestion candidate. `exists` reports whether the current scope + * contains a page named after the tag at the time of the query — when true + * the host applies the `tag` Mark with `exists: true` and a resolved + * `targetId`. See issue #767 (Phase 2). + */ +export interface TagSuggestionCandidate { + /** Display + insertion name (no leading `#`). 表示と挿入に使う名前。 */ + name: string; + /** 同名ページがスコープ内にあるか / a page with this name exists in scope */ + exists: boolean; + /** + * 解決済みのターゲットページ id(`exists === true` のときのみ非 null)。 + * Resolved target page id (non-null only when `exists === true`). + */ + targetId: string | null; +} + +/** + * 候補ソースを統合した結果。`candidates` は重複排除済み(大文字小文字無視)。 + * Aggregated candidate list with case-insensitive de-duplication. + */ +export interface UseTagCandidatesResult { + candidates: TagSuggestionCandidate[]; + isLoading: boolean; +} + +/** + * `useTagCandidates` の追加オプション。`enabled` を `false` にすると + * ゴーストタグの fetch を抑止し、ページタイトル候補のみで動作する + * (ポップオーバー閉時の不要な問い合わせを避ける、issue #767 レビュー反映)。 + * + * Optional flags for {@link useTagCandidates}. Pass `enabled: false` to + * skip the ghost-tag query so the hook does not hit IndexedDB while the + * popover is closed (review feedback on issue #767). + */ +export interface UseTagCandidatesOptions { + enabled?: boolean; +} + +/** + * タグサジェストの候補を集約する。Phase 2 では候補ソースを 2 系統用意する: + * + * 1. 現スコープ(個人 or 同一ノート)のページタイトル — `useWikiLinkCandidates` + * と同じ名前空間。タイトルがそのままタグ名として使える。 + * 2. 既出タグ名(ゴースト側)— 他ページで `#name` として登場している未解決の + * タグを `getGhostLinks` から `linkType === 'tag'` でフィルタして取得する。 + * 既存ページで実体化済みのタグは 1) で網羅されるため、ここでは未解決分のみ + * を補う。 + * + * 結果は大文字小文字を無視して重複排除し、ページタイトル経由のものは + * `exists: true` + `targetId` 入りで返す(確定時にそのまま `tag` Mark の属性に + * 反映できる)。`/api/tags` を新設する案は issue #767 の out-of-scope。 + * + * Aggregates tag suggestion candidates from two sources for Phase 2 (issue + * #767). 1) Page titles in the current scope (same namespace as + * `useWikiLinkCandidates`). 2) Existing tag names that show up only in + * `ghost_links` with `linkType === 'tag'` (resolved tags are already covered + * by 1)). Results are case-insensitively de-duplicated; page-title hits keep + * their resolved `targetId` so the host can populate the `tag` Mark on + * confirm without an extra round-trip. + * + * @param pageNoteId - 編集中ページの noteId(個人スコープなら `null`)。 + * Owning note id of the page being edited (`null` = personal scope). + * @param options - `enabled: false` でゴーストタグの fetch を抑止する。 + * Pass `enabled: false` to skip the ghost-tag query (e.g. while the + * popover is closed). + */ +export function useTagCandidates( + pageNoteId: string | null | undefined, + options?: UseTagCandidatesOptions, +): UseTagCandidatesResult { + const isEnabled = options?.enabled ?? true; + const { pages, isLoading: isPagesLoading } = useWikiLinkCandidates(pageNoteId); + const { getRepository, userId, isLoaded } = useRepository(); + + // ゴーストタグは別クエリで取得してキャッシュする。pageNoteId をキーに含めて + // ノート切替時に再フェッチさせる。`enabled` が false のとき (ポップオーバー + // 非表示時) はクエリ自体を停止し、IndexedDB を叩かない。Ghost タグは + // 「他ページで未解決のまま使われているタグ」を補完するための補助情報なので、 + // ロード遅延は致命的ではない。 + // Ghost tags are loaded in a separate, cached query keyed by pageNoteId so + // switching scope re-fetches. When `enabled` is false (popover closed) the + // query is suspended so no IndexedDB read fires. They're a secondary input, + // so being slightly late after enabling is acceptable (the popover still + // works with page titles alone). + const ghostTagsQuery = useQuery({ + queryKey: ["tag-candidates", "ghost", userId, pageNoteId ?? null], + queryFn: async (): Promise => { + const repo = await getRepository(); + const all = await repo.getGhostLinks(userId); + return all + .filter((g) => g.linkType === "tag") + .map((g) => g.linkText.trim()) + .filter((t): t is string => t.length > 0); + }, + enabled: isLoaded && isEnabled, + staleTime: 1000 * 30, // 30s — popover use case tolerates short staleness + }); + + return useMemo(() => { + const seen = new Set(); + const candidates: TagSuggestionCandidate[] = []; + + for (const page of pages) { + if (page.isDeleted) continue; + const trimmed = page.title.trim(); + if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + candidates.push({ name: trimmed, exists: true, targetId: page.id }); + } + + for (const name of ghostTagsQuery.data ?? []) { + const key = name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + candidates.push({ name, exists: false, targetId: null }); + } + + return { + candidates, + isLoading: isPagesLoading || ghostTagsQuery.isLoading, + }; + }, [pages, ghostTagsQuery.data, ghostTagsQuery.isLoading, isPagesLoading]); +} From 9c1a07f0f7c9d8f1a94ed63c265e746ba714df14 Mon Sep 17 00:00:00 2001 From: Akimasa Sugai <119780981+otomatty@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:14:26 +0900 Subject: [PATCH 12/22] ci: add Dependabot bun.lock sync workflow (#785) * ci: auto-sync bun.lock for Dependabot PRs Dependabot uses the npm ecosystem and cannot regenerate Bun's bun.lock, so its PRs touch only package.json. CI then fails immediately at `bun install --frozen-lockfile` because the lockfile drifts. The new workflow detects affected dependabot PRs, runs bun install in each changed directory, and pushes the regenerated lockfile back to the PR branch so CI can pass without manual intervention. * ci: cover admin and server/mcp in bun.lock sync workflow Address PR review: dependabot has historically modified admin/package.json (PR #374) even though admin is not currently listed in dependabot.yml. Add admin/ and server/mcp/ to both the `on.paths` filter and the install directory list so the workflow stays robust against past behavior and any future expansions of dependabot.yml. Both directories already ship their own bun.lock. * ci: correct loop-prevention rationale comment for bun.lock sync Address PR review (devin): the previous comment claimed the paths filter prevents the workflow from retriggering after a bun.lock-only push, but the pull_request paths filter actually evaluates the entire PR diff (base vs head), so the package.json change always remains in scope. The real safeguard is GitHub Actions' built-in rule that pushes performed with GITHUB_TOKEN do not trigger new workflow runs. Update the comment to reflect the correct mechanism and warn future maintainers about the implications of switching to a PAT or GitHub App token. --------- Co-authored-by: Claude --- .github/workflows/dependabot-bun-lock.yml | 137 ++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 .github/workflows/dependabot-bun-lock.yml diff --git a/.github/workflows/dependabot-bun-lock.yml b/.github/workflows/dependabot-bun-lock.yml new file mode 100644 index 00000000..016fd008 --- /dev/null +++ b/.github/workflows/dependabot-bun-lock.yml @@ -0,0 +1,137 @@ +# Dependabot bun.lock 同期ワークフロー +# Sync bun.lock for Dependabot pull requests. +# +# Dependabot は npm エコシステムを使うため `package.json` は更新できるが、 +# Bun のロックファイル (`bun.lock`) は更新できない。その結果、CI の +# `bun install --frozen-lockfile` がロック不一致で即座に失敗する。 +# 本ワークフローは dependabot が作成した PR を検知し、対応する +# `bun install` を実行して `bun.lock` を再生成し、PR ブランチに +# 自動コミット・プッシュする。 +# +# Dependabot updates `package.json` via the npm ecosystem but cannot regenerate +# Bun's `bun.lock`. CI then fails immediately at `bun install --frozen-lockfile` +# because the lockfile and manifest disagree. This workflow detects such PRs, +# runs `bun install` in each affected directory, and pushes the regenerated +# lockfile back to the PR branch so CI can pass. +name: Dependabot bun.lock sync + +on: + pull_request: + # bun.lock を持つ全ディレクトリの package.json を監視する。 + # dependabot.yml は現在 root / server/api / server/hocuspocus を対象にしているが、 + # 過去 (PR #374) には admin/package.json も dependabot に更新されており、 + # 将来 dependabot.yml が拡張されてもカバーできるよう全ロックを対象にする。 + # Watch every package.json that has a sibling bun.lock. Although the + # current dependabot.yml only tracks root / server/api / server/hocuspocus, + # admin/package.json was once updated by Dependabot (PR #374), and this + # list also future-proofs the workflow against expansions of dependabot.yml. + paths: + - "package.json" + - "admin/package.json" + - "server/api/package.json" + - "server/hocuspocus/package.json" + - "server/mcp/package.json" + +# bun install で書き戻すため contents: write が必要。 +# Needs `contents: write` to push the regenerated lockfile back to the PR branch. +permissions: + contents: write + +concurrency: + group: dependabot-bun-lock-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sync: + name: Sync bun.lock + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + with: + # PR ブランチに直接書き戻すため、head ref をチェックアウトする。 + # Check out the PR head ref so we can push commits back to it. + ref: ${{ github.event.pull_request.head.ref }} + # ベースブランチとの差分計算用に履歴を全部取る。 + # Fetch full history so we can diff against the PR base. + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3" + + - name: Detect changed package.json directories + id: detect + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "$BASE_REF" + changed=$(git diff --name-only "origin/${BASE_REF}...HEAD") + echo "Changed files in PR:" + echo "$changed" + + dirs=() + # bun.lock を持つ全ディレクトリ。`on.paths` と一致させる。 + # Every directory with a sibling bun.lock; keep in sync with `on.paths`. + for path in \ + "package.json" \ + "admin/package.json" \ + "server/api/package.json" \ + "server/hocuspocus/package.json" \ + "server/mcp/package.json"; do + if printf '%s\n' "$changed" | grep -Fxq "$path"; then + dir=$(dirname "$path") + dirs+=("$dir") + fi + done + + if [ ${#dirs[@]} -eq 0 ]; then + echo "No tracked package.json changed; nothing to do." + echo "dirs=" >> "$GITHUB_OUTPUT" + else + echo "dirs=${dirs[*]}" >> "$GITHUB_OUTPUT" + fi + + - name: Regenerate bun.lock + if: steps.detect.outputs.dirs != '' + run: | + set -euo pipefail + for dir in ${{ steps.detect.outputs.dirs }}; do + echo "::group::bun install ($dir)" + (cd "$dir" && bun install --no-summary) + echo "::endgroup::" + done + + - name: Commit and push bun.lock changes + if: steps.detect.outputs.dirs != '' + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + # bun.lock 以外の変更(例: node_modules メタ情報)は無視。 + # Only stage bun.lock files; ignore any other side effects. + git add -- ':(glob)**/bun.lock' bun.lock 2>/dev/null || true + + if git diff --cached --quiet; then + echo "bun.lock is already in sync; nothing to commit." + exit 0 + fi + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m "chore(deps): sync bun.lock with package.json changes" + # GITHUB_TOKEN による push は新規ワークフロー実行をトリガーしないため、 + # 無限ループは発生しない (GitHub Actions の組み込み安全機構)。 + # PAT や GitHub App トークンに切り替える場合は、コミッタを確認する等の + # 別途ループ防止ロジックが必要になる。 + # Pushes made with GITHUB_TOKEN do not trigger new workflow runs, so + # no infinite loop occurs (built-in GitHub Actions safety feature). + # If switching to a PAT or GitHub App token, add separate + # loop-prevention logic (e.g. checking the committer). + git push origin "HEAD:${HEAD_REF}" From a2aca6dc46776a7e020e2d91be765849597632b3 Mon Sep 17 00:00:00 2001 From: Akimasa Sugai <119780981+otomatty@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:40:42 +0900 Subject: [PATCH 13/22] fix: prevent autosave from racing soft delete (issue #768) (#786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): cancel pending autosave before delete to prevent untitled page resurrect (#768) `useEditorAutoSave` の unmount flush が `usePageDeletion` の論理削除と 並走し、IndexedDB 上で `isDeleted: true` を非削除メタで上書きして 「無題のページ」が `/home` に復活するレースを修正する。 - `useEditorAutoSave.cancelPendingSave` を公開し、保留中 debounce と unmount flush を抑止する。 - `usePageDeletion` の `handleDelete` / `handleBack` / `handleConfirmDelete` / `handleOpenDuplicatePage` で、 `deletePageMutation.mutate` の直前に `cancelPendingSave` を呼ぶ。 - 安全弁として `StorageAdapterPageRepository.updatePage` で論理削除済み ページに対する update を no-op にする(将来 `getPage` 仕様変更時の ガード)。 Closes #768 https://claude.ai/code/session_01WE1NLZbYwDG4qkAaSGqoP7 * fix(editor): defer cancelPendingSave to onSuccess in handleDelete (#786 Codex P2) `handleDelete` の `onError` ではユーザーがエディタに残るため、mutate 前に `cancelPendingSave` を呼ぶと削除失敗時に保留中の autosave が落ちて最近の 編集が失われる(Codex P2 指摘)。`/home` へ navigate する直前 (`onSuccess` 内)でのみキャンセルするよう変更。 他の削除ハンドラ (`handleBack` / `handleConfirmDelete` / `handleOpenDuplicatePage`) は mutate 直後に navigate して `onError` 分岐を 持たないため、issue #768 の修正どおり mutate の前にキャンセルしたまま。 加えて CodeRabbit の指摘どおり、`cancelPendingSave` のテストに `shouldBlockSave: true` で content-only debounce 経路をスケジュールする カバレッジを追加(既存の `not.toHaveBeenCalled()` だけでは vacuous だったため)。 https://claude.ai/code/session_01WE1NLZbYwDG4qkAaSGqoP7 * docs(editor): add bilingual JSDoc to usePageDeletion describing cancelPendingSave contract (#786) CodeRabbit の指摘どおり、エクスポート関数 `usePageDeletion` の JSDoc を 英語のみから日英併記に変更し、issue #768 で導入した `cancelPendingSave` の呼び出し契約(各削除パスで mutate 直前に呼ぶ/`handleDelete` だけは 失敗時の保留保存を守るため `onSuccess` 内でのみ呼ぶ)を明記。 プロジェクト規約 (CLAUDE.md) のバイリンガルコメント要件に合わせた スペック寄りのドキュメント。 https://claude.ai/code/session_01WE1NLZbYwDG4qkAaSGqoP7 --------- Co-authored-by: Claude --- .../PageEditor/useEditorAutoSave.test.ts | 144 ++++++++++++++ .../editor/PageEditor/useEditorAutoSave.ts | 21 ++ .../editor/PageEditor/usePageDeletion.test.ts | 184 +++++++++++++++++- .../editor/PageEditor/usePageDeletion.ts | 83 +++++++- .../usePageEditorAutoSaveWithMutation.ts | 3 +- .../PageEditor/usePageEditorStateAndSync.ts | 28 ++- .../StorageAdapterPageRepository.ts | 10 + 7 files changed, 456 insertions(+), 17 deletions(-) diff --git a/src/components/editor/PageEditor/useEditorAutoSave.test.ts b/src/components/editor/PageEditor/useEditorAutoSave.test.ts index add45658..738b5579 100644 --- a/src/components/editor/PageEditor/useEditorAutoSave.test.ts +++ b/src/components/editor/PageEditor/useEditorAutoSave.test.ts @@ -336,6 +336,150 @@ describe("useEditorAutoSave", () => { }); }); + describe("cancelPendingSave (issue #768)", () => { + it("デバウンス中に cancelPendingSave を呼ぶと onSave / syncWikiLinks がもう走らない / pending debounce is cleared so onSave never fires", async () => { + const syncWikiLinks = vi.fn().mockResolvedValue(undefined); + const onSave = vi.fn().mockResolvedValue(true); + const onSaveContentOnly = vi.fn().mockResolvedValue(true); + + const { result } = renderHook(() => + useEditorAutoSave({ + pageId, + debounceMs: 500, + onSave, + onSaveContentOnly, + syncWikiLinks, + }), + ); + + act(() => { + result.current.saveChanges("My Title", "{}"); + }); + + act(() => { + result.current.cancelPendingSave(); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(onSave).not.toHaveBeenCalled(); + expect(onSaveContentOnly).not.toHaveBeenCalled(); + expect(syncWikiLinks).not.toHaveBeenCalled(); + }); + + it("cancelPendingSave 後にアンマウントしても unmount flush で onSave / syncWikiLinks が呼ばれない / unmount flush is suppressed after cancelPendingSave", async () => { + const syncWikiLinks = vi.fn().mockResolvedValue(undefined); + const onSave = vi.fn().mockResolvedValue(true); + const onSaveContentOnly = vi.fn().mockResolvedValue(true); + + const { result, unmount } = renderHook(() => + useEditorAutoSave({ + pageId, + debounceMs: 500, + onSave, + onSaveContentOnly, + syncWikiLinks, + }), + ); + + act(() => { + result.current.saveChanges("My Title", "{}"); + }); + + act(() => { + result.current.cancelPendingSave(); + }); + + unmount(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(onSave).not.toHaveBeenCalled(); + expect(onSaveContentOnly).not.toHaveBeenCalled(); + expect(syncWikiLinks).not.toHaveBeenCalled(); + }); + + it("shouldBlockSave で content-only 経路が保留中でも cancelPendingSave で抑止される / content-only debounce branch is also cleared (CodeRabbit feedback)", async () => { + // CodeRabbit のレビュー指摘: 既存のキャンセル系テストは shouldBlockSave=false の + // フル保存経路しか走らせていなかったため、`onSaveContentOnly` を expect しても + // vacuous(そもそも呼ばれない)。`shouldBlockSave=true` を立てて content-only + // 経路の `pendingRef` も同じ `cancelPendingSave` で確実に消えることを検証する。 + // + // CodeRabbit: the prior cancellation tests only exercised the full-save + // branch, so asserting `onSaveContentOnly` was vacuous. This case enables + // `shouldBlockSave` to schedule the content-only debounce path and asserts + // `cancelPendingSave` clears that pendingRef too. + const syncWikiLinks = vi.fn().mockResolvedValue(undefined); + const onSave = vi.fn().mockResolvedValue(true); + const onSaveContentOnly = vi.fn().mockResolvedValue(true); + + const { result, unmount } = renderHook(() => + useEditorAutoSave({ + pageId, + debounceMs: 500, + shouldBlockSave: true, + onSave, + onSaveContentOnly, + syncWikiLinks, + }), + ); + + act(() => { + result.current.saveChanges("My Title", "{}"); + }); + + act(() => { + result.current.cancelPendingSave(); + }); + + // 単に時間を進めても content-only 経路の onSaveContentOnly は呼ばれない。 + // Advancing timers must not flush the content-only branch. + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(onSaveContentOnly).not.toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); + expect(syncWikiLinks).not.toHaveBeenCalled(); + + // unmount flush も走らない(pendingRef が null のため)。 + // The unmount flush must also stay silent (pendingRef is null). + unmount(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(onSaveContentOnly).not.toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); + expect(syncWikiLinks).not.toHaveBeenCalled(); + }); + + it("保留中の保存が無いときに cancelPendingSave を呼んでも安全(no-op) / cancelPendingSave is safe to call when nothing is pending", () => { + const onSave = vi.fn().mockResolvedValue(true); + const { result } = renderHook(() => + useEditorAutoSave({ + pageId, + debounceMs: 500, + onSave, + onSaveContentOnly: vi.fn().mockResolvedValue(true), + syncWikiLinks: vi.fn().mockResolvedValue(undefined), + }), + ); + + expect(() => { + act(() => { + result.current.cancelPendingSave(); + }); + }).not.toThrow(); + expect(onSave).not.toHaveBeenCalled(); + }); + }); + describe("保存成功時の linkedPages 無効化(3.5)", () => { it("onSaveSuccess で queryClient.invalidateQueries が linkedPages の queryKey で1回呼ばれる", async () => { const queryClient = new QueryClient(); diff --git a/src/components/editor/PageEditor/useEditorAutoSave.ts b/src/components/editor/PageEditor/useEditorAutoSave.ts index 4935ab51..b463af60 100644 --- a/src/components/editor/PageEditor/useEditorAutoSave.ts +++ b/src/components/editor/PageEditor/useEditorAutoSave.ts @@ -24,6 +24,18 @@ interface UseEditorAutoSaveOptions { interface UseEditorAutoSaveReturn { saveChanges: (title: string, content: string, forceBlockTitle?: boolean) => void; + /** + * 保留中の debounce 保存とアンマウント時 flush をキャンセルする。 + * `usePageDeletion` がページ削除を発火する直前に呼ぶことで、 + * unmount flush の `updatePage` が論理削除を上書きして「無題のページ」が + * 復活するレース (issue #768) を防ぐ。 + * + * Cancel any pending debounced save and suppress the unmount flush. + * Called by `usePageDeletion` immediately before firing + * `deletePageMutation.mutate`, so the unmount flush's `updatePage` cannot + * race the soft delete and resurrect the row (issue #768). + */ + cancelPendingSave: () => void; lastSaved: number | null; isSaving: boolean; isSyncingLinks: boolean; @@ -192,8 +204,17 @@ export function useEditorAutoSave({ ], ); + const cancelPendingSave = useCallback(() => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + pendingRef.current = null; + }, []); + return { saveChanges, + cancelPendingSave, lastSaved, isSaving, isSyncingLinks, diff --git a/src/components/editor/PageEditor/usePageDeletion.test.ts b/src/components/editor/PageEditor/usePageDeletion.test.ts index ce8c5e70..10b9601c 100644 --- a/src/components/editor/PageEditor/usePageDeletion.test.ts +++ b/src/components/editor/PageEditor/usePageDeletion.test.ts @@ -10,10 +10,11 @@ import { usePageDeletion } from "./usePageDeletion"; * (`handleOpenDuplicatePage`) used by the duplicate-title warning. */ -const { mockNavigate, mockMutate, mockToast } = vi.hoisted(() => ({ +const { mockNavigate, mockMutate, mockToast, mockCancelPendingSave } = vi.hoisted(() => ({ mockNavigate: vi.fn(), mockMutate: vi.fn(), mockToast: vi.fn(), + mockCancelPendingSave: vi.fn(), })); vi.mock("react-router-dom", () => ({ @@ -52,6 +53,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => { title: "foo", content: NON_EMPTY_CONTENT, shouldBlockSave: true, + cancelPendingSave: mockCancelPendingSave, }), ); @@ -69,6 +71,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => { title: "foo", content: EMPTY_CONTENT, shouldBlockSave: true, + cancelPendingSave: mockCancelPendingSave, }), ); @@ -89,6 +92,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => { title: "foo", content: NON_EMPTY_CONTENT, shouldBlockSave: true, + cancelPendingSave: mockCancelPendingSave, }), ); @@ -107,6 +111,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => { title: "foo", content: NON_EMPTY_CONTENT, shouldBlockSave: true, + cancelPendingSave: mockCancelPendingSave, }), ); @@ -128,6 +133,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => { title: "foo", content: NON_EMPTY_CONTENT, shouldBlockSave: true, + cancelPendingSave: mockCancelPendingSave, }), ); @@ -146,6 +152,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => { title: "foo", content: NON_EMPTY_CONTENT, shouldBlockSave: true, + cancelPendingSave: mockCancelPendingSave, }), ); @@ -159,3 +166,178 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => { expect(mockNavigate).toHaveBeenLastCalledWith("/home"); }); }); + +/** + * Issue #768: 削除前に保留中の autosave をキャンセルすることで、 + * unmount flush の `updatePage` が論理削除を上書きして「無題のページ」が + * 復活するレースを防ぐ。各削除パスで `cancelPendingSave` が + * `deletePageMutation.mutate` よりも先に呼ばれることを順序検証する。 + * + * Issue #768: cancelling the pending autosave before deletion prevents the + * unmount flush's `updatePage` from racing the soft delete and resurrecting + * an "untitled page" row. These tests assert that for each deletion path + * `cancelPendingSave` runs *before* `deletePageMutation.mutate`. + */ +describe("usePageDeletion - cancelPendingSave 順序 (issue #768)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("handleOpenDuplicatePage (空コンテンツ) は mutate より先に cancelPendingSave を呼ぶ", () => { + const callOrder: string[] = []; + mockCancelPendingSave.mockImplementation(() => { + callOrder.push("cancel"); + }); + mockMutate.mockImplementation(() => { + callOrder.push("mutate"); + }); + + const { result } = renderHook(() => + usePageDeletion({ + currentPageId: "dup-id", + title: "foo", + content: EMPTY_CONTENT, + shouldBlockSave: true, + cancelPendingSave: mockCancelPendingSave, + }), + ); + + act(() => result.current.handleOpenDuplicatePage("target-id")); + + expect(callOrder).toEqual(["cancel", "mutate"]); + }); + + it("handleBack (タイトル空・コンテンツ無し) は mutate より先に cancelPendingSave を呼ぶ", () => { + const callOrder: string[] = []; + mockCancelPendingSave.mockImplementation(() => { + callOrder.push("cancel"); + }); + mockMutate.mockImplementation(() => { + callOrder.push("mutate"); + }); + + const { result } = renderHook(() => + usePageDeletion({ + currentPageId: "page-1", + title: "", + content: EMPTY_CONTENT, + shouldBlockSave: false, + cancelPendingSave: mockCancelPendingSave, + }), + ); + + act(() => result.current.handleBack()); + + expect(callOrder).toEqual(["cancel", "mutate"]); + expect(mockToast).toHaveBeenCalledWith({ + title: "タイトルが未入力のため、ページを削除しました", + }); + }); + + it("handleConfirmDelete は mutate より先に cancelPendingSave を呼ぶ", () => { + const callOrder: string[] = []; + mockCancelPendingSave.mockImplementation(() => { + callOrder.push("cancel"); + }); + mockMutate.mockImplementation(() => { + callOrder.push("mutate"); + }); + + const { result } = renderHook(() => + usePageDeletion({ + currentPageId: "page-1", + title: "", + content: NON_EMPTY_CONTENT, + shouldBlockSave: false, + cancelPendingSave: mockCancelPendingSave, + }), + ); + + // 確認ダイアログを開いてから confirm + act(() => result.current.handleBack()); + act(() => result.current.handleConfirmDelete()); + + expect(callOrder).toEqual(["cancel", "mutate"]); + }); + + it("handleDelete (明示削除) は mutate → onSuccess の順で cancelPendingSave を呼ぶ (Codex P2: 失敗時の保留保存を保護)", () => { + // Codex P2 レビューの観点: `handleDelete` は他のハンドラと違い `onError` + // でエディタに残るため、mutate 前に cancelPendingSave すると失敗時に + // 保留中の編集が落ちる。`onSuccess` の中でだけキャンセルする実装を + // 順序検証する。 + // + // Codex P2: unlike other deletion paths, `handleDelete`'s `onError` + // keeps the user on the editor, so cancelling before the mutation + // would silently drop their queued autosave. Verify cancellation + // only happens inside `onSuccess`. + const callOrder: string[] = []; + mockCancelPendingSave.mockImplementation(() => { + callOrder.push("cancel"); + }); + mockMutate.mockImplementation((_id: string, opts?: { onSuccess?: () => void }) => { + callOrder.push("mutate"); + opts?.onSuccess?.(); + }); + + const { result } = renderHook(() => + usePageDeletion({ + currentPageId: "page-1", + title: "foo", + content: NON_EMPTY_CONTENT, + shouldBlockSave: false, + cancelPendingSave: mockCancelPendingSave, + }), + ); + + act(() => result.current.handleDelete()); + + expect(callOrder).toEqual(["mutate", "cancel"]); + expect(mockNavigate).toHaveBeenCalledWith("/home"); + }); + + it("handleDelete: 削除失敗時は cancelPendingSave を呼ばず保留保存を保持する (Codex P2)", () => { + // 失敗ブランチではエディタに残るので、保留中の autosave は触らない。 + // On the failure branch the user stays on the editor, so the pending + // autosave must remain intact. + mockMutate.mockImplementation((_id: string, opts?: { onError?: (e: Error) => void }) => { + opts?.onError?.(new Error("network down")); + }); + + const { result } = renderHook(() => + usePageDeletion({ + currentPageId: "page-1", + title: "foo", + content: NON_EMPTY_CONTENT, + shouldBlockSave: false, + cancelPendingSave: mockCancelPendingSave, + }), + ); + + act(() => result.current.handleDelete()); + + expect(mockCancelPendingSave).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockToast).toHaveBeenCalledWith({ + title: "削除に失敗しました", + variant: "destructive", + }); + }); + + it("削除に至らないキャンセルパス (handleCancelDelete) では cancelPendingSave を呼ばない", () => { + const { result } = renderHook(() => + usePageDeletion({ + currentPageId: "page-1", + title: "", + content: NON_EMPTY_CONTENT, + shouldBlockSave: false, + cancelPendingSave: mockCancelPendingSave, + }), + ); + + act(() => result.current.handleBack()); // opens dialog only + act(() => result.current.handleCancelDelete()); + + expect(mockCancelPendingSave).not.toHaveBeenCalled(); + expect(mockMutate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/editor/PageEditor/usePageDeletion.ts b/src/components/editor/PageEditor/usePageDeletion.ts index f902da6d..0b1dfe06 100644 --- a/src/components/editor/PageEditor/usePageDeletion.ts +++ b/src/components/editor/PageEditor/usePageDeletion.ts @@ -9,6 +9,18 @@ interface UsePageDeletionOptions { title: string; content: string; shouldBlockSave: boolean; + /** + * 削除発火直前に呼ぶ、保留中 autosave のキャンセル関数。 + * `useEditorAutoSave.cancelPendingSave` を渡す想定。issue #768 のレース + * (unmount flush の `updatePage` が論理削除を上書きして「無題のページ」 + * が復活する)を防ぐために必須。 + * + * Cancel any pending autosave before firing a delete. Pass + * `useEditorAutoSave.cancelPendingSave`. Required to prevent the issue + * #768 race where the unmount flush's `updatePage` overwrites the soft + * delete and resurrects an "untitled page" row. + */ + cancelPendingSave: () => void; } interface UsePageDeletionReturn { @@ -32,14 +44,36 @@ interface UsePageDeletionReturn { } /** - * Hook for page deletion logic - * Handles delete confirmation, back navigation with cleanup + * ページ削除フロー(明示削除・戻る・タイトル空・タイトル重複)の状態と + * ハンドラを束ねるフック。確認ダイアログの開閉、削除理由の保持、削除後の + * 遷移先決定、トースト表示までを管理する。 + * + * issue #768: 削除を発火する前に呼び出し側 (`useEditorAutoSave`) の + * `cancelPendingSave` を必ず実行し、保留中の autosave debounce と unmount + * flush を抑止する。これにより `updatePage` が論理削除を上書きして + * 「無題のページ」が `/home` に復活するレースを防ぐ。`handleDelete` だけは + * `onError` でユーザーがエディタに残るため、保留中の編集を落とさないよう + * `onSuccess` 内(navigate 直前)でのみキャンセルする。 + * + * Hook bundling page deletion flows (explicit delete, back navigation, + * empty-title cleanup, duplicate-title cleanup): manages confirmation + * dialog state, the deletion reason, post-delete navigation target, and + * toasts. + * + * issue #768: each deletion path invokes the caller-supplied + * `cancelPendingSave` (from `useEditorAutoSave`) to clear pending autosave + * debounces and suppress the unmount flush, preventing the race where + * `updatePage` overwrites the soft delete and an "untitled" row reappears + * on `/home`. `handleDelete` is the exception: its `onError` keeps the user + * on the editor, so cancellation is deferred to `onSuccess` (just before + * `navigate`) to avoid silently dropping queued edits on a failed delete. */ export function usePageDeletion({ currentPageId, title, content, shouldBlockSave, + cancelPendingSave, }: UsePageDeletionOptions): UsePageDeletionReturn { const navigate = useNavigate(); const { toast } = useToast(); @@ -53,8 +87,19 @@ export function usePageDeletion({ const handleDelete = useCallback(() => { if (currentPageId) { + // issue #768 + Codex P2: 他のハンドラと違い、`handleDelete` の `onError` + // ではユーザーがエディタに残るため、削除失敗時に保留中の autosave を + // 落とすと最近の編集が失われる。そのため `cancelPendingSave` は削除が + // 成功して `/home` に遷移する直前(`onSuccess` 内)でのみ呼ぶ。 + // + // issue #768 + Codex P2: unlike the other handlers, `handleDelete`'s + // `onError` keeps the user on the editor, so cancelling the pending + // autosave before the mutation would silently drop their queued edits + // on a failed delete. Cancel only inside `onSuccess`, just before + // navigating away (and the unmount flush). deletePageMutation.mutate(currentPageId, { onSuccess: () => { + cancelPendingSave(); toast({ title: "ページを削除しました", }); @@ -68,7 +113,7 @@ export function usePageDeletion({ }, }); } - }, [currentPageId, deletePageMutation, navigate, toast]); + }, [currentPageId, deletePageMutation, navigate, toast, cancelPendingSave]); const handleBack = useCallback(() => { const hasContent = isContentNotEmpty(content); @@ -94,6 +139,9 @@ export function usePageDeletion({ return; } + // issue #768: 削除発火前に保留中の autosave をキャンセル。 + // issue #768: cancel any pending autosave before firing the delete. + cancelPendingSave(); // コンテンツがない場合はそのまま削除 deletePageMutation.mutate(currentPageId); if (shouldDeleteForDuplicate) { @@ -107,10 +155,22 @@ export function usePageDeletion({ } } navigate("/home"); - }, [navigate, currentPageId, title, content, deletePageMutation, shouldBlockSave, toast]); + }, [ + navigate, + currentPageId, + title, + content, + deletePageMutation, + shouldBlockSave, + toast, + cancelPendingSave, + ]); const handleConfirmDelete = useCallback(() => { if (currentPageId) { + // issue #768: 削除発火前に保留中の autosave をキャンセル。 + // issue #768: cancel any pending autosave before firing the delete. + cancelPendingSave(); deletePageMutation.mutate(currentPageId); toast({ title: `${deleteReason}を削除しました`, @@ -120,7 +180,15 @@ export function usePageDeletion({ navigate(pendingNavTarget); // 次回に備えてデフォルトに戻す / reset to default for next invocation setPendingNavTarget("/home"); - }, [currentPageId, deletePageMutation, deleteReason, navigate, pendingNavTarget, toast]); + }, [ + currentPageId, + deletePageMutation, + deleteReason, + navigate, + pendingNavTarget, + toast, + cancelPendingSave, + ]); const handleCancelDelete = useCallback(() => { setDeleteConfirmOpen(false); @@ -149,6 +217,9 @@ export function usePageDeletion({ return; } + // issue #768: 削除発火前に保留中の autosave をキャンセル。 + // issue #768: cancel any pending autosave before firing the delete. + cancelPendingSave(); // コンテンツがない場合はそのまま削除して遷移 // Otherwise delete immediately and navigate to the existing page. deletePageMutation.mutate(currentPageId); @@ -157,7 +228,7 @@ export function usePageDeletion({ }); navigate(targetPath); }, - [currentPageId, content, deletePageMutation, navigate, toast], + [currentPageId, content, deletePageMutation, navigate, toast, cancelPendingSave], ); return { diff --git a/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts b/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts index a71cc4c5..3a07bd9c 100644 --- a/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts +++ b/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts @@ -31,6 +31,7 @@ export function usePageEditorAutoSaveWithMutation({ const { saveChanges, + cancelPendingSave, lastSaved: autoSaveLastSaved, isSyncingLinks, } = useEditorAutoSave({ @@ -67,5 +68,5 @@ export function usePageEditorAutoSaveWithMutation({ }, }); - return { saveChanges, lastSaved: autoSaveLastSaved, isSyncingLinks }; + return { saveChanges, cancelPendingSave, lastSaved: autoSaveLastSaved, isSyncingLinks }; } diff --git a/src/components/editor/PageEditor/usePageEditorStateAndSync.ts b/src/components/editor/PageEditor/usePageEditorStateAndSync.ts index 6c829b95..461bd298 100644 --- a/src/components/editor/PageEditor/usePageEditorStateAndSync.ts +++ b/src/components/editor/PageEditor/usePageEditorStateAndSync.ts @@ -33,12 +33,14 @@ function usePageEditorDeletionAndNav( content: string, sourceUrl: string, shouldBlockSave: boolean, + cancelPendingSave: () => void, ) { const deletion = usePageDeletion({ currentPageId, title, content, shouldBlockSave, + cancelPendingSave, }); const { handleExportMarkdown, handleCopyMarkdown } = useMarkdownExport(title, content, sourceUrl); usePageEditorKeyboard({ onBack: deletion.handleBack }); @@ -128,6 +130,17 @@ export function usePageEditorStateAndSync() { const { wikiContentForCollab, setWikiContentForCollab, resetWiki, onWikiContentApplied } = usePageEditorWikiCollab(resetWikiBase, collaboration); + const { + saveChanges, + cancelPendingSave, + lastSaved: autoSaveLastSaved, + isSyncingLinks, + } = usePageEditorAutoSaveWithMutation({ + currentPageId, + shouldBlockSave, + updateLastSaved, + }); + const { deleteConfirmOpen, deleteReason, @@ -139,17 +152,14 @@ export function usePageEditorStateAndSync() { handleOpenDuplicatePage, handleExportMarkdown, handleCopyMarkdown, - } = usePageEditorDeletionAndNav(currentPageId, title, content, sourceUrl, shouldBlockSave); - - const { - saveChanges, - lastSaved: autoSaveLastSaved, - isSyncingLinks, - } = usePageEditorAutoSaveWithMutation({ + } = usePageEditorDeletionAndNav( currentPageId, + title, + content, + sourceUrl, shouldBlockSave, - updateLastSaved, - }); + cancelPendingSave, + ); const { displayLastSaved, pendingInitialContent, setPendingInitialContent } = useDisplayLastSavedAndPending(autoSaveLastSaved, lastSaved); diff --git a/src/lib/pageRepository/StorageAdapterPageRepository.ts b/src/lib/pageRepository/StorageAdapterPageRepository.ts index c533e7f7..ce37edac 100644 --- a/src/lib/pageRepository/StorageAdapterPageRepository.ts +++ b/src/lib/pageRepository/StorageAdapterPageRepository.ts @@ -254,6 +254,16 @@ export class StorageAdapterPageRepository { ): Promise { const existing = await this.adapter.getPage(pageId); if (!existing) return; + // issue #768 safety net: 論理削除済みページに対する update は no-op。 + // 現在の `adapter.getPage` は `isDeleted: true` を null として返すので + // 上の早期 return で実質カバーされているが、将来 `getPage` が tombstone も + // 返す仕様に変わったときに削除済み行を復活させないための明示ガード。 + // + // issue #768 safety net: skip updates targeting a soft-deleted page. + // Today `adapter.getPage` already filters out `isDeleted: true` rows so + // the early return above covers it, but this explicit guard keeps the + // contract intact if `getPage` ever starts surfacing tombstones. + if (existing.isDeleted) return; const now = Date.now(); const meta: PageMetadata = { ...existing, From e6afd2ef8dbc3587de7f7d32783ae120a9da91bf Mon Sep 17 00:00:00 2001 From: Akimasa Sugai <119780981+otomatty@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:04:10 +0900 Subject: [PATCH 14/22] feat: Add i18n support to admin panel with Japanese and English translations (#788) * feat(admin): introduce react-i18next for ja/en localisation (#780) - Add `react-i18next` / `i18next` / `i18next-browser-languagedetector` to the admin workspace and wire init via `admin/src/i18n/`. - Share the `zedi-i18next-lng` localStorage key with the main app so language preference stays consistent across surfaces. - Replace hardcoded Japanese strings in Layout, Login, ConfirmActionDialog, ActivityLog, and the users / audit-logs / wiki-health / ai-models pages with `useTranslation()` lookups; ja resources mirror the previous copy and en resources provide an English translation. - Locale JSON is namespaced per domain (common, nav, auth, users, audit, wikiHealth, activityLog, aiModels) following the main app's layout. - Force JA in the admin vitest setup so existing assertions keep working. * chore(admin): clean stub jsdoc blocks injected by autofix Pre-commit lint-fix added empty `/** */` blocks above every const/state declaration inside `AiModels` because the exported function lacked a top-level description. Add a proper bilingual JSDoc on the export so the auto-fixer no longer triggers, and remove the noise it produced. --------- Co-authored-by: Claude --- admin/package.json | 3 + admin/src/components/ConfirmActionDialog.tsx | 11 +- admin/src/i18n/index.ts | 72 +++++++++++ admin/src/i18n/locales/en/activityLog.json | 29 +++++ admin/src/i18n/locales/en/aiModels.json | 30 +++++ admin/src/i18n/locales/en/audit.json | 20 +++ admin/src/i18n/locales/en/auth.json | 7 ++ admin/src/i18n/locales/en/common.json | 19 +++ admin/src/i18n/locales/en/nav.json | 12 ++ admin/src/i18n/locales/en/users.json | 63 ++++++++++ admin/src/i18n/locales/en/wikiHealth.json | 27 ++++ admin/src/i18n/locales/ja/activityLog.json | 29 +++++ admin/src/i18n/locales/ja/aiModels.json | 30 +++++ admin/src/i18n/locales/ja/audit.json | 20 +++ admin/src/i18n/locales/ja/auth.json | 7 ++ admin/src/i18n/locales/ja/common.json | 19 +++ admin/src/i18n/locales/ja/nav.json | 12 ++ admin/src/i18n/locales/ja/users.json | 63 ++++++++++ admin/src/i18n/locales/ja/wikiHealth.json | 27 ++++ admin/src/main.tsx | 1 + admin/src/pages/ActivityLog.tsx | 56 +++------ admin/src/pages/Layout.tsx | 23 ++-- admin/src/pages/Login.tsx | 17 +-- admin/src/pages/ai-models/AiModelCard.tsx | 10 +- admin/src/pages/ai-models/AiModelRow.tsx | 9 +- admin/src/pages/ai-models/AiModelsContent.tsx | 37 ++++-- .../src/pages/ai-models/SyncPreviewModal.tsx | 34 +++-- admin/src/pages/ai-models/index.tsx | 10 +- .../src/pages/audit-logs/AuditLogsContent.tsx | 50 ++++---- admin/src/pages/users/SuspendDialog.tsx | 14 +-- admin/src/pages/users/UserCard.tsx | 25 ++-- admin/src/pages/users/UsersContent.tsx | 118 +++++++++++------- .../pages/wiki-health/WikiHealthContent.tsx | 92 +++++++------- admin/src/test/setup.ts | 8 ++ bun.lock | 3 + 35 files changed, 787 insertions(+), 220 deletions(-) create mode 100644 admin/src/i18n/index.ts create mode 100644 admin/src/i18n/locales/en/activityLog.json create mode 100644 admin/src/i18n/locales/en/aiModels.json create mode 100644 admin/src/i18n/locales/en/audit.json create mode 100644 admin/src/i18n/locales/en/auth.json create mode 100644 admin/src/i18n/locales/en/common.json create mode 100644 admin/src/i18n/locales/en/nav.json create mode 100644 admin/src/i18n/locales/en/users.json create mode 100644 admin/src/i18n/locales/en/wikiHealth.json create mode 100644 admin/src/i18n/locales/ja/activityLog.json create mode 100644 admin/src/i18n/locales/ja/aiModels.json create mode 100644 admin/src/i18n/locales/ja/audit.json create mode 100644 admin/src/i18n/locales/ja/auth.json create mode 100644 admin/src/i18n/locales/ja/common.json create mode 100644 admin/src/i18n/locales/ja/nav.json create mode 100644 admin/src/i18n/locales/ja/users.json create mode 100644 admin/src/i18n/locales/ja/wikiHealth.json diff --git a/admin/package.json b/admin/package.json index 160cbb39..6e6fed36 100644 --- a/admin/package.json +++ b/admin/package.json @@ -12,9 +12,12 @@ }, "dependencies": { "@zedi/ui": "workspace:*", + "i18next": "^26.0.1", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.7.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-i18next": "^17.0.1", "react-router-dom": "^7.13.2" }, "devDependencies": { diff --git a/admin/src/components/ConfirmActionDialog.tsx b/admin/src/components/ConfirmActionDialog.tsx index abbbd20b..758173b0 100644 --- a/admin/src/components/ConfirmActionDialog.tsx +++ b/admin/src/components/ConfirmActionDialog.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { AlertDialog, AlertDialogContent, @@ -71,7 +72,7 @@ export function ConfirmActionDialog({ onOpenChange, title, description, - confirmLabel = "確認", + confirmLabel, destructive = false, loading = false, confirmPhrase, @@ -79,6 +80,8 @@ export function ConfirmActionDialog({ onConfirm, children, }: ConfirmActionDialogProps) { + const { t } = useTranslation(); + const resolvedConfirmLabel = confirmLabel ?? t("common.confirm"); const [phraseInput, setPhraseInput] = useState(""); // ダイアログ閉じ時に確認フレーズ入力をリセット / Reset phrase input when dialog closes @@ -116,7 +119,7 @@ export function ConfirmActionDialog({
- キャンセル + {t("common.cancel")} - {loading ? "処理中..." : confirmLabel} + {loading ? t("common.processing") : resolvedConfirmLabel} diff --git a/admin/src/i18n/index.ts b/admin/src/i18n/index.ts new file mode 100644 index 00000000..127bb8c1 --- /dev/null +++ b/admin/src/i18n/index.ts @@ -0,0 +1,72 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; + +// 翻訳をドメインごとに分割(1ファイルあたりのコード量を抑える) +// Translations are split per domain to keep individual files small. +import jaCommon from "./locales/ja/common.json"; +import jaNav from "./locales/ja/nav.json"; +import jaAuth from "./locales/ja/auth.json"; +import jaUsers from "./locales/ja/users.json"; +import jaAudit from "./locales/ja/audit.json"; +import jaWikiHealth from "./locales/ja/wikiHealth.json"; +import jaActivityLog from "./locales/ja/activityLog.json"; +import jaAiModels from "./locales/ja/aiModels.json"; +import enCommon from "./locales/en/common.json"; +import enNav from "./locales/en/nav.json"; +import enAuth from "./locales/en/auth.json"; +import enUsers from "./locales/en/users.json"; +import enAudit from "./locales/en/audit.json"; +import enWikiHealth from "./locales/en/wikiHealth.json"; +import enActivityLog from "./locales/en/activityLog.json"; +import enAiModels from "./locales/en/aiModels.json"; + +const ja = { + common: jaCommon, + nav: jaNav, + auth: jaAuth, + users: jaUsers, + audit: jaAudit, + wikiHealth: jaWikiHealth, + activityLog: jaActivityLog, + aiModels: jaAiModels, +}; + +const en = { + common: enCommon, + nav: enNav, + auth: enAuth, + users: enUsers, + audit: enAudit, + wikiHealth: enWikiHealth, + activityLog: enActivityLog, + aiModels: enAiModels, +}; + +/** + * 管理画面用 i18n インスタンス。 + * 本体アプリと同じ localStorage キー(`zedi-i18next-lng`)を共有して、 + * 言語設定が両画面間で一貫するようにする。 + * + * Admin i18n instance. Shares the same localStorage key as the main app + * (`zedi-i18next-lng`) so language preference stays consistent across surfaces. + */ +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + ja: { translation: ja }, + en: { translation: en }, + }, + fallbackLng: "ja", + interpolation: { + escapeValue: false, + }, + detection: { + order: ["localStorage", "navigator"], + lookupLocalStorage: "zedi-i18next-lng", + }, + }); + +export default i18n; diff --git a/admin/src/i18n/locales/en/activityLog.json b/admin/src/i18n/locales/en/activityLog.json new file mode 100644 index 00000000..0e799fcc --- /dev/null +++ b/admin/src/i18n/locales/en/activityLog.json @@ -0,0 +1,29 @@ +{ + "title": "Activity Log", + "filters": { + "kind": "Kind", + "actor": "Actor" + }, + "columns": { + "kind": "Kind", + "actor": "Actor", + "detail": "Detail", + "relatedPages": "Related pages", + "createdAt": "Recorded at" + }, + "empty": "No activity logs yet.", + "showing": "Showing {{shown}} of {{total}}", + "kinds": { + "clip_ingest": "Clip ingest", + "chat_promote": "Chat promote", + "lint_run": "Lint run", + "wiki_generate": "Wiki generate", + "index_build": "Index build", + "wiki_schema_update": "Schema update" + }, + "actors": { + "user": "User", + "ai": "AI", + "system": "System" + } +} diff --git a/admin/src/i18n/locales/en/aiModels.json b/admin/src/i18n/locales/en/aiModels.json new file mode 100644 index 00000000..a0ab3a71 --- /dev/null +++ b/admin/src/i18n/locales/en/aiModels.json @@ -0,0 +1,30 @@ +{ + "title": "AI Model Management", + "syncing": "Syncing...", + "syncWithProvider": "Sync with provider", + "syncResult": "Sync result:", + "syncStat": "Added {{added}} / Deactivated {{deactivated}}", + "columns": { + "reorder": "Reorder", + "provider": "Provider", + "modelId": "Model ID", + "displayName": "Display name", + "tier": "Tier", + "active": "Active", + "sortOrder": "Order" + }, + "summary": "{{total}} models (active: {{active}})", + "dragHint": "Drag to reorder", + "displayNameAriaLabel": "Display name for {{modelId}}", + "tierAriaLabel": "Tier for {{name}}", + "preview": { + "title": "Sync preview", + "description": "New models will be added, and existing models no longer in scope will be deactivated. Existing models' display names and pricing are not overwritten. Sonnet-family models are added as inactive.", + "noChanges": "No changes", + "addLabel": "Add: {{name}}", + "deactivateLabel": "Deactivate: {{name}}", + "inactiveTag": "(inactive)", + "errorWarning": "Some providers reported errors. Providers with errors will not be synced.", + "confirm": "Run sync (Add {{added}} / Deactivate {{deactivated}})" + } +} diff --git a/admin/src/i18n/locales/en/audit.json b/admin/src/i18n/locales/en/audit.json new file mode 100644 index 00000000..3a6c9791 --- /dev/null +++ b/admin/src/i18n/locales/en/audit.json @@ -0,0 +1,20 @@ +{ + "title": "Audit Logs", + "filters": { + "action": "Filter by action", + "from": "From", + "to": "To" + }, + "columns": { + "createdAt": "Timestamp", + "actor": "Actor", + "action": "Action", + "target": "Target", + "diff": "Changes", + "ipAddress": "IP" + }, + "empty": "No audit logs.", + "actions": { + "user.role.update": "User: Role change" + } +} diff --git a/admin/src/i18n/locales/en/auth.json b/admin/src/i18n/locales/en/auth.json new file mode 100644 index 00000000..69672076 --- /dev/null +++ b/admin/src/i18n/locales/en/auth.json @@ -0,0 +1,7 @@ +{ + "title": "Zedi Admin", + "description": "To log in as an administrator, please sign in to the main app first.", + "signInButton": "Sign in to continue", + "afterSignIn": "After signing in, return to this page.", + "guardLoading": "Loading..." +} diff --git a/admin/src/i18n/locales/en/common.json b/admin/src/i18n/locales/en/common.json new file mode 100644 index 00000000..72b4c17f --- /dev/null +++ b/admin/src/i18n/locales/en/common.json @@ -0,0 +1,19 @@ +{ + "cancel": "Cancel", + "confirm": "Confirm", + "loading": "Loading...", + "saving": "Saving...", + "processing": "Processing...", + "previous": "Previous", + "next": "Next", + "all": "All", + "save": "Save", + "delete": "Delete", + "reload": "Reload", + "running": "Running...", + "page": "Page {{page}} / {{count}}", + "showingRange": "Showing {{rangeStart}}-{{rangeEnd}} of {{total}}", + "showingZero": "Showing 0 of {{total}}", + "totalCount": "Total: {{count}}", + "confirmPhraseDefault": "Type \"{{phrase}}\" to confirm" +} diff --git a/admin/src/i18n/locales/en/nav.json b/admin/src/i18n/locales/en/nav.json new file mode 100644 index 00000000..2c6fdaef --- /dev/null +++ b/admin/src/i18n/locales/en/nav.json @@ -0,0 +1,12 @@ +{ + "adminPanelTitle": "Zedi Admin", + "adminShortTitle": "Admin", + "menu": "Menu", + "items": { + "aiModels": "AI Models", + "users": "Users", + "auditLogs": "Audit Logs", + "wikiHealth": "Wiki Health", + "activityLog": "Activity Log" + } +} diff --git a/admin/src/i18n/locales/en/users.json b/admin/src/i18n/locales/en/users.json new file mode 100644 index 00000000..41010810 --- /dev/null +++ b/admin/src/i18n/locales/en/users.json @@ -0,0 +1,63 @@ +{ + "title": "User Management", + "statusFilterAriaLabel": "Status filter", + "searchEmailPlaceholder": "Search by email", + "searchEmailAriaLabel": "Search by email", + "columns": { + "email": "Email", + "name": "Name", + "status": "Status", + "role": "Role", + "pageCount": "Pages", + "createdAt": "Created", + "actions": "Actions" + }, + "actions": { + "restore": "Restore", + "suspend": "Suspend", + "delete": "Delete" + }, + "states": { + "saving": "Saving...", + "deleted": "Deleted" + }, + "card": { + "reasonPrefix": "Reason: {{reason}}", + "pageCount": "Pages: {{count}}" + }, + "row": { + "roleAriaLabel": "Role for {{email}}", + "suspendedReasonShort": "({{reason}})" + }, + "roleChange": { + "title": "Change role", + "description": "Change role of {{name}} from \"{{from}}\" to \"{{to}}\"?", + "confirm": "Change" + }, + "unsuspend": { + "title": "Lift suspension", + "description": "Lift the suspension and restore {{name}}'s account?", + "confirm": "Restore" + }, + "deleteUser": { + "title": "Delete user", + "description": "Delete {{name}}. Personal information will be anonymized, and sessions and OAuth links will be removed. This action cannot be undone.", + "confirm": "Delete" + }, + "impact": { + "title": "Impact:", + "notes": "Owned notes: {{count}}", + "sessions": "Active sessions: {{count}}", + "subscription": "Subscription: {{value}}", + "subscriptionActive": "Active", + "subscriptionNone": "None", + "lastAiUsage": "Last AI usage: {{date}}" + }, + "suspendDialog": { + "title": "Suspend user", + "description": "Suspend {{name}}. Suspended users lose access to all APIs and existing sessions are invalidated.", + "confirm": "Suspend", + "reasonLabel": "Reason (optional)", + "reasonPlaceholder": "Enter the reason for suspension" + } +} diff --git a/admin/src/i18n/locales/en/wikiHealth.json b/admin/src/i18n/locales/en/wikiHealth.json new file mode 100644 index 00000000..c79f2f22 --- /dev/null +++ b/admin/src/i18n/locales/en/wikiHealth.json @@ -0,0 +1,27 @@ +{ + "title": "Wiki Health Dashboard", + "runLint": "Run lint", + "filterRule": "Filter by rule", + "emptyAll": "No lint findings. Click \"Run lint\" to start.", + "emptyFiltered": "No matching findings.", + "columns": { + "rule": "Rule", + "severity": "Severity", + "detail": "Detail", + "createdAt": "Detected at", + "actions": "Actions" + }, + "pagesRelated": "{{count}} pages related", + "resolve": "Resolve", + "rules": { + "orphan": "Orphan", + "ghost_many": "Ghost Excess", + "title_similar": "Title Similar", + "conflict": "Conflict", + "broken_link": "Broken Link", + "stale": "Stale" + }, + "detail": { + "linkText": "\"{{linkText}}\" ({{count}})" + } +} diff --git a/admin/src/i18n/locales/ja/activityLog.json b/admin/src/i18n/locales/ja/activityLog.json new file mode 100644 index 00000000..d68dac1f --- /dev/null +++ b/admin/src/i18n/locales/ja/activityLog.json @@ -0,0 +1,29 @@ +{ + "title": "活動ログ", + "filters": { + "kind": "種別 / Kind", + "actor": "起点 / Actor" + }, + "columns": { + "kind": "種別", + "actor": "起点", + "detail": "詳細", + "relatedPages": "関連ページ", + "createdAt": "記録日時" + }, + "empty": "活動ログはまだありません。", + "showing": "表示 {{shown}} / 合計 {{total}} 件", + "kinds": { + "clip_ingest": "クリップ取り込み / Clip ingest", + "chat_promote": "Chat → Wiki 昇格 / Chat promote", + "lint_run": "Lint 実行 / Lint run", + "wiki_generate": "Wiki 生成 / Wiki generate", + "index_build": "Index 構築 / Index build", + "wiki_schema_update": "スキーマ更新 / Schema update" + }, + "actors": { + "user": "ユーザー / User", + "ai": "AI", + "system": "システム / System" + } +} diff --git a/admin/src/i18n/locales/ja/aiModels.json b/admin/src/i18n/locales/ja/aiModels.json new file mode 100644 index 00000000..5a097b9e --- /dev/null +++ b/admin/src/i18n/locales/ja/aiModels.json @@ -0,0 +1,30 @@ +{ + "title": "AI モデル管理", + "syncing": "同期中...", + "syncWithProvider": "プロバイダーと同期", + "syncResult": "同期結果:", + "syncStat": "追加 {{added}} / 無効化 {{deactivated}}", + "columns": { + "reorder": "並び替え", + "provider": "プロバイダー", + "modelId": "モデルID", + "displayName": "表示名", + "tier": "ティア", + "active": "有効", + "sortOrder": "並び順" + }, + "summary": "{{total}} 件(有効: {{active}})", + "dragHint": "ドラッグで並び替え", + "displayNameAriaLabel": "{{modelId}} の表示名", + "tierAriaLabel": "{{name}} のティア", + "preview": { + "title": "同期プレビュー", + "description": "新規モデルは追加され、同期対象から外れた既存モデルは非アクティブ化されます。 既存モデルの表示名や料金は上書きされません。Sonnet 系は非アクティブで追加されます。", + "noChanges": "変更なし", + "addLabel": "追加: {{name}}", + "deactivateLabel": "無効化: {{name}}", + "inactiveTag": "(非アクティブ)", + "errorWarning": "一部プロバイダーでエラーが発生しています。エラーのあるプロバイダーは同期されません。", + "confirm": "同期実行(追加 {{added}} / 無効化 {{deactivated}})" + } +} diff --git a/admin/src/i18n/locales/ja/audit.json b/admin/src/i18n/locales/ja/audit.json new file mode 100644 index 00000000..d77a7cb9 --- /dev/null +++ b/admin/src/i18n/locales/ja/audit.json @@ -0,0 +1,20 @@ +{ + "title": "監査ログ", + "filters": { + "action": "アクションで絞り込み", + "from": "期間(開始)", + "to": "期間(終了)" + }, + "columns": { + "createdAt": "日時", + "actor": "操作者", + "action": "アクション", + "target": "対象", + "diff": "変更内容", + "ipAddress": "IP" + }, + "empty": "監査ログはありません。", + "actions": { + "user.role.update": "ユーザー: ロール変更" + } +} diff --git a/admin/src/i18n/locales/ja/auth.json b/admin/src/i18n/locales/ja/auth.json new file mode 100644 index 00000000..52e26ad4 --- /dev/null +++ b/admin/src/i18n/locales/ja/auth.json @@ -0,0 +1,7 @@ +{ + "title": "Zedi 管理画面", + "description": "管理者としてログインするには、まずメインアプリでサインインしてください。", + "signInButton": "サインインして続ける", + "afterSignIn": "サインイン後、このページに戻ってきてください。", + "guardLoading": "Loading..." +} diff --git a/admin/src/i18n/locales/ja/common.json b/admin/src/i18n/locales/ja/common.json new file mode 100644 index 00000000..10fe867c --- /dev/null +++ b/admin/src/i18n/locales/ja/common.json @@ -0,0 +1,19 @@ +{ + "cancel": "キャンセル", + "confirm": "確認", + "loading": "読み込み中...", + "saving": "保存中...", + "processing": "処理中...", + "previous": "前へ", + "next": "次へ", + "all": "すべて", + "save": "保存", + "delete": "削除", + "reload": "再読み込み", + "running": "実行中...", + "page": "{{page}} / {{count}} ページ", + "showingRange": "{{rangeStart}}-{{rangeEnd}} 件を表示 / 合計 {{total}} 件", + "showingZero": "0 件を表示 / 合計 {{total}} 件", + "totalCount": "合計 {{count}} 件", + "confirmPhraseDefault": "確認のため「{{phrase}}」を入力してください / Type \"{{phrase}}\" to confirm" +} diff --git a/admin/src/i18n/locales/ja/nav.json b/admin/src/i18n/locales/ja/nav.json new file mode 100644 index 00000000..a4eb8248 --- /dev/null +++ b/admin/src/i18n/locales/ja/nav.json @@ -0,0 +1,12 @@ +{ + "adminPanelTitle": "Zedi 管理画面", + "adminShortTitle": "管理画面", + "menu": "メニュー", + "items": { + "aiModels": "AI モデル", + "users": "ユーザー管理", + "auditLogs": "監査ログ", + "wikiHealth": "Wiki Health", + "activityLog": "活動ログ" + } +} diff --git a/admin/src/i18n/locales/ja/users.json b/admin/src/i18n/locales/ja/users.json new file mode 100644 index 00000000..6ba33ec0 --- /dev/null +++ b/admin/src/i18n/locales/ja/users.json @@ -0,0 +1,63 @@ +{ + "title": "ユーザー管理", + "statusFilterAriaLabel": "ステータスフィルタ", + "searchEmailPlaceholder": "メールで検索", + "searchEmailAriaLabel": "メールで検索", + "columns": { + "email": "メール", + "name": "名前", + "status": "ステータス", + "role": "ロール", + "pageCount": "ページ数", + "createdAt": "作成日", + "actions": "操作" + }, + "actions": { + "restore": "復活", + "suspend": "サスペンド", + "delete": "削除" + }, + "states": { + "saving": "保存中...", + "deleted": "削除済み" + }, + "card": { + "reasonPrefix": "理由: {{reason}}", + "pageCount": "ページ数: {{count}}" + }, + "row": { + "roleAriaLabel": "{{email}} のロール", + "suspendedReasonShort": "({{reason}})" + }, + "roleChange": { + "title": "ロールを変更", + "description": "{{name}} のロールを「{{from}}」から「{{to}}」に変更しますか?", + "confirm": "変更する" + }, + "unsuspend": { + "title": "サスペンドを解除", + "description": "{{name}} のサスペンドを解除し、アカウントを復活させますか?", + "confirm": "復活させる" + }, + "deleteUser": { + "title": "ユーザーを削除", + "description": "{{name}} を削除します。個人情報は匿名化され、セッションと OAuth 連携は削除されます。この操作は元に戻せません。", + "confirm": "削除する" + }, + "impact": { + "title": "影響範囲:", + "notes": "所有ノート: {{count}} 件", + "sessions": "アクティブセッション: {{count}} 件", + "subscription": "サブスクリプション: {{value}}", + "subscriptionActive": "あり (active)", + "subscriptionNone": "なし", + "lastAiUsage": "最後の AI 使用: {{date}}" + }, + "suspendDialog": { + "title": "ユーザーをサスペンド", + "description": "{{name}} をサスペンドします。サスペンドされたユーザーはすべての API にアクセスできなくなり、既存セッションも無効化されます。", + "confirm": "サスペンド", + "reasonLabel": "理由(任意)", + "reasonPlaceholder": "サスペンドの理由を入力してください" + } +} diff --git a/admin/src/i18n/locales/ja/wikiHealth.json b/admin/src/i18n/locales/ja/wikiHealth.json new file mode 100644 index 00000000..22fadd33 --- /dev/null +++ b/admin/src/i18n/locales/ja/wikiHealth.json @@ -0,0 +1,27 @@ +{ + "title": "Wiki Health ダッシュボード", + "runLint": "Lint 実行", + "filterRule": "ルールで絞り込み", + "emptyAll": "Lint findings はありません。「Lint 実行」をクリックしてください。", + "emptyFiltered": "該当する findings はありません。", + "columns": { + "rule": "ルール", + "severity": "重要度", + "detail": "詳細", + "createdAt": "検出日時", + "actions": "操作" + }, + "pagesRelated": "{{count}} ページ関連", + "resolve": "解決", + "rules": { + "orphan": "孤立ページ / Orphan", + "ghost_many": "Ghost Link 過多 / Ghost Excess", + "title_similar": "タイトル類似 / Title Similar", + "conflict": "矛盾 / Conflict", + "broken_link": "リンク切れ / Broken Link", + "stale": "古い情報 / Stale" + }, + "detail": { + "linkText": "「{{linkText}}」({{count}} 件)" + } +} diff --git a/admin/src/main.tsx b/admin/src/main.tsx index e9e67814..0315b359 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; +import "./i18n"; import "./index.css"; const rootEl = document.getElementById("root"); diff --git a/admin/src/pages/ActivityLog.tsx b/admin/src/pages/ActivityLog.tsx index e0da56fc..9a670681 100644 --- a/admin/src/pages/ActivityLog.tsx +++ b/admin/src/pages/ActivityLog.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Badge, Button, @@ -22,24 +23,6 @@ import { } from "@/api/activity"; import { formatDate } from "@/lib/dateUtils"; -/** - * ルール/起点のラベルマップ。 - * Labels for activity kind and actor. - */ -const KIND_LABELS: Record = { - clip_ingest: "クリップ取り込み / Clip ingest", - chat_promote: "Chat → Wiki 昇格 / Chat promote", - lint_run: "Lint 実行 / Lint run", - wiki_generate: "Wiki 生成 / Wiki generate", - index_build: "Index 構築 / Index build", - wiki_schema_update: "スキーマ更新 / Schema update", -}; -const ACTOR_LABELS: Record = { - user: "ユーザー / User", - ai: "AI", - system: "システム / System", -}; - const ANY = "__any__"; const KINDS: ActivityKind[] = [ "clip_ingest", @@ -82,6 +65,7 @@ function formatDetail(entry: ActivityEntry): string { * Admin Activity Log page. */ export default function ActivityLog() { + const { t } = useTranslation(); const [entries, setEntries] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); @@ -130,26 +114,26 @@ export default function ActivityLog() { return (
-

Activity Log / 活動ログ

+

{t("activityLog.title")}

- すべて + {t("common.all")} {ACTORS.map((a) => ( - {ACTOR_LABELS[a]} + {t(`activityLog.actors.${a}`)} ))} @@ -180,29 +164,29 @@ export default function ActivityLog() { )} {loading && entries.length === 0 ? ( -

読み込み中...

+

{t("common.loading")}

) : entries.length === 0 ? ( -

活動ログはまだありません。

+

{t("activityLog.empty")}

) : (
- 種別 - 起点 - 詳細 - 関連ページ - 記録日時 + {t("activityLog.columns.kind")} + {t("activityLog.columns.actor")} + {t("activityLog.columns.detail")} + {t("activityLog.columns.relatedPages")} + {t("activityLog.columns.createdAt")} {entries.map((entry) => ( - {KIND_LABELS[entry.kind]} + {t(`activityLog.kinds.${entry.kind}`)} - {ACTOR_LABELS[entry.actor]} + {t(`activityLog.actors.${entry.actor}`)} {formatDetail(entry)} @@ -218,7 +202,7 @@ export default function ActivityLog() {

- 表示 {entries.length} / 合計 {total} 件 + {t("activityLog.showing", { shown: entries.length, total })}

)} diff --git a/admin/src/pages/Layout.tsx b/admin/src/pages/Layout.tsx index 634a02f8..604c7525 100644 --- a/admin/src/pages/Layout.tsx +++ b/admin/src/pages/Layout.tsx @@ -1,5 +1,6 @@ import { Outlet, Link, useLocation } from "react-router-dom"; import { Bot, Users, ScrollText, HeartPulse, Activity } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { SidebarProvider, Sidebar, @@ -15,12 +16,12 @@ import { SidebarHeader, } from "@zedi/ui"; -const navLinks = [ - { to: "/ai-models", label: "AI モデル", icon: Bot }, - { to: "/users", label: "ユーザー管理", icon: Users }, - { to: "/audit-logs", label: "監査ログ", icon: ScrollText }, - { to: "/wiki-health", label: "Wiki Health", icon: HeartPulse }, - { to: "/activity-log", label: "活動ログ", icon: Activity }, +const NAV_ITEMS = [ + { to: "/ai-models", labelKey: "nav.items.aiModels", icon: Bot }, + { to: "/users", labelKey: "nav.items.users", icon: Users }, + { to: "/audit-logs", labelKey: "nav.items.auditLogs", icon: ScrollText }, + { to: "/wiki-health", labelKey: "nav.items.wikiHealth", icon: HeartPulse }, + { to: "/activity-log", labelKey: "nav.items.activityLog", icon: Activity }, ]; /** @@ -29,19 +30,21 @@ const navLinks = [ */ export default function Layout() { const location = useLocation(); + const { t } = useTranslation(); return ( - Zedi 管理画面 + {t("nav.adminPanelTitle")} - メニュー + {t("nav.menu")} - {navLinks.map(({ to, label, icon: Icon }) => { + {NAV_ITEMS.map(({ to, labelKey, icon: Icon }) => { + const label = t(labelKey); const isActive = location.pathname === to || (to !== "/" && location.pathname.startsWith(to)); return ( @@ -63,7 +66,7 @@ export default function Layout() {
- 管理画面 + {t("nav.adminShortTitle")}
diff --git a/admin/src/pages/Login.tsx b/admin/src/pages/Login.tsx index 61632a47..16270e57 100644 --- a/admin/src/pages/Login.tsx +++ b/admin/src/pages/Login.tsx @@ -1,26 +1,27 @@ import { Button } from "@zedi/ui"; +import { useTranslation } from "react-i18next"; /** * 管理者ログイン案内 * メインアプリでサインイン後、このドメインに戻るとセッションが有効になる。 + * + * Admin login prompt. Once the user signs in via the main app, returning to + * this domain establishes the admin session. */ export default function Login() { + const { t } = useTranslation(); const mainAppUrl = import.meta.env.VITE_MAIN_APP_URL || "https://zedi-note.app"; const signInUrl = `${mainAppUrl}/sign-in`; return (
-

Zedi 管理画面

-

- 管理者としてログインするには、まずメインアプリでサインインしてください。 -

+

{t("auth.title")}

+

{t("auth.description")}

-

- サインイン後、このページに戻ってきてください。 -

+

{t("auth.afterSignIn")}

); diff --git a/admin/src/pages/ai-models/AiModelCard.tsx b/admin/src/pages/ai-models/AiModelCard.tsx index c698e1e5..a368a42d 100644 --- a/admin/src/pages/ai-models/AiModelCard.tsx +++ b/admin/src/pages/ai-models/AiModelCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, Card, @@ -21,6 +22,7 @@ interface AiModelCardProps { /** * モバイル用リスト表示(カード形式)。ドラッグ並び替えはなし。 + * Mobile card view of an AI model row (no drag reorder support). */ export function AiModelCard({ model: m, @@ -29,6 +31,7 @@ export function AiModelCard({ onTierChange, onToggleActive, }: AiModelCardProps) { + const { t } = useTranslation(); return ( @@ -50,7 +53,7 @@ export function AiModelCard({ } }} className="h-8 text-sm" - aria-label={`${m.modelId} の表示名`} + aria-label={t("aiModels.displayNameAriaLabel", { modelId: m.modelId })} />
@@ -60,7 +63,10 @@ export function AiModelCard({ if (v === "free" || v === "pro") onTierChange(v); }} > - + diff --git a/admin/src/pages/ai-models/AiModelRow.tsx b/admin/src/pages/ai-models/AiModelRow.tsx index 48784b4b..731eb8bb 100644 --- a/admin/src/pages/ai-models/AiModelRow.tsx +++ b/admin/src/pages/ai-models/AiModelRow.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, Input, @@ -44,6 +45,7 @@ export function AiModelRow({ onDrop, onDragEnd, }: AiModelRowProps) { + const { t } = useTranslation(); return ( - すべて - {KNOWN_ACTIONS.map((a) => ( - - {a.label} + {t("common.all")} + {KNOWN_ACTION_VALUES.map((value) => ( + + {t(`audit.actions.${value}`)} ))} @@ -134,7 +134,7 @@ export function AuditLogsContent({
読み込み中...

+

{t("common.loading")}

) : logs.length === 0 ? ( -

監査ログはありません。

+

{t("audit.empty")}

) : ( <>
- 日時 - 操作者 - アクション - 対象 - 変更内容 - IP + {t("audit.columns.createdAt")} + {t("audit.columns.actor")} + {t("audit.columns.action")} + {t("audit.columns.target")} + {t("audit.columns.diff")} + {t("audit.columns.ipAddress")} @@ -219,13 +219,15 @@ export function AuditLogsContent({

- {total > 0 ? `${rangeStart}-${rangeEnd}` : "0"} 件を表示 / 合計 {total} 件 + {total > 0 + ? t("common.showingRange", { rangeStart, rangeEnd, total }) + : t("common.showingZero", { total })}

{total > pageSize && (
- {page + 1} / {pageCount} ページ + {t("common.page", { page: page + 1, count: pageCount })}
diff --git a/admin/src/pages/users/SuspendDialog.tsx b/admin/src/pages/users/SuspendDialog.tsx index df16d60d..0fe66558 100644 --- a/admin/src/pages/users/SuspendDialog.tsx +++ b/admin/src/pages/users/SuspendDialog.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Label, Textarea } from "@zedi/ui"; import type { UserAdmin } from "@/api/admin"; import { ConfirmActionDialog } from "@/components/ConfirmActionDialog"; @@ -17,6 +18,7 @@ interface SuspendDialogProps { * Dialog for entering suspension reason before suspending a user. */ export function SuspendDialog({ user, onClose, onConfirm }: SuspendDialogProps) { + const { t } = useTranslation(); const [reason, setReason] = useState(""); const handleConfirm = () => { @@ -37,23 +39,21 @@ export function SuspendDialog({ user, onClose, onConfirm }: SuspendDialogProps)
- +