diff --git a/.env.example b/.env.example index 3098b4d8..b1cfc43a 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ VITE_REALTIME_URL=ws://localhost:1234 # Hocuspocus server (server/hocuspocus): internal URL of the API for session verification (Better Auth cookie). # 未設定のままでは本番・ステージングで接続拒否。ローカルだけで API なしで試す場合のみ HOCUSPOCUS_DEV_MODE=true(非本番のみ)。 # API_INTERNAL_URL=http://localhost:3000 +# API server (restore snapshots): internal URL of Hocuspocus for live document invalidation after restore. +# スナップショット復元後に stale なライブドキュメントを切断するための Hocuspocus 内部 URL。 +# ローカル開発では未設定時に http://127.0.0.1:1234 を既定で使用。 +# HOCUSPOCUS_INTERNAL_URL=http://localhost:1234 # HOCUSPOCUS_DEV_MODE=true # Polar (Pro plan billing) diff --git a/.gitignore b/.gitignore index 2e759350..04559ac1 100644 --- a/.gitignore +++ b/.gitignore @@ -64,8 +64,9 @@ src-tauri/gen/ # Sidecar executable for externalBin (build with `bun run sidecar:build` or `tauri:dev` ensure step) src-tauri/binaries/claude-sidecar* -# Claude Code local worktrees (not tracked) +# Claude Code local worktrees and settings (not tracked) .claude/worktrees/ +.claude/settings.local.json # Local-only notes (not tracked). See AGENTS.md / SPECIFICATION_POLICY.md. /docs/ diff --git a/db/migrations/002_add_page_snapshots.sql b/db/migrations/002_add_page_snapshots.sql new file mode 100644 index 00000000..70557beb --- /dev/null +++ b/db/migrations/002_add_page_snapshots.sql @@ -0,0 +1,19 @@ +-- Migration: 002_add_page_snapshots +-- Description: Add page_snapshots table for page version history +-- Date: 2026-04-07 + +-- ページスナップショット(バージョン履歴) +-- Page snapshots (version history) +CREATE TABLE IF NOT EXISTS page_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + version BIGINT NOT NULL, + ydoc_state BYTEA NOT NULL, + content_text TEXT, + created_by TEXT, + trigger TEXT NOT NULL DEFAULT 'auto', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_page_snapshots_page_id ON page_snapshots(page_id); +CREATE INDEX IF NOT EXISTS idx_page_snapshots_page_created ON page_snapshots(page_id, created_at DESC); diff --git a/server/api/src/__tests__/constants.test.ts b/server/api/src/__tests__/constants.test.ts new file mode 100644 index 00000000..3cf560ff --- /dev/null +++ b/server/api/src/__tests__/constants.test.ts @@ -0,0 +1,18 @@ +/** + * 共通定数のテスト + * Tests for shared constants + */ +import { describe, it, expect } from "vitest"; +import { SNAPSHOT_INTERVAL_MS, MAX_SNAPSHOTS_PER_PAGE } from "../constants.js"; + +describe("SNAPSHOT_INTERVAL_MS", () => { + it("is 10 minutes in milliseconds", () => { + expect(SNAPSHOT_INTERVAL_MS).toBe(600_000); + }); +}); + +describe("MAX_SNAPSHOTS_PER_PAGE", () => { + it("is 100", () => { + expect(MAX_SNAPSHOTS_PER_PAGE).toBe(100); + }); +}); diff --git a/server/api/src/__tests__/routes/pageSnapshots.test.ts b/server/api/src/__tests__/routes/pageSnapshots.test.ts new file mode 100644 index 00000000..62556db6 --- /dev/null +++ b/server/api/src/__tests__/routes/pageSnapshots.test.ts @@ -0,0 +1,318 @@ +/** + * pageSnapshots ルートのテスト(認可・CRUD) + * Tests for page snapshots routes: authorization, list, detail, restore. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../types/index.js"; + +vi.mock("../../middleware/auth.js", () => ({ + authRequired: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + await next(); + }, +})); + +import { Hono } from "hono"; +import pageSnapshotRoutes from "../../routes/pageSnapshots.js"; +import { createMockDb } from "../createMockDb.js"; + +const OWNER_ID = "owner-user-001"; +const MEMBER_ID = "member-user-002"; +const OTHER_ID = "other-user-003"; +const PAGE_ID = "page-snap-test-001"; +const SNAPSHOT_ID = "snap-001"; +const NOTE_ID = "note-001"; + +function authHeaders(userId: string = OWNER_ID) { + return { + "x-test-user-id": userId, + "Content-Type": "application/json", + }; +} + +function createSnapshotsApp(dbResults: unknown[]) { + const { db } = createMockDb(dbResults); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("db", db as unknown as AppEnv["Variables"]["db"]); + await next(); + }); + app.route("/api/pages", pageSnapshotRoutes); + return app; +} + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// ── 認証 / Authentication ────────────────────────────────────────────────── + +describe("Authentication", () => { + it("returns 401 without auth header", async () => { + const app = createSnapshotsApp([]); + const res = await app.request(`/api/pages/${PAGE_ID}/snapshots`, { + method: "GET", + }); + expect(res.status).toBe(401); + }); +}); + +// ── GET /snapshots — 一覧 / List ──────────────────────────────────────────── + +describe("GET /api/pages/:id/snapshots", () => { + it("returns snapshots for page owner", async () => { + const now = new Date(); + const app = createSnapshotsApp([ + // assertPageViewAccess: pages query + [{ id: PAGE_ID, ownerId: OWNER_ID }], + // snapshots query + [ + { + id: SNAPSHOT_ID, + version: 1, + contentText: "hello", + createdBy: OWNER_ID, + trigger: "auto", + createdAt: now, + }, + ], + // users query (email resolution) + [{ id: OWNER_ID, email: "owner@example.com" }], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/snapshots`, { + method: "GET", + headers: authHeaders(OWNER_ID), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + snapshots: Array<{ + id: string; + version: number; + content_text: string; + created_by: string; + created_by_email: string; + trigger: string; + created_at: string; + }>; + }; + expect(body.snapshots).toEqual([ + expect.objectContaining({ + id: SNAPSHOT_ID, + created_by_email: "owner@example.com", + }), + ]); + }); + + it("returns 404 when page does not exist", async () => { + const app = createSnapshotsApp([ + // assertPageViewAccess: pages query returns empty + [], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/snapshots`, { + method: "GET", + headers: authHeaders(OWNER_ID), + }); + + expect(res.status).toBe(404); + }); + + it("returns 403 when user is not owner and not a note member", async () => { + const app = createSnapshotsApp([ + // assertPageViewAccess: pages query + [{ id: PAGE_ID, ownerId: OWNER_ID }], + // user email lookup + [{ email: "other@example.com" }], + // notePages + noteMembers JOIN returns empty + [], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/snapshots`, { + method: "GET", + headers: authHeaders(OTHER_ID), + }); + + expect(res.status).toBe(403); + }); + + it("allows access for note member", async () => { + const app = createSnapshotsApp([ + // assertPageViewAccess: pages query (owner is different) + [{ id: PAGE_ID, ownerId: OWNER_ID }], + // user email lookup + [{ email: "member@example.com" }], + // notePages + noteMembers JOIN returns a match + [{ noteId: NOTE_ID }], + // snapshots query + [], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/snapshots`, { + method: "GET", + headers: authHeaders(MEMBER_ID), + }); + + expect(res.status).toBe(200); + }); +}); + +// ── GET /snapshots/:snapshotId — 詳細 / Detail ───────────────────────────── + +describe("GET /api/pages/:id/snapshots/:snapshotId", () => { + it("returns snapshot detail for owner", async () => { + const now = new Date(); + const ydocBuffer = Buffer.from("test-ydoc"); + const app = createSnapshotsApp([ + // assertPageViewAccess: pages query + [{ id: PAGE_ID, ownerId: OWNER_ID }], + // snapshot query + [ + { + id: SNAPSHOT_ID, + pageId: PAGE_ID, + version: 1, + ydocState: ydocBuffer, + contentText: "hello", + createdBy: OWNER_ID, + trigger: "auto", + createdAt: now, + }, + ], + // user email lookup for created_by + [{ email: "owner@example.com" }], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}`, { + method: "GET", + headers: authHeaders(OWNER_ID), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + id: string; + ydoc_state: string; + content_text: string; + }; + expect(body.id).toBe(SNAPSHOT_ID); + expect(body.ydoc_state).toBe(ydocBuffer.toString("base64")); + expect(body.content_text).toBe("hello"); + }); + + it("returns 404 when snapshot does not exist", async () => { + const app = createSnapshotsApp([ + // assertPageViewAccess: pages query + [{ id: PAGE_ID, ownerId: OWNER_ID }], + // snapshot query returns empty + [], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/nonexistent`, { + method: "GET", + headers: authHeaders(OWNER_ID), + }); + + expect(res.status).toBe(404); + }); +}); + +// ── POST /snapshots/:snapshotId/restore — 復元 / Restore ────────────────── + +describe("POST /api/pages/:id/snapshots/:snapshotId/restore", () => { + it("returns 403 when non-owner tries to restore", async () => { + const app = createSnapshotsApp([ + // page ownership check + [{ id: PAGE_ID, ownerId: OWNER_ID }], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}/restore`, { + method: "POST", + headers: authHeaders(OTHER_ID), + }); + + expect(res.status).toBe(403); + }); + + it("returns 404 when page does not exist for restore", async () => { + const app = createSnapshotsApp([ + // page query returns empty + [], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}/restore`, { + method: "POST", + headers: authHeaders(OWNER_ID), + }); + + expect(res.status).toBe(404); + }); + + it("returns 404 when snapshot does not exist for restore", async () => { + const app = createSnapshotsApp([ + // page ownership check + [{ id: PAGE_ID, ownerId: OWNER_ID }], + // snapshot query returns empty + [], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}/restore`, { + method: "POST", + headers: authHeaders(OWNER_ID), + }); + + expect(res.status).toBe(404); + }); + + it("restores snapshot and returns new version for owner", async () => { + const ydocBuffer = Buffer.from("restored-ydoc"); + const app = createSnapshotsApp([ + // page ownership check + [{ id: PAGE_ID, ownerId: OWNER_ID }], + // snapshot query + [ + { + id: SNAPSHOT_ID, + pageId: PAGE_ID, + version: 1, + ydocState: ydocBuffer, + contentText: "restored content", + createdBy: OWNER_ID, + trigger: "auto", + createdAt: new Date(), + }, + ], + // transaction: row lock + [{}], + // transaction: current content + [{ version: 2, ydocState: Buffer.from("current"), contentText: "current" }], + // transaction: insert current snapshot + [{}], + // transaction: update page_contents + [{ version: 3, pageId: PAGE_ID }], + // transaction: insert restore snapshot + [{ id: "snap-restore-001" }], + // transaction: update pages metadata + [{}], + // transaction: prune old snapshots + [{}], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}/restore`, { + method: "POST", + headers: authHeaders(OWNER_ID), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { version: number; snapshot_id: string }; + expect(body.version).toBe(3); + expect(body.snapshot_id).toBe("snap-restore-001"); + }); +}); diff --git a/server/api/src/__tests__/routes/pages.test.ts b/server/api/src/__tests__/routes/pages.test.ts index 0840ce59..f81dec53 100644 --- a/server/api/src/__tests__/routes/pages.test.ts +++ b/server/api/src/__tests__/routes/pages.test.ts @@ -40,6 +40,17 @@ function createPagesApp(dbResults: unknown[]) { return app; } +function createPagesAppWithChains(dbResults: unknown[]) { + const { db, chains } = createMockDb(dbResults); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("db", db as unknown as AppEnv["Variables"]["db"]); + await next(); + }); + app.route("/api/pages", pageRoutes); + return { app, chains }; +} + describe("GET /api/pages/:id/content", () => { it("returns 200 with empty ydoc_state when page exists but page_contents row is missing", async () => { const app = createPagesApp([[{ id: PAGE_ID, ownerId: TEST_USER_ID }], []]); @@ -84,9 +95,12 @@ describe("GET /api/pages/:id/content", () => { describe("PUT /api/pages/:id/content", () => { it("creates page_contents when expected_version is 0 and no row exists (aligns with GET version 0)", async () => { const ydocB64 = Buffer.from("hello").toString("base64"); - const app = createPagesApp([ + const { app, chains } = createPagesAppWithChains([ [{ id: PAGE_ID, ownerId: TEST_USER_ID }], [{ version: 1, pageId: PAGE_ID }], + [], + [{ id: "snap-1" }], + [], ]); const res = await app.request(`/api/pages/${PAGE_ID}/content`, { @@ -101,12 +115,18 @@ describe("PUT /api/pages/:id/content", () => { expect(res.status).toBe(200); const body = (await res.json()) as { version: number }; expect(body.version).toBe(1); + // maybeCreateSnapshot の内部実装順に依存しないよう、スナップショット経路が走ったことだけ確認する。 + const methods = chains.map((chain) => chain.startMethod); + expect(methods).toContain("insert"); }); it("accepts ydoc_state empty string for first save (matches GET when page_contents is missing)", async () => { const app = createPagesApp([ [{ id: PAGE_ID, ownerId: TEST_USER_ID }], [{ version: 1, pageId: PAGE_ID }], + [], + [{ id: "snap-2" }], + [], ]); const res = await app.request(`/api/pages/${PAGE_ID}/content`, { diff --git a/server/api/src/__tests__/services/snapshotService.test.ts b/server/api/src/__tests__/services/snapshotService.test.ts new file mode 100644 index 00000000..333efc4a --- /dev/null +++ b/server/api/src/__tests__/services/snapshotService.test.ts @@ -0,0 +1,86 @@ +/** + * snapshotService のテスト + * Tests for snapshotService (API-side auto-snapshot logic) + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createMockDb } from "../createMockDb.js"; +import { maybeCreateSnapshot } from "../../services/snapshotService.js"; + +// SNAPSHOT_INTERVAL_MS = 600_000 (10 minutes) +const TEN_MINUTES = 10 * 60 * 1000; + +const PAGE_ID = "page-aaa-111"; +const USER_ID = "user-bbb-222"; + +function makeYdocBuffer(): Buffer { + return Buffer.from("fake-ydoc-state"); +} + +describe("maybeCreateSnapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + it("前回スナップショットがない場合、スナップショットを作成する / creates snapshot when no prior snapshot exists", async () => { + vi.setSystemTime(new Date("2026-04-07T12:00:00Z")); + + // Query 1: select last snapshot → empty + // Query 2: insert snapshot + // Query 3: delete pruning (execute) + const { db } = createMockDb([ + [], // no prior snapshots + [{ id: "snap-1" }], // insert result (not used) + [], // pruning result + ]); + + await maybeCreateSnapshot(db as never, PAGE_ID, makeYdocBuffer(), "some text", 5, USER_ID); + + // 3 DB operations: select, insert, execute(delete) + expect(true).toBe(true); // No error thrown = success + }); + + it("前回スナップショットから10分経過している場合、スナップショットを作成する / creates snapshot when 10+ minutes elapsed", async () => { + const now = new Date("2026-04-07T12:10:00Z"); + vi.setSystemTime(now); + + const lastCreatedAt = new Date(now.getTime() - TEN_MINUTES); // exactly 10 min ago + + const { db } = createMockDb([ + [{ createdAt: lastCreatedAt }], // last snapshot + [{ id: "snap-new" }], // insert + [], // pruning + ]); + + await maybeCreateSnapshot(db as never, PAGE_ID, makeYdocBuffer(), "updated text", 10, USER_ID); + + expect(true).toBe(true); // No error thrown = success + }); + + it("前回スナップショットから10分未満の場合、スナップショットを作成しない / skips snapshot when less than 10 minutes elapsed", async () => { + const now = new Date("2026-04-07T12:05:00Z"); + vi.setSystemTime(now); + + const lastCreatedAt = new Date(now.getTime() - (TEN_MINUTES - 1000)); // 9 min 59 sec ago + + // Only 1 query: select last snapshot + // No insert or pruning should happen + const { db, chains } = createMockDb([[{ createdAt: lastCreatedAt }]]); + + await maybeCreateSnapshot(db as never, PAGE_ID, makeYdocBuffer(), "text", 3, USER_ID); + + // Should only have 1 chain (the select query) + expect(chains.length).toBe(1); + expect(chains[0]?.startMethod).toBe("select"); + }); + + it("contentText が null でもエラーにならない / handles null contentText", async () => { + vi.setSystemTime(new Date("2026-04-07T12:00:00Z")); + + const { db } = createMockDb([[], [{ id: "snap-1" }], []]); + + await expect( + maybeCreateSnapshot(db as never, PAGE_ID, makeYdocBuffer(), null, 1, USER_ID), + ).resolves.toBeUndefined(); + }); +}); diff --git a/server/api/src/app.ts b/server/api/src/app.ts index a222e9bc..46f545d0 100644 --- a/server/api/src/app.ts +++ b/server/api/src/app.ts @@ -11,6 +11,7 @@ import type { AppEnv } from "./types/index.js"; import healthRoutes from "./routes/health.js"; import userRoutes from "./routes/users.js"; import pageRoutes from "./routes/pages.js"; +import pageSnapshotRoutes from "./routes/pageSnapshots.js"; import syncPageRoutes from "./routes/syncPages.js"; import noteRoutes from "./routes/notes/index.js"; import searchRoutes from "./routes/search.js"; @@ -83,6 +84,9 @@ export function createApp(): Hono { // Pages app.route("/api/pages", pageRoutes); + // Page Snapshots (version history) + app.route("/api/pages", pageSnapshotRoutes); + // Sync app.route("/api/sync/pages", syncPageRoutes); diff --git a/server/api/src/constants.ts b/server/api/src/constants.ts new file mode 100644 index 00000000..c38c0a5e --- /dev/null +++ b/server/api/src/constants.ts @@ -0,0 +1,20 @@ +/** + * サーバー共通定数 + * Shared server-side constants + */ + +/** + * スナップショット取得間隔(ミリ秒)/ Snapshot interval in ms (10 minutes) + * + * ⚠️ server/hocuspocus/src/snapshotUtils.ts にも同じ値が定義されています。変更時は両方を更新してください。 + * ⚠️ The same value is defined in server/hocuspocus/src/snapshotUtils.ts. Update both when changing. + */ +export const SNAPSHOT_INTERVAL_MS = 10 * 60 * 1000; + +/** + * スナップショット保持上限 / Maximum snapshots per page + * + * ⚠️ server/hocuspocus/src/snapshotUtils.ts にも同じ値が定義されています。変更時は両方を更新してください。 + * ⚠️ The same value is defined in server/hocuspocus/src/snapshotUtils.ts. Update both when changing. + */ +export const MAX_SNAPSHOTS_PER_PAGE = 100; diff --git a/server/api/src/routes/pageSnapshots.ts b/server/api/src/routes/pageSnapshots.ts new file mode 100644 index 00000000..dc1da8b8 --- /dev/null +++ b/server/api/src/routes/pageSnapshots.ts @@ -0,0 +1,301 @@ +/** + * /api/pages/:id/snapshots — ページバージョン履歴 API + * Page version history (snapshots) API + * + * GET /:id/snapshots — スナップショット一覧 / List snapshots + * GET /:id/snapshots/:snapshotId — スナップショット詳細 / Get snapshot detail + * POST /:id/snapshots/:snapshotId/restore — 復元(新バージョンとして)/ Restore as new version + */ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { eq, and, desc, sql, inArray } from "drizzle-orm"; +import { pages, pageContents, pageSnapshots, users } from "../schema/index.js"; +import { authRequired } from "../middleware/auth.js"; +import type { AppEnv } from "../types/index.js"; +import { assertPageViewAccess } from "../services/pageAccessService.js"; +import { pruneSnapshotsExceedingLimitSql } from "../services/snapshotService.js"; + +const app = new Hono(); +const DEFAULT_HOCUSPOCUS_INTERNAL_URL = "http://127.0.0.1:1234"; +/** Best-effort invalidation HTTP timeout (ms). / ベストエフォート無効化の HTTP タイムアウト(ミリ秒) */ +const HOCUSPOCUS_INVALIDATE_TIMEOUT_MS = 2500; + +function getHocuspocusInternalUrl(): string | null { + const explicitUrl = process.env.HOCUSPOCUS_INTERNAL_URL?.trim(); + if (explicitUrl) { + return explicitUrl.replace(/\/$/, ""); + } + return process.env.NODE_ENV === "development" ? DEFAULT_HOCUSPOCUS_INTERNAL_URL : null; +} + +/** + * Hocuspocus に復元後のライブドキュメント無効化を依頼する(ベストエフォート)。 + * タイムアウト・HTTP エラーはログのみで呼び出し元には伝えない。 + * + * Best-effort: asks Hocuspocus to drop live Y.Doc after restore. Timeouts and HTTP + * errors are logged only and never thrown to the caller. + */ +async function invalidateHocuspocusDocument(pageId: string): Promise { + const baseUrl = getHocuspocusInternalUrl(); + const internalSecret = process.env.BETTER_AUTH_SECRET?.trim(); + + if (!baseUrl || !internalSecret) { + if (process.env.NODE_ENV === "development") { + console.warn( + `[Snapshots] Skipped Hocuspocus invalidation for page ${pageId}: internal URL or secret is missing.`, + ); + } + return; + } + + const url = `${baseUrl}/internal/documents/${encodeURIComponent(pageId)}/invalidate`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), HOCUSPOCUS_INVALIDATE_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "x-internal-secret": internalSecret, + }, + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + console.warn( + `[Snapshots] Hocuspocus invalidation HTTP ${response.status} for page ${pageId}`, + ); + } + } catch (error) { + clearTimeout(timeoutId); + const name = error instanceof Error ? error.name : ""; + if (name === "AbortError") { + console.warn(`[Snapshots] Hocuspocus invalidation timed out for page ${pageId}`); + return; + } + console.warn(`[Snapshots] Hocuspocus invalidation failed for page ${pageId}:`, error); + } +} + +// ── GET /:id/snapshots ────────────────────────────────────────────────────── +app.get("/:id/snapshots", authRequired, async (c) => { + const pageId = c.req.param("id"); + const userId = c.get("userId"); + const db = c.get("db"); + + await assertPageViewAccess(db, pageId, userId); + + const rows = await db + .select({ + id: pageSnapshots.id, + version: pageSnapshots.version, + contentText: pageSnapshots.contentText, + createdBy: pageSnapshots.createdBy, + trigger: pageSnapshots.trigger, + createdAt: pageSnapshots.createdAt, + }) + .from(pageSnapshots) + .where(eq(pageSnapshots.pageId, pageId)) + .orderBy(desc(pageSnapshots.createdAt)); + + // created_by → email をまとめて解決 + const userIds = [...new Set(rows.map((r) => r.createdBy).filter(Boolean))] as string[]; + const emailMap = new Map(); + if (userIds.length > 0) { + const userRows = await db + .select({ id: users.id, email: users.email }) + .from(users) + .where(inArray(users.id, userIds)); + for (const u of userRows) { + emailMap.set(u.id, u.email); + } + } + + return c.json({ + snapshots: rows.map((r) => ({ + id: r.id, + version: r.version, + content_text: r.contentText, + created_by: r.createdBy, + created_by_email: r.createdBy ? (emailMap.get(r.createdBy) ?? null) : null, + trigger: r.trigger, + created_at: r.createdAt.toISOString(), + })), + }); +}); + +// ── GET /:id/snapshots/:snapshotId ────────────────────────────────────────── +app.get("/:id/snapshots/:snapshotId", authRequired, async (c) => { + const pageId = c.req.param("id"); + const snapshotId = c.req.param("snapshotId"); + const userId = c.get("userId"); + const db = c.get("db"); + + await assertPageViewAccess(db, pageId, userId); + + const rows = await db + .select() + .from(pageSnapshots) + .where(and(eq(pageSnapshots.id, snapshotId), eq(pageSnapshots.pageId, pageId))) + .limit(1); + + const row = rows[0]; + if (!row) throw new HTTPException(404, { message: "Snapshot not found" }); + + // created_by の email を取得 + let createdByEmail: string | null = null; + if (row.createdBy) { + const userRow = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.id, row.createdBy)) + .limit(1); + createdByEmail = userRow[0]?.email ?? null; + } + + const ydocBase64 = + row.ydocState instanceof Buffer + ? row.ydocState.toString("base64") + : Buffer.from(row.ydocState as unknown as ArrayBufferLike).toString("base64"); + + return c.json({ + id: row.id, + version: row.version, + ydoc_state: ydocBase64, + content_text: row.contentText, + created_by: row.createdBy, + created_by_email: createdByEmail, + trigger: row.trigger, + created_at: row.createdAt.toISOString(), + }); +}); + +/** + * POST /:id/snapshots/:snapshotId/restore + * + * スナップショットを復元する。復元はページオーナーのみが実行可能。 + * 共同編集者(ノートメンバー)には復元権限がない。これは意図的な仕様制限であり、 + * オーナーが明示的に承認した状態のみが復元されることを保証する。 + * + * Restore a snapshot. Only the page owner can perform a restore. + * Note members (collaborators) are intentionally excluded from this operation + * to ensure only owner-approved states are restored. + * + * **Collaboration / コラボレーション**: This endpoint acquires a DB row lock for `page_contents` + * and then asks Hocuspocus to invalidate the live document after commit. Configure + * `HOCUSPOCUS_INTERNAL_URL` (or rely on the local default) plus `BETTER_AUTH_SECRET` + * so stale in-memory Y.Doc state is disconnected before it can overwrite the restored DB state. + */ +// ── POST /:id/snapshots/:snapshotId/restore ───────────────────────────────── +app.post("/:id/snapshots/:snapshotId/restore", authRequired, async (c) => { + const pageId = c.req.param("id"); + const snapshotId = c.req.param("snapshotId"); + const userId = c.get("userId"); + const db = c.get("db"); + + // 復元は編集権限が必要(所有者のみ) / Restore requires owner permission + const page = await db + .select({ id: pages.id, ownerId: pages.ownerId }) + .from(pages) + .where(and(eq(pages.id, pageId), eq(pages.isDeleted, false))) + .limit(1); + + const pageRow = page[0]; + if (!pageRow) throw new HTTPException(404, { message: "Page not found" }); + if (pageRow.ownerId !== userId) throw new HTTPException(403, { message: "Forbidden" }); + + // 復元対象のスナップショットを取得 + const snapRows = await db + .select() + .from(pageSnapshots) + .where(and(eq(pageSnapshots.id, snapshotId), eq(pageSnapshots.pageId, pageId))) + .limit(1); + + const snap = snapRows[0]; + if (!snap) throw new HTTPException(404, { message: "Snapshot not found" }); + + // トランザクションで復元処理 + const result = await db.transaction(async (tx) => { + // page_contents 行をロックし、pre-restore バックアップと復元を同じ直列化境界で実行する。 + // Lock the current page_contents row so backup + restore observe a consistent state. + await tx.execute(sql`SELECT 1 FROM page_contents WHERE page_id = ${pageId} FOR UPDATE`); + + // 1. 現在の状態をスナップショットとして保存 + const currentContent = await tx + .select() + .from(pageContents) + .where(eq(pageContents.pageId, pageId)) + .limit(1); + + const current = currentContent[0]; + if (current) { + await tx.insert(pageSnapshots).values({ + pageId, + version: current.version, + ydocState: current.ydocState, + contentText: current.contentText, + createdBy: userId, + trigger: "pre-restore", + }); + } + + // 2. page_contents を復元対象で上書き(version +1) + const updated = await tx + .update(pageContents) + .set({ + ydocState: snap.ydocState, + version: sql`${pageContents.version} + 1`, + contentText: snap.contentText, + updatedAt: new Date(), + }) + .where(eq(pageContents.pageId, pageId)) + .returning(); + + const updatedRow = updated[0]; + if (!updatedRow) throw new HTTPException(500, { message: "Restore failed" }); + + // 3. 復元後の状態もスナップショットとして保存 (trigger: 'restore') + const restoreSnap = await tx + .insert(pageSnapshots) + .values({ + pageId, + version: updatedRow.version, + ydocState: snap.ydocState, + contentText: snap.contentText, + createdBy: userId, + trigger: "restore", + }) + .returning(); + const restoreSnapshotId = restoreSnap[0]?.id; + if (!restoreSnapshotId) { + throw new HTTPException(500, { message: "Restore snapshot insert failed" }); + } + + // 4. pages メタデータ更新 + const contentPreview = snap.contentText + ? snap.contentText.trim().replace(/\s+/g, " ").slice(0, 120) + : null; + await tx + .update(pages) + .set({ contentPreview, updatedAt: new Date() }) + .where(eq(pages.id, pageId)); + + // 5. 100件超過分を削除 + await tx.execute(pruneSnapshotsExceedingLimitSql(pageId)); + + return { + version: updatedRow.version, + snapshotId: restoreSnapshotId, + }; + }); + + await invalidateHocuspocusDocument(pageId); + + return c.json({ + version: result.version, + snapshot_id: result.snapshotId, + }); +}); + +export default app; diff --git a/server/api/src/routes/pages.ts b/server/api/src/routes/pages.ts index 97447de6..f5c35571 100644 --- a/server/api/src/routes/pages.ts +++ b/server/api/src/routes/pages.ts @@ -13,6 +13,26 @@ import { eq, and, sql } from "drizzle-orm"; import { pages, pageContents } from "../schema/index.js"; import { authRequired } from "../middleware/auth.js"; import type { AppEnv, Database } from "../types/index.js"; +import { maybeCreateSnapshot } from "../services/snapshotService.js"; + +/** + * ベストエフォートで自動スナップショットを作成する。失敗してもメイン処理には影響しない。 + * Best-effort auto-snapshot creation. Failures are logged but never propagate. + */ +async function tryAutoSnapshot( + db: Database, + pageId: string, + ydocState: Buffer, + contentText: string | null, + version: number, + userId: string, +): Promise { + try { + await maybeCreateSnapshot(db, pageId, ydocState, contentText, version, userId); + } catch (error) { + console.error(`[Snapshot] Failed to create auto-snapshot for page ${pageId}:`, error); + } +} const app = new Hono(); @@ -143,6 +163,14 @@ app.put("/:id/content", authRequired, async (c) => { }); if (firstSave.done) { + void tryAutoSnapshot( + db, + pageId, + ydocBuffer, + body.content_text ?? null, + firstSave.version, + userId, + ); return c.json({ version: firstSave.version }); } } @@ -177,6 +205,15 @@ app.put("/:id/content", authRequired, async (c) => { await applyPagesMetadataUpdate(db, pageId, body); + void tryAutoSnapshot( + db, + pageId, + ydocBuffer, + body.content_text ?? null, + updatedRow.version ?? 0, + userId, + ); + return c.json({ version: updatedRow.version ?? 0 }); } @@ -204,6 +241,16 @@ app.put("/:id/content", authRequired, async (c) => { const resultRow = result[0]; if (!resultRow) throw new HTTPException(500, { message: "Upsert failed" }); + + void tryAutoSnapshot( + db, + pageId, + ydocBuffer, + body.content_text ?? null, + resultRow.version ?? 0, + userId, + ); + return c.json({ version: resultRow.version }); }); diff --git a/server/api/src/schema/index.ts b/server/api/src/schema/index.ts index b7c8d2f9..99b1b094 100644 --- a/server/api/src/schema/index.ts +++ b/server/api/src/schema/index.ts @@ -28,6 +28,7 @@ export { type NewGhostLink, } from "./links.js"; export { pageContents, type PageContent, type NewPageContent } from "./pageContents.js"; +export { pageSnapshots, type PageSnapshot, type NewPageSnapshot } from "./pageSnapshots.js"; export { media, type Media, type NewMedia } from "./media.js"; export { subscriptions, type Subscription, type NewSubscription } from "./subscriptions.js"; export { @@ -64,6 +65,7 @@ export { linksRelations, ghostLinksRelations, pageContentsRelations, + pageSnapshotsRelations, mediaRelations, subscriptionsRelations, aiUsageLogsRelations, diff --git a/server/api/src/schema/pageSnapshots.ts b/server/api/src/schema/pageSnapshots.ts new file mode 100644 index 00000000..f09e5ef5 --- /dev/null +++ b/server/api/src/schema/pageSnapshots.ts @@ -0,0 +1,51 @@ +/** + * page_snapshots — ページバージョン履歴スナップショット + * Page version history snapshots + */ +import { pgTable, uuid, text, bigint, timestamp, customType, index } from "drizzle-orm/pg-core"; +import { pages } from "./pages.js"; + +const bytea = customType<{ data: Buffer; dpiType: string }>({ + dataType() { + return "bytea"; + }, +}); + +/** + * `page_snapshots` テーブル定義。ページごとの履歴スナップショットを保持し、 + * 復元・比較・自動保存の基準データとして使う。 + * `page_snapshots` table definition for per-page history snapshots used by + * restore, compare, and auto-save workflows. + */ +export const pageSnapshots = pgTable( + "page_snapshots", + { + id: uuid("id").primaryKey().defaultRandom(), + pageId: uuid("page_id") + .notNull() + .references(() => pages.id, { onDelete: "cascade" }), + version: bigint("version", { mode: "number" }).notNull(), + ydocState: bytea("ydoc_state").notNull(), + contentText: text("content_text"), + createdBy: text("created_by"), + trigger: text("trigger", { enum: ["auto", "restore", "pre-restore"] }) + .notNull() + .default("auto"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("idx_page_snapshots_page_id").on(table.pageId), + index("idx_page_snapshots_page_created").on(table.pageId, table.createdAt), + ], +); + +/** + * 取得時のページスナップショット行型。 + * Selected row type for `page_snapshots`. + */ +export type PageSnapshot = typeof pageSnapshots.$inferSelect; +/** + * 挿入時のページスナップショット行型。 + * Insert shape for `page_snapshots`. + */ +export type NewPageSnapshot = typeof pageSnapshots.$inferInsert; diff --git a/server/api/src/schema/relations.ts b/server/api/src/schema/relations.ts index 2c7c63e8..db1ba92d 100644 --- a/server/api/src/schema/relations.ts +++ b/server/api/src/schema/relations.ts @@ -4,11 +4,15 @@ import { pages } from "./pages.js"; import { notes, notePages, noteMembers } from "./notes.js"; import { links, ghostLinks } from "./links.js"; import { pageContents } from "./pageContents.js"; +import { pageSnapshots } from "./pageSnapshots.js"; import { media } from "./media.js"; import { subscriptions } from "./subscriptions.js"; import { aiUsageLogs, aiMonthlyUsage } from "./aiModels.js"; -export const usersRelations = relations(users, ({ many, one }) => ({ +export /** + * + */ +const usersRelations = relations(users, ({ many, one }) => ({ pages: many(pages), notes: many(notes), media: many(media), @@ -22,21 +26,30 @@ export const usersRelations = relations(users, ({ many, one }) => ({ aiMonthlyUsage: many(aiMonthlyUsage), })); -export const sessionRelations = relations(session, ({ one }) => ({ +export /** + * + */ +const sessionRelations = relations(session, ({ one }) => ({ user: one(users, { fields: [session.userId], references: [users.id], }), })); -export const accountRelations = relations(account, ({ one }) => ({ +export /** + * + */ +const accountRelations = relations(account, ({ one }) => ({ user: one(users, { fields: [account.userId], references: [users.id], }), })); -export const pagesRelations = relations(pages, ({ one, many }) => ({ +export /** + * + */ +const pagesRelations = relations(pages, ({ one, many }) => ({ owner: one(users, { fields: [pages.ownerId], references: [users.id], @@ -51,13 +64,17 @@ export const pagesRelations = relations(pages, ({ one, many }) => ({ references: [pageContents.pageId], }), notePages: many(notePages), + snapshots: many(pageSnapshots), media: many(media), outgoingLinks: many(links, { relationName: "sourceLinks" }), incomingLinks: many(links, { relationName: "targetLinks" }), ghostLinksFrom: many(ghostLinks, { relationName: "ghostLinkSource" }), })); -export const notesRelations = relations(notes, ({ one, many }) => ({ +export /** + * + */ +const notesRelations = relations(notes, ({ one, many }) => ({ owner: one(users, { fields: [notes.ownerId], references: [users.id], @@ -66,7 +83,10 @@ export const notesRelations = relations(notes, ({ one, many }) => ({ noteMembers: many(noteMembers), })); -export const notePagesRelations = relations(notePages, ({ one }) => ({ +export /** + * + */ +const notePagesRelations = relations(notePages, ({ one }) => ({ note: one(notes, { fields: [notePages.noteId], references: [notes.id], @@ -81,7 +101,10 @@ export const notePagesRelations = relations(notePages, ({ one }) => ({ }), })); -export const noteMembersRelations = relations(noteMembers, ({ one }) => ({ +export /** + * + */ +const noteMembersRelations = relations(noteMembers, ({ one }) => ({ note: one(notes, { fields: [noteMembers.noteId], references: [notes.id], @@ -92,7 +115,10 @@ export const noteMembersRelations = relations(noteMembers, ({ one }) => ({ }), })); -export const linksRelations = relations(links, ({ one }) => ({ +export /** + * + */ +const linksRelations = relations(links, ({ one }) => ({ source: one(pages, { fields: [links.sourceId], references: [pages.id], @@ -105,7 +131,10 @@ export const linksRelations = relations(links, ({ one }) => ({ }), })); -export const ghostLinksRelations = relations(ghostLinks, ({ one }) => ({ +export /** + * + */ +const ghostLinksRelations = relations(ghostLinks, ({ one }) => ({ sourcePage: one(pages, { fields: [ghostLinks.sourcePageId], references: [pages.id], @@ -122,14 +151,30 @@ export const ghostLinksRelations = relations(ghostLinks, ({ one }) => ({ }), })); -export const pageContentsRelations = relations(pageContents, ({ one }) => ({ +export /** + * + */ +const pageContentsRelations = relations(pageContents, ({ one }) => ({ page: one(pages, { fields: [pageContents.pageId], references: [pages.id], }), })); -export const mediaRelations = relations(media, ({ one }) => ({ +export /** + * + */ +const pageSnapshotsRelations = relations(pageSnapshots, ({ one }) => ({ + page: one(pages, { + fields: [pageSnapshots.pageId], + references: [pages.id], + }), +})); + +export /** + * + */ +const mediaRelations = relations(media, ({ one }) => ({ owner: one(users, { fields: [media.ownerId], references: [users.id], @@ -140,21 +185,30 @@ export const mediaRelations = relations(media, ({ one }) => ({ }), })); -export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({ +export /** + * + */ +const subscriptionsRelations = relations(subscriptions, ({ one }) => ({ user: one(users, { fields: [subscriptions.userId], references: [users.id], }), })); -export const aiUsageLogsRelations = relations(aiUsageLogs, ({ one }) => ({ +export /** + * + */ +const aiUsageLogsRelations = relations(aiUsageLogs, ({ one }) => ({ user: one(users, { fields: [aiUsageLogs.userId], references: [users.id], }), })); -export const aiMonthlyUsageRelations = relations(aiMonthlyUsage, ({ one }) => ({ +export /** + * + */ +const aiMonthlyUsageRelations = relations(aiMonthlyUsage, ({ one }) => ({ user: one(users, { fields: [aiMonthlyUsage.userId], references: [users.id], diff --git a/server/api/src/services/pageAccessService.ts b/server/api/src/services/pageAccessService.ts new file mode 100644 index 00000000..fcbfcfb3 --- /dev/null +++ b/server/api/src/services/pageAccessService.ts @@ -0,0 +1,69 @@ +/** + * ページアクセス権限チェックの共有サービス + * Shared page access authorization service. + */ +import { HTTPException } from "hono/http-exception"; +import { eq, and } from "drizzle-orm"; +import { pages, users, notes, notePages, noteMembers } from "../schema/index.js"; +import type { Database } from "../types/index.js"; + +/** + * ページへの閲覧権限を確認する。所有者またはノートメンバーであればアクセス可能。 + * Verify the user can view the page (owner or note member). + * + * Hocuspocus の `canEditNotePage` に準拠し、`note_members` を JOIN して + * 現在のユーザーが当該ノートのメンバーであることを検証する。 + * Mirrors the Hocuspocus `canEditNotePage` logic: JOINs `notes` with + * `is_deleted = FALSE` and `note_members` to verify membership. + */ +export async function assertPageViewAccess( + db: Database, + pageId: string, + userId: string, +): Promise { + const page = await db + .select({ id: pages.id, ownerId: pages.ownerId }) + .from(pages) + .where(and(eq(pages.id, pageId), eq(pages.isDeleted, false))) + .limit(1); + + const pageRow = page[0]; + if (!pageRow) throw new HTTPException(404, { message: "Page not found" }); + + // オーナーはアクセス可 / Owner always has access + if (pageRow.ownerId === userId) return; + + // ユーザーの email を取得 / Get user email for note_members lookup + const userRow = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!userRow[0]) { + throw new HTTPException(403, { message: "Forbidden" }); + } + + const userEmail = userRow[0].email.trim().toLowerCase(); + + // ページが属するノートを取得し、そのノートのメンバーかチェック + // Find notes this page belongs to and verify user is a member + const noteMembership = await db + .select({ noteId: notePages.noteId }) + .from(notePages) + .innerJoin(notes, and(eq(notes.id, notePages.noteId), eq(notes.isDeleted, false))) + .innerJoin( + noteMembers, + and( + eq(noteMembers.noteId, notePages.noteId), + eq(noteMembers.memberEmail, userEmail), + eq(noteMembers.isDeleted, false), + ), + ) + .where(and(eq(notePages.pageId, pageId), eq(notePages.isDeleted, false))) + .limit(1); + + if (noteMembership[0]) return; + + throw new HTTPException(403, { message: "Forbidden" }); +} diff --git a/server/api/src/services/snapshotService.ts b/server/api/src/services/snapshotService.ts new file mode 100644 index 00000000..f054840c --- /dev/null +++ b/server/api/src/services/snapshotService.ts @@ -0,0 +1,76 @@ +/** + * スナップショット自動保存サービス(API 用) + * Auto-snapshot service for the API server. + * + * ⚠️ hocuspocus 側にも同様のスナップショット作成ロジックがあります: + * - server/hocuspocus/src/snapshotUtils.ts + * 定数やpruning SQLを変更する場合は、必ず両方を同時に更新してください。 + * + * ⚠️ A similar snapshot creation logic exists on the hocuspocus side: + * - server/hocuspocus/src/snapshotUtils.ts + * When changing constants or pruning SQL, always update both files. + */ +import { eq, desc, sql } from "drizzle-orm"; +import { pageSnapshots } from "../schema/index.js"; +import { SNAPSHOT_INTERVAL_MS, MAX_SNAPSHOTS_PER_PAGE } from "../constants.js"; +import type { Database } from "../types/index.js"; + +/** + * 保持上限を超えたスナップショットを削除する SQL(Drizzle raw)。 + * Raw SQL fragment to delete snapshots beyond the retention limit. + * + * API の `maybeCreateSnapshot` と復元トランザクションの両方で共有する。 + * Shared by `maybeCreateSnapshot` and the restore transaction. + */ +export function pruneSnapshotsExceedingLimitSql(pageId: string) { + return sql`DELETE FROM page_snapshots WHERE id IN ( + SELECT id FROM page_snapshots WHERE page_id = ${pageId} + ORDER BY created_at DESC OFFSET ${MAX_SNAPSHOTS_PER_PAGE} + )`; +} + +/** + * 前回スナップショットから10分経過していればスナップショットを自動作成する。 + * Creates an auto-snapshot if 10+ minutes have elapsed since the last one. + * + * API 経由のスナップショットは `created_by` に userId が設定される。 + * API-created snapshots set `created_by` to the userId. + * + * ⚠️ hocuspocus 側にも同様のロジックがあります(server/hocuspocus/src/snapshotUtils.ts)。 + * インターバル判定や pruning SQL を変更する場合は両方を更新してください。 + * ⚠️ A similar logic exists on the hocuspocus side (server/hocuspocus/src/snapshotUtils.ts). + * When changing interval checks or pruning SQL, update both. + */ +export async function maybeCreateSnapshot( + db: Database, + pageId: string, + ydocState: Buffer, + contentText: string | null, + version: number, + userId: string, +): Promise { + const lastSnap = await db + .select({ createdAt: pageSnapshots.createdAt }) + .from(pageSnapshots) + .where(eq(pageSnapshots.pageId, pageId)) + .orderBy(desc(pageSnapshots.createdAt)) + .limit(1); + + const now = Date.now(); + const shouldSnapshot = + !lastSnap[0] || now - lastSnap[0].createdAt.getTime() >= SNAPSHOT_INTERVAL_MS; + + if (!shouldSnapshot) return; + + await db.insert(pageSnapshots).values({ + pageId, + version, + ydocState: ydocState, + contentText: contentText ?? null, + createdBy: userId, + trigger: "auto", + }); + + // 100件超過分を削除 / Prune snapshots exceeding the limit + await db.execute(pruneSnapshotsExceedingLimitSql(pageId)); +} diff --git a/server/hocuspocus/src/index.ts b/server/hocuspocus/src/index.ts index a635ce5c..b7938a16 100644 --- a/server/hocuspocus/src/index.ts +++ b/server/hocuspocus/src/index.ts @@ -10,11 +10,14 @@ import { warnDevAuthBypassOnce, } from "./dev-auth-bypass.js"; import { buildContentPreview, extractTextFromYXml } from "./extractPlainTextFromYXml.js"; +import { maybeCreateSnapshot } from "./snapshotUtils.js"; const PORT = parseInt(process.env.PORT || "1234", 10); const REDIS_URL = process.env.REDIS_URL; const DATABASE_URL = process.env.DATABASE_URL; const API_INTERNAL_URL = process.env.API_INTERNAL_URL; +const INTERNAL_SECRET = process.env.BETTER_AUTH_SECRET?.trim(); + /** Cached env reads for auth paths (avoid repeated `process.env` lookups). / 認証経路用に env を一度だけ読む */ const NODE_ENV = process.env.NODE_ENV; const HOCUSPOCUS_DEV_MODE = process.env.HOCUSPOCUS_DEV_MODE; @@ -52,6 +55,11 @@ function getPool(): Pool { return pgPool; } +function isAuthorizedInternalRequest(req: IncomingMessage): boolean { + if (!INTERNAL_SECRET) return false; + return req.headers["x-internal-secret"] === INTERNAL_SECRET; +} + async function verifySession( token: string, ): Promise<{ userId: string; email?: string; name?: string } | null> { @@ -221,6 +229,7 @@ async function saveDocumentToDb(pageId: string, document: Y.Doc): Promise contentPreview, pageId, ]); + await client.query("COMMIT"); } catch (error) { await client.query("ROLLBACK"); @@ -228,6 +237,17 @@ async function saveDocumentToDb(pageId: string, document: Y.Doc): Promise } finally { client.release(); } + + // 自動スナップショット判定(ベストエフォート: 失敗してもドキュメント保存に影響させない) + // Auto-snapshot check (best-effort: failures do not affect document save) + const snapshotClient = await getPool().connect(); + try { + await maybeCreateSnapshot(snapshotClient, pageId, encodedState, contentText); + } catch (error) { + console.error(`[Snapshot] Failed to create auto-snapshot for page ${pageId}:`, error); + } finally { + snapshotClient.release(); + } } function parseRedisOptions(redisUrl: string): Record { @@ -364,10 +384,57 @@ const hocuspocus = new Hocuspocus({ }, }); +async function invalidateLiveDocument(documentName: string): Promise { + if (!hocuspocus.documents.has(documentName)) { + return false; + } + + // closeConnections(documentName) は documents マップを走査するため、delete より先に呼ぶ。 + // Pass documentName so only that document's WebSocket connections close (not server-wide). + hocuspocus.closeConnections(documentName); + hocuspocus.documents.delete(documentName); + return true; +} + +async function handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise { + const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + + if (req.method === "POST") { + const match = requestUrl.pathname.match(/^\/internal\/documents\/([^/]+)\/invalidate$/); + if (match) { + if (!isAuthorizedInternalRequest(req)) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Unauthorized" })); + return; + } + + const pageId = decodeURIComponent(match[1] ?? ""); + const documentName = `page-${pageId}`; + const invalidated = await invalidateLiveDocument(documentName); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, documentName, invalidated })); + return; + } + } + + await handleHttpRequestFallback(requestUrl, res); +} + // カスタムHTTPサーバー(ヘルスチェック用) const httpServer = createServer((req: IncomingMessage, res: ServerResponse) => { + void handleHttpRequest(req, res).catch((error) => { + console.error("[HTTP] Request handling failed:", error); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Internal Server Error" })); + } + }); +}); + +async function handleHttpRequestFallback(requestUrl: URL, res: ServerResponse): Promise { // ヘルスチェックエンドポイント - if (req.url === "/health" || req.url === "/") { + if (requestUrl.pathname === "/health" || requestUrl.pathname === "/") { res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ @@ -384,7 +451,7 @@ const httpServer = createServer((req: IncomingMessage, res: ServerResponse) => { // その他のリクエストは404 res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not Found" })); -}); +} // WebSocketサーバーをHTTPサーバーにアタッチ const wss = new WebSocketServer({ server: httpServer }); diff --git a/server/hocuspocus/src/snapshotUtils.test.ts b/server/hocuspocus/src/snapshotUtils.test.ts new file mode 100644 index 00000000..b8028a56 --- /dev/null +++ b/server/hocuspocus/src/snapshotUtils.test.ts @@ -0,0 +1,145 @@ +/** + * snapshotUtils のテスト(hocuspocus 用) + * Tests for snapshotUtils (hocuspocus-side auto-snapshot logic) + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + maybeCreateSnapshot, + SNAPSHOT_INTERVAL_MS, + MAX_SNAPSHOTS_PER_PAGE, +} from "./snapshotUtils.js"; +import type { PoolClient } from "pg"; + +const PAGE_ID = "page-aaa-111"; + +function makeEncodedState(): Buffer { + return Buffer.from("fake-ydoc-state"); +} + +/** + * PoolClient のモックを作成する。query の呼び出し順序で結果を返す。 + * Creates a mock PoolClient that returns results in call order. + */ +function createMockClient(queryResults: { rows: unknown[] }[]): { + client: PoolClient; + queryCalls: { text: string; values: unknown[] }[]; +} { + let callIndex = 0; + const queryCalls: { text: string; values: unknown[] }[] = []; + + const client = { + query: vi.fn().mockImplementation((text: string, values?: unknown[]) => { + queryCalls.push({ text, values: values ?? [] }); + const result = queryResults[callIndex] ?? { rows: [] }; + callIndex++; + return Promise.resolve(result); + }), + } as unknown as PoolClient; + + return { client, queryCalls }; +} + +describe("snapshotUtils", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + describe("定数 / Constants", () => { + it("SNAPSHOT_INTERVAL_MS は 10分(600000ms)である", () => { + expect(SNAPSHOT_INTERVAL_MS).toBe(10 * 60 * 1000); + }); + + it("MAX_SNAPSHOTS_PER_PAGE は 100 である", () => { + expect(MAX_SNAPSHOTS_PER_PAGE).toBe(100); + }); + }); + + describe("maybeCreateSnapshot", () => { + it("前回スナップショットがない場合、スナップショットを作成する / creates snapshot when no prior snapshot exists", async () => { + vi.setSystemTime(new Date("2026-04-07T12:00:00Z")); + + const { client, queryCalls } = createMockClient([ + { rows: [] }, // no prior snapshots + { rows: [{ version: "5" }] }, // version query + { rows: [] }, // insert + { rows: [] }, // pruning + ]); + + await maybeCreateSnapshot(client, PAGE_ID, makeEncodedState(), "hello"); + + // 4 queries: select last snap, select version, insert, delete pruning + expect(queryCalls.length).toBe(4); + expect(queryCalls[0]?.text).toContain("page_snapshots"); + expect(queryCalls[2]?.text).toContain("INSERT INTO page_snapshots"); + expect(queryCalls[2]?.values).toContain(PAGE_ID); + expect(queryCalls[3]?.text).toContain("DELETE FROM page_snapshots"); + }); + + it("前回スナップショットから10分経過している場合、スナップショットを作成する / creates snapshot when 10+ minutes elapsed", async () => { + const now = new Date("2026-04-07T12:10:00Z"); + vi.setSystemTime(now); + + const lastCreatedAt = new Date(now.getTime() - SNAPSHOT_INTERVAL_MS); + + const { client, queryCalls } = createMockClient([ + { rows: [{ created_at: lastCreatedAt }] }, + { rows: [{ version: "10" }] }, + { rows: [] }, // insert + { rows: [] }, // pruning + ]); + + await maybeCreateSnapshot(client, PAGE_ID, makeEncodedState(), "content"); + + expect(queryCalls.length).toBe(4); + expect(queryCalls[2]?.text).toContain("INSERT INTO page_snapshots"); + }); + + it("前回スナップショットから10分未満の場合、スナップショットを作成しない / skips when less than 10 minutes elapsed", async () => { + const now = new Date("2026-04-07T12:05:00Z"); + vi.setSystemTime(now); + + const lastCreatedAt = new Date(now.getTime() - (SNAPSHOT_INTERVAL_MS - 1000)); + + const { client, queryCalls } = createMockClient([{ rows: [{ created_at: lastCreatedAt }] }]); + + await maybeCreateSnapshot(client, PAGE_ID, makeEncodedState(), "content"); + + // Only 1 query: the initial snapshot check + expect(queryCalls.length).toBe(1); + }); + + it("version が存在しない場合、デフォルトの version 1 を使用する / uses version 1 when no version row exists", async () => { + vi.setSystemTime(new Date("2026-04-07T12:00:00Z")); + + const { client, queryCalls } = createMockClient([ + { rows: [] }, // no prior snapshots + { rows: [] }, // no version row + { rows: [] }, // insert + { rows: [] }, // pruning + ]); + + await maybeCreateSnapshot(client, PAGE_ID, makeEncodedState(), "text"); + + // insert query should use version = 1 + const insertValues = queryCalls[2]?.values; + expect(insertValues?.[1]).toBe(1); + }); + + it("pruning クエリで MAX_SNAPSHOTS_PER_PAGE を使用する / prune uses MAX_SNAPSHOTS_PER_PAGE", async () => { + vi.setSystemTime(new Date("2026-04-07T12:00:00Z")); + + const { client, queryCalls } = createMockClient([ + { rows: [] }, + { rows: [{ version: "1" }] }, + { rows: [] }, + { rows: [] }, + ]); + + await maybeCreateSnapshot(client, PAGE_ID, makeEncodedState(), "text"); + + const pruneValues = queryCalls[3]?.values; + expect(pruneValues).toContain(MAX_SNAPSHOTS_PER_PAGE); + }); + }); +}); diff --git a/server/hocuspocus/src/snapshotUtils.ts b/server/hocuspocus/src/snapshotUtils.ts new file mode 100644 index 00000000..b494fc05 --- /dev/null +++ b/server/hocuspocus/src/snapshotUtils.ts @@ -0,0 +1,86 @@ +/** + * スナップショット自動保存ユーティリティ(hocuspocus 用) + * Auto-snapshot utility for the hocuspocus server. + * + * ⚠️ API 側にも同様のスナップショット作成ロジックがあります: + * - server/api/src/services/snapshotService.ts + * 定数やpruning SQLを変更する場合は、必ず両方を同時に更新してください。 + * + * ⚠️ A similar snapshot creation logic exists on the API side: + * - server/api/src/services/snapshotService.ts + * When changing constants or pruning SQL, always update both files. + */ +import type { PoolClient } from "pg"; + +/** + * スナップショット取得間隔(ミリ秒)/ Snapshot interval in ms (10 minutes) + * + * ⚠️ server/api/src/constants.ts にも同じ値が定義されています。変更時は両方を更新してください。 + * ⚠️ The same value is defined in server/api/src/constants.ts. Update both when changing. + */ +export const SNAPSHOT_INTERVAL_MS = 10 * 60 * 1000; + +/** + * スナップショット保持上限 / Maximum snapshots per page + * + * ⚠️ server/api/src/constants.ts にも同じ値が定義されています。変更時は両方を更新してください。 + * ⚠️ The same value is defined in server/api/src/constants.ts. Update both when changing. + */ +export const MAX_SNAPSHOTS_PER_PAGE = 100; + +/** + * 前回スナップショットから一定時間経過していればスナップショットを保存する。 + * Takes a snapshot if enough time has elapsed since the last one. + * + * hocuspocus 経由のスナップショットは `created_by` が NULL になる。 + * `created_by IS NULL` は hocuspocus(サーバー)による自動保存を意味する。 + * + * Snapshots created via hocuspocus have `created_by = NULL`. + * `created_by IS NULL` indicates an auto-save by the hocuspocus server. + * + * ⚠️ API 側にも同様のロジックがあります(server/api/src/services/snapshotService.ts)。 + * インターバル判定や pruning SQL を変更する場合は両方を更新してください。 + * ⚠️ A similar logic exists on the API side (server/api/src/services/snapshotService.ts). + * When changing interval checks or pruning SQL, update both. + */ +export async function maybeCreateSnapshot( + client: PoolClient, + pageId: string, + encodedState: Buffer, + contentText: string, +): Promise { + const lastSnap = await client.query<{ created_at: Date }>( + `SELECT created_at FROM page_snapshots + WHERE page_id = $1 ORDER BY created_at DESC LIMIT 1`, + [pageId], + ); + + const now = Date.now(); + const shouldSnapshot = + !lastSnap.rows[0] || + now - new Date(lastSnap.rows[0].created_at).getTime() >= SNAPSHOT_INTERVAL_MS; + + if (!shouldSnapshot) return; + + // 現在の version を取得 + const versionResult = await client.query<{ version: string }>( + `SELECT version FROM page_contents WHERE page_id = $1 LIMIT 1`, + [pageId], + ); + const version = versionResult.rows[0] ? Number(versionResult.rows[0].version) : 1; + + await client.query( + `INSERT INTO page_snapshots (page_id, version, ydoc_state, content_text, trigger, created_at) + VALUES ($1, $2, $3, $4, 'auto', NOW())`, + [pageId, version, encodedState, contentText], + ); + + // 100件超過分を削除 / Prune snapshots exceeding the limit + await client.query( + `DELETE FROM page_snapshots WHERE id IN ( + SELECT id FROM page_snapshots WHERE page_id = $1 + ORDER BY created_at DESC OFFSET $2 + )`, + [pageId, MAX_SNAPSHOTS_PER_PAGE], + ); +} diff --git a/src/components/editor/PageEditor/PageEditorHeader.test.tsx b/src/components/editor/PageEditor/PageEditorHeader.test.tsx index ff0aeff0..9c8f80dc 100644 --- a/src/components/editor/PageEditor/PageEditorHeader.test.tsx +++ b/src/components/editor/PageEditor/PageEditorHeader.test.tsx @@ -132,6 +132,31 @@ describe("PageEditorHeader", () => { expect(onDelete).toHaveBeenCalledTimes(1); }); + it("onOpenHistory を渡すとドロップダウンに変更履歴メニューが表示される", async () => { + const user = userEvent.setup(); + const onOpenHistory = vi.fn(); + renderHeader({ onOpenHistory }); + const buttons = screen.getAllByRole("button"); + await user.click(buttons[buttons.length - 1]); + const historyItem = await screen.findByRole("menuitem", { name: /変更履歴|pageHistory/ }); + await user.click(historyItem); + expect(onOpenHistory).toHaveBeenCalledTimes(1); + }); + + it("onOpenHistory を渡さないとき変更履歴メニューは表示されない", async () => { + const user = userEvent.setup(); + renderHeader(); + const buttons = screen.getAllByRole("button"); + await user.click(buttons[buttons.length - 1]); + // 変更履歴メニューが存在しないことを確認 + const historyItems = screen + .queryAllByRole("menuitem") + .filter( + (el) => el.textContent?.includes("変更履歴") || el.textContent?.includes("pageHistory"), + ); + expect(historyItems).toHaveLength(0); + }); + it("collaboration ありで ConnectionIndicator の onReconnect をクリックすると onReconnect が呼ばれる", async () => { const user = userEvent.setup(); const onReconnect = vi.fn(); diff --git a/src/components/editor/PageEditor/PageEditorHeader.tsx b/src/components/editor/PageEditor/PageEditorHeader.tsx index cdaeef91..78522fac 100644 --- a/src/components/editor/PageEditor/PageEditorHeader.tsx +++ b/src/components/editor/PageEditor/PageEditorHeader.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { ArrowLeft, Trash2, MoreHorizontal, Download, Copy } from "lucide-react"; +import { ArrowLeft, Trash2, MoreHorizontal, Download, Copy, History } from "lucide-react"; import { Button } from "@zedi/ui"; import { DropdownMenu, @@ -11,6 +11,7 @@ import { import Container from "@/components/layout/Container"; import { HeaderSearchBar } from "@/components/layout/Header/HeaderSearchBar"; import { useGlobalSearchContextOptional } from "@/contexts/GlobalSearchContext"; +import { useTranslation } from "react-i18next"; import { formatTimeAgo } from "@/lib/dateUtils"; import { ConnectionIndicator } from "../ConnectionIndicator"; import { UserAvatars } from "../UserAvatars"; @@ -24,6 +25,8 @@ interface PageEditorHeaderProps { onDelete: () => void; onExportMarkdown: () => void; onCopyMarkdown: () => void; + /** 変更履歴モーダルを開く / Open version history modal */ + onOpenHistory?: () => void; /** リアルタイムコラボレーション状態(有効時のみ渡す) */ collaboration?: { status: ConnectionStatus; @@ -43,8 +46,10 @@ export const PageEditorHeader: React.FC = ({ onDelete, onExportMarkdown, onCopyMarkdown, + onOpenHistory, collaboration, }) => { + const { t } = useTranslation(); const searchContext = useGlobalSearchContextOptional(); const hasSearchContext = searchContext != null; @@ -91,6 +96,12 @@ export const PageEditorHeader: React.FC = ({ + {onOpenHistory && ( + + + {t("editor.pageHistory.menuButton")} + + )} Markdownでエクスポート diff --git a/src/components/editor/PageEditor/PageEditorLayout.test.tsx b/src/components/editor/PageEditor/PageEditorLayout.test.tsx new file mode 100644 index 00000000..8976f985 --- /dev/null +++ b/src/components/editor/PageEditor/PageEditorLayout.test.tsx @@ -0,0 +1,161 @@ +/** + * PageEditorLayout コンポーネントのテスト(履歴モーダル関連) + * Tests for PageEditorLayout (history modal integration) + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { PageEditorLayout } from "./PageEditorLayout"; +import type { PageEditorLayoutProps } from "./PageEditorLayout"; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +vi.mock("./PageEditorHeader", () => ({ + PageEditorHeader: ({ onOpenHistory }: { onOpenHistory?: () => void }) => ( +
+ {onOpenHistory && ( + + )} +
+ ), +})); + +vi.mock("./PageEditorAlerts", () => ({ + PageEditorAlerts: () =>
, +})); + +vi.mock("./PageEditorContent", () => ({ + PageEditorContent: () =>
, +})); + +vi.mock("./PageEditorDialogs", () => ({ + PageEditorDialogs: () =>
, +})); + +vi.mock("../../ai-chat/ContentWithAIChat", () => ({ + ContentWithAIChat: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("../pageHistory/PageHistoryModal", () => ({ + PageHistoryModal: ({ + open, + onRestored, + onOpenChange, + }: { + open: boolean; + currentYdoc: unknown; + onRestored?: () => void; + onOpenChange: (open: boolean) => void; + }) => + open ? ( +
+ + +
+ ) : null, +})); + +const defaultProps: PageEditorLayoutProps = { + title: "Test Page", + content: "", + sourceUrl: undefined, + currentPageId: "page-1", + pageId: "page-1", + isNewPage: false, + displayLastSaved: null, + wikiStatus: "idle", + isWikiGenerating: false, + isSyncingLinks: false, + isLocalDocEnabled: false, + collaboration: undefined, + duplicatePage: null, + errorMessage: null, + contentError: null, + pendingInitialContent: null, + onBack: vi.fn(), + onDelete: vi.fn(), + onExportMarkdown: vi.fn(), + onCopyMarkdown: vi.fn(), + onGenerateWiki: vi.fn(), + onOpenDuplicatePage: vi.fn(), + onCancelWiki: vi.fn(), + onContentChange: vi.fn(), + onContentError: vi.fn(), + onTitleChange: vi.fn(), + onPendingInitialContentClear: vi.fn(), + deleteConfirmOpen: false, + deleteReason: "", + onDeleteConfirmOpenChange: vi.fn(), + onConfirmDelete: vi.fn(), + onCancelDelete: vi.fn(), + wikiErrorMessage: null, + onResetWiki: vi.fn(), + onGoToAISettings: vi.fn(), + wikiContentForCollab: null, + onWikiContentApplied: vi.fn(), +}; + +describe("PageEditorLayout", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("主要な子コンポーネントをレンダリングする / renders main child components", () => { + render(); + + expect(screen.getByTestId("editor-header")).toBeInTheDocument(); + expect(screen.getByTestId("editor-alerts")).toBeInTheDocument(); + expect(screen.getByTestId("editor-content")).toBeInTheDocument(); + expect(screen.getByTestId("editor-dialogs")).toBeInTheDocument(); + }); + + it("初期状態では履歴モーダルが表示されない / history modal is hidden by default", () => { + render(); + + expect(screen.queryByTestId("history-modal")).not.toBeInTheDocument(); + }); + + it("履歴ボタンをクリックすると履歴モーダルが表示される / shows history modal after clicking open history", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("open-history-btn")); + + expect(screen.getByTestId("history-modal")).toBeInTheDocument(); + }); + + it("モーダルを閉じると非表示になる / hides modal on close", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("open-history-btn")); + expect(screen.getByTestId("history-modal")).toBeInTheDocument(); + + await user.click(screen.getByTestId("close-modal-btn")); + expect(screen.queryByTestId("history-modal")).not.toBeInTheDocument(); + }); + + it("復元後に window.location.reload が呼ばれる / calls reload on restore", async () => { + const user = userEvent.setup(); + const reloadMock = vi.fn(); + Object.defineProperty(window, "location", { + value: { ...window.location, reload: reloadMock }, + writable: true, + }); + + render(); + + await user.click(screen.getByTestId("open-history-btn")); + await user.click(screen.getByTestId("restore-btn")); + + expect(reloadMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/editor/PageEditor/PageEditorLayout.tsx b/src/components/editor/PageEditor/PageEditorLayout.tsx index db3b8ebe..67e9affe 100644 --- a/src/components/editor/PageEditor/PageEditorLayout.tsx +++ b/src/components/editor/PageEditor/PageEditorLayout.tsx @@ -1,9 +1,10 @@ -import React from "react"; +import React, { useState, useCallback } from "react"; import { PageEditorHeader } from "./PageEditorHeader"; import { PageEditorAlerts } from "./PageEditorAlerts"; import { PageEditorContent } from "./PageEditorContent"; import { PageEditorDialogs } from "./PageEditorDialogs"; import { ContentWithAIChat } from "../../ai-chat/ContentWithAIChat"; +import { PageHistoryModal } from "../pageHistory/PageHistoryModal"; import type { ContentError } from "../TiptapEditor/useContentSanitizer"; import type { Page } from "@/types/page"; import type { UseCollaborationReturn } from "@/lib/collaboration/types"; @@ -99,6 +100,22 @@ export const PageEditorLayout: React.FC = (props) => { onWikiContentApplied, } = props; + const [historyOpen, setHistoryOpen] = useState(false); + + const handleOpenHistory = useCallback(() => { + setHistoryOpen(true); + }, []); + + const handleRestored = useCallback(() => { + // 復元後にページをリロードして最新状態を反映する + // Reload the page after restore to reflect the latest state + window.location.reload(); + }, []); + + // React Compiler が optional chain の依存を保持できないため先に抽出する + // Extract ydoc to avoid React Compiler memoization issue with optional chaining + const ydoc = collaboration?.ydoc ?? null; + return (
= (props) => { onDelete={onDelete} onExportMarkdown={onExportMarkdown} onCopyMarkdown={onCopyMarkdown} + onOpenHistory={handleOpenHistory} collaboration={undefined} /> @@ -158,6 +176,16 @@ export const PageEditorLayout: React.FC = (props) => { onResetWiki={onResetWiki} onGoToAISettings={onGoToAISettings} /> + + {historyOpen && ( + + )}
); }; diff --git a/src/components/editor/TiptapEditor/editorConfig.ts b/src/components/editor/TiptapEditor/editorConfig.ts index dfc2bd68..22a6df24 100644 --- a/src/components/editor/TiptapEditor/editorConfig.ts +++ b/src/components/editor/TiptapEditor/editorConfig.ts @@ -115,10 +115,20 @@ export interface EditorExtensionsOptions { }; } -/** - * Create the array of Tiptap extensions for the editor - */ -export function createEditorExtensions(options: EditorExtensionsOptions): Extension[] { +interface CommonEditorExtensionsOptions { + placeholder?: string; + onLinkClick: (title: string) => void; + onStateChange?: (state: WikiLinkSuggestionState) => void; + onSlashStateChange?: (state: SlashSuggestionState) => void; + imageUploadOptions?: Partial; + imageOptions?: Partial; + fileReference?: EditorExtensionsOptions["fileReference"]; + includePlaceholder?: boolean; + includeInteractionPlugins?: boolean; + collaboration?: CollaborationExtensionsOptions; +} + +function createCommonEditorExtensions(options: CommonEditorExtensionsOptions): Extension[] { const useCollaboration = Boolean(options.collaboration); return [ @@ -140,10 +150,14 @@ export function createEditorExtensions(options: EditorExtensionsOptions): Extens }), // Typography for smart quotes and dashes Typography, - Placeholder.configure({ - placeholder: options.placeholder, - emptyEditorClass: "is-editor-empty", - }), + ...(options.includePlaceholder + ? [ + Placeholder.configure({ + placeholder: options.placeholder ?? "", + emptyEditorClass: "is-editor-empty", + }), + ] + : []), Link.configure({ openOnClick: true, HTMLAttributes: { @@ -202,13 +216,17 @@ export function createEditorExtensions(options: EditorExtensionsOptions): Extens getWorkspaceRoot: options.fileReference?.getWorkspaceRoot ?? (() => null), getNoteId: options.fileReference?.getNoteId ?? (() => null), }), - WikiLinkSuggestionPlugin.configure({ - onStateChange: options.onStateChange, - }), - // --- Phase 0: Slash Command --- - SlashSuggestionPlugin.configure({ - onStateChange: options.onSlashStateChange, - }), + ...(options.includeInteractionPlugins + ? [ + WikiLinkSuggestionPlugin.configure({ + onStateChange: options.onStateChange ?? (() => undefined), + }), + // --- Phase 0: Slash Command --- + SlashSuggestionPlugin.configure({ + onStateChange: options.onSlashStateChange ?? (() => undefined), + }), + ] + : []), // --- Image --- ImageUpload.configure({ HTMLAttributes: {}, @@ -247,6 +265,42 @@ export function createEditorExtensions(options: EditorExtensionsOptions): Extens ] as Extension[]; } +/** + * メインエディタ用の Tiptap 拡張配列を生成する(プレースホルダー、スラッシュ、コラボ等を含む)。 + * Creates the full Tiptap extension list for the main editor (placeholder, slash, collaboration, etc.). + * + * @param options - 拡張のオプション(リンク・画像・コラボ設定など) / Extension options (links, images, collaboration, …) + * @returns Tiptap の `Extension[]` / Array of Tiptap extensions + */ +export function createEditorExtensions(options: EditorExtensionsOptions): Extension[] { + return createCommonEditorExtensions({ + placeholder: options.placeholder, + onLinkClick: options.onLinkClick, + onStateChange: options.onStateChange, + onSlashStateChange: options.onSlashStateChange, + imageUploadOptions: options.imageUploadOptions, + imageOptions: options.imageOptions, + fileReference: options.fileReference, + includePlaceholder: true, + includeInteractionPlugins: true, + collaboration: options.collaboration, + }); +} + +/** + * スナップショットプレビュー用の拡張セットを返す。 + * Returns the shared extension set used by snapshot preview editors. + */ +export function createSnapshotPreviewExtensions(): Extension[] { + return createCommonEditorExtensions({ + onLinkClick: () => undefined, + imageUploadOptions: {}, + imageOptions: {}, + includePlaceholder: false, + includeInteractionPlugins: false, + }); +} + /** * Default editor props for Tiptap (ProseMirror root `editorProps.attributes`). * エディタルートに付与する属性。コードブロックのスペルチェックは NodeView 側で制御する。 diff --git a/src/components/editor/pageHistory/PageHistoryModal.test.tsx b/src/components/editor/pageHistory/PageHistoryModal.test.tsx new file mode 100644 index 00000000..7ab4c32b --- /dev/null +++ b/src/components/editor/pageHistory/PageHistoryModal.test.tsx @@ -0,0 +1,186 @@ +/** + * PageHistoryModal コンポーネントのテスト + * Tests for the PageHistoryModal component + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { PageHistoryModal } from "./PageHistoryModal"; +import type { PageSnapshot } from "@/types/pageSnapshot"; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +const mockMutateAsync = vi.fn(); +const mockSnapshots: PageSnapshot[] = [ + { + id: "snap-1", + version: 3, + contentText: "version 3 text", + createdBy: "user-1", + createdByEmail: "user@example.com", + trigger: "auto", + createdAt: "2026-04-07T12:00:00Z", + }, + { + id: "snap-2", + version: 2, + contentText: "version 2 text", + createdBy: null, + createdByEmail: null, + trigger: "auto", + createdAt: "2026-04-07T11:00:00Z", + }, +]; + +vi.mock("@/hooks/usePageSnapshotQueries", () => ({ + usePageSnapshots: () => ({ data: mockSnapshots, isLoading: false }), + usePageSnapshot: (_pageId: string, snapshotId: string | null) => ({ + data: snapshotId + ? { + id: snapshotId, + version: 3, + ydocState: "base64state", + contentText: "detail text", + createdBy: "user-1", + createdByEmail: "user@example.com", + trigger: "auto" as const, + createdAt: "2026-04-07T12:00:00Z", + } + : undefined, + isLoading: false, + }), + useRestorePageSnapshot: () => ({ + mutateAsync: mockMutateAsync, + isPending: false, + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const map: Record = { + "editor.pageHistory.title": "変更履歴", + "editor.pageHistory.description": "過去のバージョンを確認", + "editor.pageHistory.selectSnapshot": "スナップショットを選択", + "editor.pageHistory.preview": "プレビュー", + "editor.pageHistory.compare": "比較", + "editor.pageHistory.restoreButton": "復元", + "editor.pageHistory.restoreConfirmTitle": "復元の確認", + "editor.pageHistory.restoreConfirmDescription": "この操作は元に戻せません", + "editor.pageHistory.restoreConfirmCancel": "キャンセル", + "editor.pageHistory.restoreConfirmAction": "復元する", + "editor.pageHistory.restoreSuccess": "復元しました", + "editor.pageHistory.restoreError": "復元に失敗しました", + "editor.pageHistory.restoring": "復元中...", + }; + return map[key] ?? key; + }, + }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("./SnapshotList", () => ({ + SnapshotList: ({ + snapshots, + onSelect, + }: { + snapshots: PageSnapshot[]; + selectedId: string | null; + onSelect: (snap: PageSnapshot) => void; + }) => ( +
+ {snapshots.map((s) => ( + + ))} +
+ ), +})); + +vi.mock("./SnapshotPreview", () => ({ + SnapshotPreview: ({ ydocState }: { ydocState: string }) => ( +
{ydocState}
+ ), +})); + +vi.mock("./SnapshotCompare", () => ({ + SnapshotCompare: () =>
compare view
, +})); + +const defaultProps = { + open: true, + onOpenChange: vi.fn(), + pageId: "page-123", + currentYdoc: null, + onRestored: vi.fn(), +}; + +describe("PageHistoryModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("モーダルが開いているとき、タイトルと説明を表示する / shows title and description when open", () => { + render(); + + expect(screen.getByText("変更履歴")).toBeInTheDocument(); + expect(screen.getByText("過去のバージョンを確認")).toBeInTheDocument(); + }); + + it("スナップショット一覧を表示する / renders snapshot list", () => { + render(); + + expect(screen.getByTestId("snapshot-list")).toBeInTheDocument(); + expect(screen.getByTestId("snap-item-snap-1")).toBeInTheDocument(); + expect(screen.getByTestId("snap-item-snap-2")).toBeInTheDocument(); + }); + + it("未選択時に選択メッセージを表示する / shows select message when no snapshot selected", () => { + render(); + + expect(screen.getByText("スナップショットを選択")).toBeInTheDocument(); + }); + + it("スナップショットを選択するとプレビュータブが表示される / shows preview tab after selecting a snapshot", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("snap-item-snap-1")); + + expect(screen.getByText("プレビュー")).toBeInTheDocument(); + expect(screen.getByText("比較")).toBeInTheDocument(); + expect(screen.getByText("復元")).toBeInTheDocument(); + }); + + it("復元ボタンをクリックすると確認ダイアログが表示される / shows confirmation dialog on restore click", async () => { + const user = userEvent.setup(); + render(); + + // まずスナップショットを選択 + await user.click(screen.getByTestId("snap-item-snap-1")); + + // 復元ボタンをクリック + await user.click(screen.getByText("復元")); + + // 確認ダイアログが表示される + expect(screen.getByText("復元の確認")).toBeInTheDocument(); + expect(screen.getByText("この操作は元に戻せません")).toBeInTheDocument(); + }); + + it("確認ダイアログで復元を実行すると mutateAsync が呼ばれる / calls mutateAsync on confirm", async () => { + const user = userEvent.setup(); + mockMutateAsync.mockResolvedValueOnce({ version: 4, snapshot_id: "snap-new" }); + + render(); + + await user.click(screen.getByTestId("snap-item-snap-1")); + await user.click(screen.getByText("復元")); + await user.click(screen.getByText("復元する")); + + expect(mockMutateAsync).toHaveBeenCalledWith("snap-1"); + }); +}); diff --git a/src/components/editor/pageHistory/PageHistoryModal.tsx b/src/components/editor/pageHistory/PageHistoryModal.tsx new file mode 100644 index 00000000..2ef6ea8b --- /dev/null +++ b/src/components/editor/pageHistory/PageHistoryModal.tsx @@ -0,0 +1,249 @@ +/** + * ページ変更履歴モーダル + * Page version history modal + */ +import React, { useState, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import * as Y from "yjs"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + Button, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Tabs, + TabsList, + TabsTrigger, + TabsContent, + Skeleton, +} from "@zedi/ui"; +import { + usePageSnapshots, + usePageSnapshot, + useRestorePageSnapshot, +} from "@/hooks/usePageSnapshotQueries"; +import { SnapshotList } from "./SnapshotList"; +import { SnapshotPreview } from "./SnapshotPreview"; +import { SnapshotCompare } from "./SnapshotCompare"; +import type { PageSnapshot } from "@/types/pageSnapshot"; + +interface PageHistoryModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + pageId: string; + /** 現在の編集用 Y.Doc(比較タブ選択時のみ base64 化する) */ + currentYdoc: Y.Doc | null; + /** 復元後にエディタをリロードするコールバック */ + onRestored?: () => void; +} + +/** + * 比較表示用に Y.Doc を base64 でエンコードする(重い処理のため Compare タブ時のみ実行)。 + * Encode Y.Doc to base64 for compare — only when Compare tab needs it. + */ +function encodeYdocStateToBase64(ydoc: Y.Doc): string { + try { + const state = Y.encodeStateAsUpdate(ydoc); + const chunks: string[] = []; + for (let i = 0; i < state.length; i += 8192) { + chunks.push(String.fromCharCode.apply(null, [...state.subarray(i, i + 8192)])); + } + return btoa(chunks.join("")); + } catch { + return ""; + } +} + +/** + * + */ +export /** + * + */ +const PageHistoryModal: React.FC = ({ + open, + onOpenChange, + pageId, + currentYdoc, + onRestored, +}) => { + /** + * + */ + const { t } = useTranslation(); + /** + * + */ + const [selectedSnapshot, setSelectedSnapshot] = useState(null); + /** + * + */ + const [tab, setTab] = useState("preview"); + /** + * + */ + const [confirmOpen, setConfirmOpen] = useState(false); + + /** + * + */ + const { data: snapshots, isLoading: isLoadingList } = usePageSnapshots(pageId); + /** + * + */ + const { data: snapshotDetail, isLoading: isLoadingDetail } = usePageSnapshot( + pageId, + selectedSnapshot?.id ?? null, + ); + /** + * + */ + const restoreMutation = useRestorePageSnapshot(pageId); + + /** 比較タブがアクティブなときだけ現在ドキュメントをエンコード(協調編集中の負荷を抑える) */ + const currentYdocState = useMemo((): string => { + if (!open || tab !== "compare" || !currentYdoc) return ""; + return encodeYdocStateToBase64(currentYdoc); + }, [open, tab, currentYdoc]); + + /** + * + */ + const handleSelect = useCallback((snap: PageSnapshot) => { + setSelectedSnapshot(snap); + }, []); + + /** + * + */ + const handleRestore = useCallback(async () => { + if (!selectedSnapshot) return; + try { + await restoreMutation.mutateAsync(selectedSnapshot.id); + toast.success(t("editor.pageHistory.restoreSuccess")); + setConfirmOpen(false); + onOpenChange(false); + onRestored?.(); + } catch { + toast.error(t("editor.pageHistory.restoreError")); + } + }, [selectedSnapshot, restoreMutation, t, onOpenChange, onRestored]); + + return ( + <> + + + + {t("editor.pageHistory.title")} + {t("editor.pageHistory.description")} + + +
+
+ {isLoadingList ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} +
+ +
+ {!selectedSnapshot ? ( +
+

+ {t("editor.pageHistory.selectSnapshot")} +

+
+ ) : ( + +
+ + {t("editor.pageHistory.preview")} + {t("editor.pageHistory.compare")} + + + +
+ + + {isLoadingDetail ? ( +
+ + + +
+ ) : snapshotDetail ? ( + + ) : null} +
+ + + {isLoadingDetail ? ( +
+ + +
+ ) : snapshotDetail ? ( + + ) : null} +
+
+ )} +
+
+
+
+ + + + + {t("editor.pageHistory.restoreConfirmTitle")} + + {t("editor.pageHistory.restoreConfirmDescription")} + + + + + {t("editor.pageHistory.restoreConfirmCancel")} + + + {restoreMutation.isPending + ? t("editor.pageHistory.restoring") + : t("editor.pageHistory.restoreConfirmAction")} + + + + + + ); +}; diff --git a/src/components/editor/pageHistory/SnapshotCompare.test.tsx b/src/components/editor/pageHistory/SnapshotCompare.test.tsx new file mode 100644 index 00000000..3468b8e5 --- /dev/null +++ b/src/components/editor/pageHistory/SnapshotCompare.test.tsx @@ -0,0 +1,50 @@ +/** + * SnapshotCompare コンポーネントのテスト + * Tests for the SnapshotCompare component + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SnapshotCompare } from "./SnapshotCompare"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const map: Record = { + "editor.pageHistory.selectedVersion": "選択バージョン", + "editor.pageHistory.currentVersion": "現在のバージョン", + }; + return map[key] ?? key; + }, + }), +})); + +vi.mock("./SnapshotPreview", () => ({ + SnapshotPreview: ({ ydocState }: { ydocState: string }) => ( +
{`preview:${ydocState.slice(0, 10)}`}
+ ), +})); + +describe("SnapshotCompare", () => { + it("選択バージョンと現在バージョンの2つのプレビューを表示する / renders two side-by-side previews", () => { + render( + , + ); + + expect(screen.getByText("選択バージョン")).toBeInTheDocument(); + expect(screen.getByText("現在のバージョン")).toBeInTheDocument(); + + const previews = screen.getAllByTestId("snapshot-preview"); + expect(previews).toHaveLength(2); + }); + + it("SnapshotPreview に正しい ydocState が渡される / passes correct ydocState to each preview", () => { + render(); + + const previews = screen.getAllByTestId("snapshot-preview"); + expect(previews[0]?.textContent).toContain("AAAA"); + expect(previews[1]?.textContent).toContain("BBBB"); + }); +}); diff --git a/src/components/editor/pageHistory/SnapshotCompare.tsx b/src/components/editor/pageHistory/SnapshotCompare.tsx new file mode 100644 index 00000000..986fce68 --- /dev/null +++ b/src/components/editor/pageHistory/SnapshotCompare.tsx @@ -0,0 +1,55 @@ +/** + * スナップショット並列比較ビュー(side-by-side) + * Side-by-side comparison of selected snapshot vs current content + */ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { SnapshotPreview } from "./SnapshotPreview"; + +interface SnapshotCompareProps { + /** 選択したスナップショットの Y.Doc state (base64) */ + selectedYdocState: string; + /** 現在のページの Y.Doc state (base64) */ + currentYdocState: string; +} + +/** + * + */ +export /** + * + */ +const SnapshotCompare: React.FC = ({ + selectedYdocState, + currentYdocState, +}) => { + /** + * + */ + const { t } = useTranslation(); + + return ( +
+
+
+ + {t("editor.pageHistory.selectedVersion")} + +
+
+ +
+
+
+
+ + {t("editor.pageHistory.currentVersion")} + +
+
+ +
+
+
+ ); +}; diff --git a/src/components/editor/pageHistory/SnapshotList.test.tsx b/src/components/editor/pageHistory/SnapshotList.test.tsx new file mode 100644 index 00000000..2ce4c591 --- /dev/null +++ b/src/components/editor/pageHistory/SnapshotList.test.tsx @@ -0,0 +1,120 @@ +/** + * SnapshotList コンポーネントのテスト + * Tests for the SnapshotList component + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SnapshotList } from "./SnapshotList"; +import type { PageSnapshot } from "@/types/pageSnapshot"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + const map: Record = { + "editor.pageHistory.noSnapshots": "スナップショットなし", + "editor.pageHistory.noSnapshotsDescription": "まだ履歴がありません", + "editor.pageHistory.auto": "自動", + "editor.pageHistory.restore": "復元", + }; + if (key === "editor.pageHistory.version" && params?.version != null) { + return `v${params.version}`; + } + return map[key] ?? key; + }, + }), +})); + +vi.mock("@/lib/dateUtils", () => ({ + formatTimeAgo: (ts: number) => `formatted:${ts}`, +})); + +vi.mock("@zedi/ui", () => ({ + ScrollArea: ({ children, ...props }: React.PropsWithChildren>) => ( +
+ {children} +
+ ), + Badge: ({ children, ...props }: React.PropsWithChildren>) => ( + + {children} + + ), +})); + +function createSnapshot(overrides: Partial = {}): PageSnapshot { + return { + id: "snap-1", + version: 1, + contentText: "test content", + createdBy: "user-1", + createdByEmail: "user@example.com", + trigger: "auto", + createdAt: "2026-04-07T12:00:00Z", + ...overrides, + }; +} + +describe("SnapshotList", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("スナップショットがない場合、空メッセージを表示する / shows empty message when no snapshots", () => { + render(); + + expect(screen.getByText("スナップショットなし")).toBeInTheDocument(); + expect(screen.getByText("まだ履歴がありません")).toBeInTheDocument(); + }); + + it("スナップショット一覧を表示する / renders snapshot items", () => { + const snapshots = [ + createSnapshot({ id: "s1", version: 3 }), + createSnapshot({ id: "s2", version: 2, trigger: "restore", createdByEmail: null }), + ]; + + render(); + + expect(screen.getByText("v3")).toBeInTheDocument(); + expect(screen.getByText("v2")).toBeInTheDocument(); + }); + + it("選択中のスナップショットにはスタイルが適用される / selected item has primary border", () => { + const snapshots = [createSnapshot({ id: "s1" })]; + + const { container } = render( + , + ); + + const button = container.querySelector("button"); + expect(button?.className).toContain("border-primary"); + }); + + it("クリックすると onSelect が呼ばれる / calls onSelect on click", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + const snap = createSnapshot({ id: "s1", version: 5 }); + + render(); + + await user.click(screen.getByText("v5")); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(snap); + }); + + it("createdByEmail がある場合に表示する / shows email when available", () => { + const snap = createSnapshot({ createdByEmail: "test@example.com" }); + + render(); + + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + it("createdByEmail がない場合は表示しない / hides email when null", () => { + const snap = createSnapshot({ createdByEmail: null }); + + render(); + + expect(screen.queryByText("·")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/editor/pageHistory/SnapshotList.tsx b/src/components/editor/pageHistory/SnapshotList.tsx new file mode 100644 index 00000000..7880aaab --- /dev/null +++ b/src/components/editor/pageHistory/SnapshotList.tsx @@ -0,0 +1,92 @@ +/** + * スナップショット一覧(左パネル) + * Snapshot list panel (left side of the history modal) + */ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ScrollArea, Badge } from "@zedi/ui"; +import { formatTimeAgo } from "@/lib/dateUtils"; +import type { PageSnapshot } from "@/types/pageSnapshot"; + +interface SnapshotListProps { + snapshots: PageSnapshot[]; + selectedId: string | null; + onSelect: (snapshot: PageSnapshot) => void; +} + +/** + * + */ +export /** + * + */ +const SnapshotList: React.FC = ({ snapshots, selectedId, onSelect }) => { + /** + * + */ + const { t } = useTranslation(); + + if (snapshots.length === 0) { + return ( +
+

+ {t("editor.pageHistory.noSnapshots")} +

+

+ {t("editor.pageHistory.noSnapshotsDescription")} +

+
+ ); + } + + return ( + +
+ {snapshots.map((snap) => { + /** + * + */ + const isSelected = snap.id === selectedId; + /** + * + */ + const date = new Date(snap.createdAt); + + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/components/editor/pageHistory/SnapshotPreview.test.tsx b/src/components/editor/pageHistory/SnapshotPreview.test.tsx new file mode 100644 index 00000000..82834bd4 --- /dev/null +++ b/src/components/editor/pageHistory/SnapshotPreview.test.tsx @@ -0,0 +1,72 @@ +/** + * SnapshotPreview コンポーネントのテスト + * Tests for the SnapshotPreview component + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import * as Y from "yjs"; +import { SnapshotPreview } from "./SnapshotPreview"; + +// TipTap の useEditor は jsdom では完全に動作しないためモック +const mockSetContent = vi.fn(); +vi.mock("@tiptap/react", () => ({ + useEditor: () => ({ + commands: { setContent: mockSetContent }, + destroy: vi.fn(), + }), + EditorContent: ({ editor }: { editor: unknown }) => ( +
{editor ? "editor-loaded" : "no-editor"}
+ ), +})); + +vi.mock("../TiptapEditor/editorConfig", () => ({ + createSnapshotPreviewExtensions: () => [], +})); +vi.mock("@/lib/ydoc/yDocToTiptapJson", () => ({ + yXmlFragmentToTiptapJson: () => ({ type: "doc", content: [{ type: "paragraph" }] }), +})); + +/** + * 有効な Y.Doc state の base64 文字列を生成する + * Generate a valid Y.Doc state as base64 string + */ +function createValidYdocBase64(): string { + const doc = new Y.Doc(); + doc.transact(() => { + const fragment = doc.getXmlFragment("default"); + const p = new Y.XmlElement("paragraph"); + const t = new Y.XmlText(); + t.insert(0, "Hello"); + p.push([t]); + fragment.push([p]); + }); + const state = Y.encodeStateAsUpdate(doc); + return btoa(String.fromCharCode(...state)); +} + +describe("SnapshotPreview", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("有効な ydocState でエディタをレンダリングする / renders editor with valid ydocState", () => { + const base64 = createValidYdocBase64(); + render(); + + expect(screen.getByTestId("editor-content")).toBeInTheDocument(); + expect(screen.getByText("editor-loaded")).toBeInTheDocument(); + }); + + it("className が渡される / passes className to wrapper", () => { + const base64 = createValidYdocBase64(); + const { container } = render(); + + expect(container.firstChild).toHaveClass("custom-class"); + }); + + it("不正な ydocState でもクラッシュしない / does not crash with invalid ydocState", () => { + render(); + + expect(screen.getByTestId("editor-content")).toBeInTheDocument(); + }); +}); diff --git a/src/components/editor/pageHistory/SnapshotPreview.tsx b/src/components/editor/pageHistory/SnapshotPreview.tsx new file mode 100644 index 00000000..614cbfa7 --- /dev/null +++ b/src/components/editor/pageHistory/SnapshotPreview.tsx @@ -0,0 +1,52 @@ +/** + * スナップショットプレビュー(読み取り専用エディタ) + * Read-only TipTap editor for previewing a snapshot + */ +import React, { useEffect, useMemo } from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +import * as Y from "yjs"; +import { yXmlFragmentToTiptapJson } from "@/lib/ydoc/yDocToTiptapJson"; +import { createSnapshotPreviewExtensions } from "../TiptapEditor/editorConfig"; + +interface SnapshotPreviewProps { + /** base64-encoded Y.Doc state */ + ydocState: string; + className?: string; +} + +/** + * Y.Doc バイナリから TipTap JSON を復元し、読み取り専用エディタで表示する。 + * Restores TipTap JSON from Y.Doc binary and renders in a read-only editor. + */ +export const SnapshotPreview: React.FC = ({ ydocState, className }) => { + const content = useMemo(() => { + try { + const doc = new Y.Doc(); + const binary = Uint8Array.from(atob(ydocState), (c) => c.charCodeAt(0)); + Y.applyUpdate(doc, binary); + + const xmlFragment = doc.getXmlFragment("default"); + return yXmlFragmentToTiptapJson(xmlFragment); + } catch { + return null; + } + }, [ydocState]); + + const editor = useEditor({ + extensions: createSnapshotPreviewExtensions(), + editable: false, + content: content ?? undefined, + }); + + useEffect(() => { + if (editor && content) { + editor.commands.setContent(content); + } + }, [editor, content]); + + return ( +
+ +
+ ); +}; diff --git a/src/hooks/usePageSnapshotQueries.test.ts b/src/hooks/usePageSnapshotQueries.test.ts new file mode 100644 index 00000000..bbdd1aff --- /dev/null +++ b/src/hooks/usePageSnapshotQueries.test.ts @@ -0,0 +1,176 @@ +/** + * usePageSnapshotQueries のテスト + * Tests for page snapshot React Query hooks + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { snapshotKeys } from "./usePageSnapshotQueries"; + +// React Query wrapper +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +describe("snapshotKeys", () => { + it("all キーが正しい / all key is correct", () => { + expect(snapshotKeys.all).toEqual(["pageSnapshots"]); + }); + + it("lists キーが正しい / lists key is correct", () => { + expect(snapshotKeys.lists()).toEqual(["pageSnapshots", "list"]); + }); + + it("list(pageId) キーが正しい / list key includes pageId", () => { + expect(snapshotKeys.list("page-1")).toEqual(["pageSnapshots", "list", "page-1"]); + }); + + it("details キーが正しい / details key is correct", () => { + expect(snapshotKeys.details()).toEqual(["pageSnapshots", "detail"]); + }); + + it("detail(pageId, snapshotId) キーが正しい / detail key includes both ids", () => { + expect(snapshotKeys.detail("page-1", "snap-1")).toEqual([ + "pageSnapshots", + "detail", + "page-1", + "snap-1", + ]); + }); +}); + +// ── Hook tests (mock API) ────────────────────────────────────────────────── + +const mockGetPageSnapshots = vi.fn(); +const mockGetPageSnapshot = vi.fn(); +const mockRestorePageSnapshot = vi.fn(); + +vi.mock("@/lib/api", () => ({ + createApiClient: () => ({ + getPageSnapshots: mockGetPageSnapshots, + getPageSnapshot: mockGetPageSnapshot, + restorePageSnapshot: mockRestorePageSnapshot, + }), +})); + +// Re-import after mock is set up +const { usePageSnapshots, usePageSnapshot, useRestorePageSnapshot } = + await import("./usePageSnapshotQueries"); + +describe("usePageSnapshots", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("スナップショット一覧を取得して変換する / fetches and transforms snapshot list", async () => { + mockGetPageSnapshots.mockResolvedValueOnce({ + snapshots: [ + { + id: "s1", + version: 3, + content_text: "text", + created_by: "u1", + created_by_email: "u@example.com", + trigger: "auto", + created_at: "2026-04-07T12:00:00Z", + }, + ], + }); + + const { result } = renderHook(() => usePageSnapshots("page-1"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toHaveLength(1); + expect(result.current.data?.[0]).toEqual({ + id: "s1", + version: 3, + contentText: "text", + createdBy: "u1", + createdByEmail: "u@example.com", + trigger: "auto", + createdAt: "2026-04-07T12:00:00Z", + }); + }); + + it("pageId が空の場合はクエリを無効化する / disables query when pageId is empty", () => { + const { result } = renderHook(() => usePageSnapshots(""), { + wrapper: createWrapper(), + }); + + expect(result.current.fetchStatus).toBe("idle"); + }); +}); + +describe("usePageSnapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("スナップショット詳細を取得して変換する / fetches and transforms snapshot detail", async () => { + mockGetPageSnapshot.mockResolvedValueOnce({ + id: "s1", + version: 5, + ydoc_state: "base64data", + content_text: "detail", + created_by: "u1", + created_by_email: "u@example.com", + trigger: "auto", + created_at: "2026-04-07T12:00:00Z", + }); + + const { result } = renderHook(() => usePageSnapshot("page-1", "s1"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual({ + id: "s1", + version: 5, + ydocState: "base64data", + contentText: "detail", + createdBy: "u1", + createdByEmail: "u@example.com", + trigger: "auto", + createdAt: "2026-04-07T12:00:00Z", + }); + }); + + it("snapshotId が null の場合はクエリを無効化する / disables query when snapshotId is null", () => { + const { result } = renderHook(() => usePageSnapshot("page-1", null), { + wrapper: createWrapper(), + }); + + expect(result.current.fetchStatus).toBe("idle"); + }); +}); + +describe("useRestorePageSnapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("復元 API を呼び出す / calls restore API", async () => { + mockRestorePageSnapshot.mockResolvedValueOnce({ version: 6, snapshot_id: "snap-new" }); + + const { result } = renderHook(() => useRestorePageSnapshot("page-1"), { + wrapper: createWrapper(), + }); + + await result.current.mutateAsync("snap-1"); + + expect(mockRestorePageSnapshot).toHaveBeenCalledWith("page-1", "snap-1"); + }); +}); diff --git a/src/hooks/usePageSnapshotQueries.ts b/src/hooks/usePageSnapshotQueries.ts new file mode 100644 index 00000000..b571e4d7 --- /dev/null +++ b/src/hooks/usePageSnapshotQueries.ts @@ -0,0 +1,99 @@ +/** + * ページスナップショット(バージョン履歴)用の React Query フック + * React Query hooks for page snapshots (version history) + */ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { createApiClient } from "@/lib/api"; +import type { PageSnapshot, PageSnapshotDetail } from "@/types/pageSnapshot"; +import type { SnapshotListItem } from "@/lib/api/types"; + +export /** + * + */ +const snapshotKeys = { + all: ["pageSnapshots"] as const, + lists: () => [...snapshotKeys.all, "list"] as const, + list: (pageId: string) => [...snapshotKeys.lists(), pageId] as const, + details: () => [...snapshotKeys.all, "detail"] as const, + detail: (pageId: string, snapshotId: string) => + [...snapshotKeys.details(), pageId, snapshotId] as const, +}; + +/** + * API レスポンスをフロント型に変換する + * Convert API response to frontend type + */ +function apiSnapshotToSnapshot(item: SnapshotListItem): PageSnapshot { + return { + id: item.id, + version: item.version, + contentText: item.content_text, + createdBy: item.created_by, + createdByEmail: item.created_by_email, + trigger: item.trigger, + createdAt: item.created_at, + }; +} + +/** + * スナップショット一覧を取得する + * Fetch the list of snapshots for a page + */ +export function usePageSnapshots(pageId: string) { + const api = createApiClient(); + return useQuery({ + queryKey: snapshotKeys.list(pageId), + queryFn: async (): Promise => { + const res = await api.getPageSnapshots(pageId); + return res.snapshots.map(apiSnapshotToSnapshot); + }, + enabled: !!pageId, + }); +} + +/** + * スナップショット詳細(Y.Doc 含む)を取得する + * Fetch snapshot detail with Y.Doc state + */ +export function usePageSnapshot(pageId: string, snapshotId: string | null) { + const api = createApiClient(); + return useQuery({ + queryKey: snapshotKeys.detail(pageId, snapshotId ?? ""), + queryFn: async (): Promise => { + if (!snapshotId) throw new Error("unreachable: snapshotId is null"); + const res = await api.getPageSnapshot(pageId, snapshotId); + return { + id: res.id, + version: res.version, + ydocState: res.ydoc_state, + contentText: res.content_text, + createdBy: res.created_by, + createdByEmail: res.created_by_email, + trigger: res.trigger, + createdAt: res.created_at, + }; + }, + enabled: !!pageId && !!snapshotId, + }); +} + +/** + * スナップショットを復元する(新バージョンとして) + * Restore a snapshot as a new version + */ +export function useRestorePageSnapshot(pageId: string) { + const api = createApiClient(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (snapshotId: string) => { + return api.restorePageSnapshot(pageId, snapshotId); + }, + onSuccess: () => { + // スナップショット一覧を再取得 / Refetch snapshot list + queryClient.invalidateQueries({ queryKey: snapshotKeys.list(pageId) }); + // ページコンテンツ関連のキャッシュも無効化 / Invalidate page content caches + queryClient.invalidateQueries({ queryKey: ["pageContent", pageId] }); + }, + }); +} diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 15496d72..d5957089 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -214,6 +214,32 @@ "typescript": "TypeScript" } }, + "pageHistory": { + "title": "Version History", + "description": "View previous versions of this page", + "menuButton": "Version History", + "snapshotList": "Versions", + "preview": "Preview", + "compare": "Compare", + "auto": "Auto-saved", + "restore": "Restored", + "pre-restore": "Pre-restore", + "version": "v{{version}}", + "noSnapshots": "No version history yet", + "noSnapshotsDescription": "Edits are auto-saved every 10 minutes", + "selectSnapshot": "Select a version from the list on the left", + "restoreButton": "Restore this version", + "restoreConfirmTitle": "Restore this version?", + "restoreConfirmDescription": "The current content will be saved as a snapshot, and the selected version will be restored as a new version.", + "restoreConfirmCancel": "Cancel", + "restoreConfirmAction": "Restore", + "restoring": "Restoring...", + "restoreSuccess": "Version restored", + "restoreError": "Failed to restore", + "currentVersion": "Current version", + "selectedVersion": "Selected version", + "by": "by {{email}}" + }, "markdownExport": { "sourceAttribution": "📎 Source", "downloaded": "Markdown file downloaded", diff --git a/src/i18n/locales/ja/editor.json b/src/i18n/locales/ja/editor.json index 9d8e2d23..617f4688 100644 --- a/src/i18n/locales/ja/editor.json +++ b/src/i18n/locales/ja/editor.json @@ -214,6 +214,32 @@ "typescript": "TypeScript" } }, + "pageHistory": { + "title": "変更履歴", + "description": "このページの過去のバージョンを表示します", + "menuButton": "変更履歴", + "snapshotList": "バージョン一覧", + "preview": "プレビュー", + "compare": "比較", + "auto": "自動保存", + "restore": "復元", + "pre-restore": "復元前", + "version": "v{{version}}", + "noSnapshots": "変更履歴がまだありません", + "noSnapshotsDescription": "編集内容は10分ごとに自動保存されます", + "selectSnapshot": "左の一覧からバージョンを選択してください", + "restoreButton": "このバージョンに復元する", + "restoreConfirmTitle": "このバージョンに復元しますか?", + "restoreConfirmDescription": "現在の内容はスナップショットとして保存され、選択したバージョンが新しいバージョンとして復元されます。", + "restoreConfirmCancel": "キャンセル", + "restoreConfirmAction": "復元する", + "restoring": "復元中...", + "restoreSuccess": "バージョンを復元しました", + "restoreError": "復元に失敗しました", + "currentVersion": "現在のバージョン", + "selectedVersion": "選択したバージョン", + "by": "by {{email}}" + }, "markdownExport": { "sourceAttribution": "📎 引用元", "downloaded": "Markdownファイルをダウンロードしました", diff --git a/src/lib/api/apiClient.ts b/src/lib/api/apiClient.ts index dce2df4d..b697020a 100644 --- a/src/lib/api/apiClient.ts +++ b/src/lib/api/apiClient.ts @@ -16,10 +16,17 @@ import type { GetNoteResponse, NoteMemberItem, DiscoverResponse, + SnapshotListResponse, + SnapshotDetailResponse, + RestoreSnapshotResponse, } from "./types"; export type { NoteListItem }; +/** + * API クライアント生成オプション。 + * Options for creating the API client. + */ export interface ApiClientOptions { /** Base URL for API (e.g. https://api.zedi-note.app or "" for same-origin). */ baseUrl?: string; @@ -29,6 +36,10 @@ export interface ApiClientOptions { /** API error with status and optional code from body. */ export class ApiError extends Error { + /** + * API エラーを生成する。 + * Creates an API error with HTTP status and optional application code. + */ constructor( message: string, public status: number, @@ -176,6 +187,13 @@ async function requestOptionalAuth( return unwrapEnvelope(data); } +/** + * 型付き API クライアントを生成する。 + * Creates a typed API client for Zedi backend endpoints. + * + * @param options - API クライアント設定 / API client options + * @returns API 呼び出しヘルパー群 / API request helpers + */ export function createApiClient(options?: Partial) { const baseUrl = options?.baseUrl ?? getDefaultBaseUrl(); @@ -229,6 +247,32 @@ export function createApiClient(options?: Partial) { ); }, + // ── Page Snapshots (Version History) ────────────────────────────────── + + /** GET /api/pages/:id/snapshots — スナップショット一覧 / List snapshots */ + async getPageSnapshots(pageId: string): Promise { + return req("GET", `/api/pages/${encodeURIComponent(pageId)}/snapshots`); + }, + + /** GET /api/pages/:id/snapshots/:snapshotId — スナップショット詳細 / Get snapshot detail */ + async getPageSnapshot(pageId: string, snapshotId: string): Promise { + return req( + "GET", + `/api/pages/${encodeURIComponent(pageId)}/snapshots/${encodeURIComponent(snapshotId)}`, + ); + }, + + /** POST /api/pages/:id/snapshots/:snapshotId/restore — 復元 / Restore snapshot */ + async restorePageSnapshot( + pageId: string, + snapshotId: string, + ): Promise { + return req( + "POST", + `/api/pages/${encodeURIComponent(pageId)}/snapshots/${encodeURIComponent(snapshotId)}/restore`, + ); + }, + /** GET /api/notes — list notes the user can access (role, page_count, member_count). */ async getNotes(): Promise { return req("GET", "/api/notes"); @@ -286,7 +330,7 @@ export function createApiClient(options?: Partial) { ); }, - /** POST /api/notes/:id/pages — add page { pageId } or create new { title }. */ + /** POST /api/notes/:id/pages — add an existing page or create a new titled page. */ async addNotePage( noteId: string, body: { pageId?: string; page_id?: string; title?: string }, @@ -357,4 +401,8 @@ export function createApiClient(options?: Partial) { }; } +/** + * API クライアント型。 + * API client type inferred from `createApiClient`. + */ export type ApiClient = ReturnType; diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index e4456430..6a750152 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -11,6 +11,9 @@ export interface SyncPagesResponse { server_time: string; } +/** + * + */ export interface SyncPageItem { id: string; owner_id: string; @@ -24,12 +27,18 @@ export interface SyncPageItem { is_deleted: boolean; } +/** + * + */ export interface SyncLinkItem { source_id: string; target_id: string; created_at: string; } +/** + * + */ export interface SyncGhostLinkItem { link_text: string; source_page_id: string; @@ -130,6 +139,9 @@ export interface DiscoverResponse { notes: DiscoverNoteItem[]; } +/** + * + */ export interface DiscoverNoteItem { id: string; owner_id: string; @@ -174,6 +186,40 @@ export interface GetNoteResponse { }>; } +/** GET /api/pages/:id/snapshots response. */ +export interface SnapshotListResponse { + snapshots: SnapshotListItem[]; +} + +/** Snapshot list item (without ydoc_state). */ +export interface SnapshotListItem { + id: string; + version: number; + content_text: string | null; + created_by: string | null; + created_by_email: string | null; + trigger: "auto" | "restore" | "pre-restore"; + created_at: string; +} + +/** GET /api/pages/:id/snapshots/:snapshotId response. */ +export interface SnapshotDetailResponse { + id: string; + version: number; + ydoc_state: string; // base64 + content_text: string | null; + created_by: string | null; + created_by_email: string | null; + trigger: "auto" | "restore" | "pre-restore"; + created_at: string; +} + +/** POST /api/pages/:id/snapshots/:snapshotId/restore response. */ +export interface RestoreSnapshotResponse { + version: number; + snapshot_id: string; +} + /** GET /api/notes/:id/members response item. */ export interface NoteMemberItem { note_id: string; diff --git a/src/lib/ydoc/__tests__/yDocToTiptapJson.test.ts b/src/lib/ydoc/__tests__/yDocToTiptapJson.test.ts new file mode 100644 index 00000000..6ebf5207 --- /dev/null +++ b/src/lib/ydoc/__tests__/yDocToTiptapJson.test.ts @@ -0,0 +1,167 @@ +/** + * Y.Doc → TipTap JSON 変換ロジックのテスト + * Tests for Y.Doc to TipTap JSON conversion logic + */ +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; +import { + yXmlFragmentToTiptapJson, + yXmlElementToJson, + textToInlineNodes, + textToJson, +} from "../yDocToTiptapJson"; + +describe("yXmlFragmentToTiptapJson", () => { + it("returns doc with empty paragraph for empty fragment", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + + const result = yXmlFragmentToTiptapJson(fragment); + + expect(result).toEqual({ + type: "doc", + content: [{ type: "paragraph" }], + }); + }); + + it("converts fragment with a paragraph containing text", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const paragraph = new Y.XmlElement("paragraph"); + const text = new Y.XmlText(); + text.insert(0, "Hello, world!"); + paragraph.insert(0, [text]); + fragment.insert(0, [paragraph]); + + const result = yXmlFragmentToTiptapJson(fragment); + + expect(result.type).toBe("doc"); + expect(result.content).toBeInstanceOf(Array); + const content = result.content as Array>; + expect(content).toHaveLength(1); + expect(content[0]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "Hello, world!" }], + }); + }); + + it("converts nested elements (heading with text) — inline text, not nested paragraph", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const heading = new Y.XmlElement("heading"); + heading.setAttribute("level", 2); + const text = new Y.XmlText(); + text.insert(0, "Title"); + heading.insert(0, [text]); + fragment.insert(0, [heading]); + + const result = yXmlFragmentToTiptapJson(fragment); + + const content = result.content as Array>; + expect(content[0]).toMatchObject({ + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Title" }], + }); + }); +}); + +describe("yXmlElementToJson", () => { + it("returns null for unsupported types", () => { + const doc = new Y.Doc(); + const map = doc.getMap("test"); + const result = yXmlElementToJson(map as unknown as Y.XmlElement); + expect(result).toBeNull(); + }); + + it("converts XmlElement with attributes", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("test"); + const el = new Y.XmlElement("codeBlock"); + fragment.insert(0, [el]); + el.setAttribute("language", "typescript"); + + const result = yXmlElementToJson(el); + + expect(result).toEqual({ + type: "codeBlock", + attrs: { language: "typescript" }, + }); + }); + + it("converts XmlElement without attributes or children", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("test"); + const el = new Y.XmlElement("bulletList"); + fragment.insert(0, [el]); + + const result = yXmlElementToJson(el); + + expect(result).toEqual({ type: "bulletList" }); + }); +}); + +describe("textToInlineNodes", () => { + it("returns empty array for empty text", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("test"); + const text = new Y.XmlText(); + fragment.insert(0, [text]); + + expect(textToInlineNodes(text)).toEqual([]); + }); + + it("converts plain text to inline text nodes", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("test"); + const text = new Y.XmlText(); + fragment.insert(0, [text]); + text.insert(0, "Plain text"); + + expect(textToInlineNodes(text)).toEqual([{ type: "text", text: "Plain text" }]); + }); + + it("converts text with bold mark", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("test"); + const text = new Y.XmlText(); + fragment.insert(0, [text]); + text.insert(0, "Bold", { bold: true }); + + expect(textToInlineNodes(text)).toEqual([ + { + type: "text", + text: "Bold", + marks: [{ type: "bold" }], + }, + ]); + }); + + it("converts text with non-boolean mark attributes", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("test"); + const text = new Y.XmlText(); + fragment.insert(0, [text]); + text.insert(0, "Link", { link: { href: "https://example.com" } }); + + const nodes = textToInlineNodes(text); + expect(nodes[0]?.marks).toEqual([{ type: "link", attrs: { href: "https://example.com" } }]); + }); +}); + +describe("textToJson (paragraph wrapper)", () => { + it("wraps inline nodes in a paragraph for legacy callers", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("test"); + const text = new Y.XmlText(); + fragment.insert(0, [text]); + text.insert(0, "Plain text"); + + const result = textToJson(text); + + expect(result).toEqual({ + type: "paragraph", + content: [{ type: "text", text: "Plain text" }], + }); + }); +}); diff --git a/src/lib/ydoc/yDocToTiptapJson.ts b/src/lib/ydoc/yDocToTiptapJson.ts new file mode 100644 index 00000000..788af23b --- /dev/null +++ b/src/lib/ydoc/yDocToTiptapJson.ts @@ -0,0 +1,103 @@ +/** + * Y.Doc → TipTap JSON (ProseMirror Doc) 変換ユーティリティ + * Utility to convert Y.Doc XML fragments into TipTap-compatible ProseMirror JSON. + */ +import * as Y from "yjs"; + +/** + * Y.XmlFragment を TipTap JSON (ProseMirror Doc) に変換する。 + * Converts a Y.XmlFragment into TipTap-compatible ProseMirror JSON. + * + * ルート直下の XmlText は paragraph でラップする(フラグメントにブロックが必須なため)。 + * Top-level XmlText is wrapped in a paragraph (fragment must contain blocks). + */ +export function yXmlFragmentToTiptapJson(fragment: Y.XmlFragment): Record { + const children: Record[] = []; + for (let i = 0; i < fragment.length; i++) { + const child = fragment.get(i); + if (child instanceof Y.XmlText) { + const inlines = textToInlineNodes(child); + if (inlines.length > 0) { + children.push({ type: "paragraph", content: inlines }); + } + } else { + const node = yXmlElementToJson(child); + if (node) children.push(node); + } + } + return { type: "doc", content: children.length > 0 ? children : [{ type: "paragraph" }] }; +} + +/** + * Y.XmlElement / Y.XmlText を ProseMirror ノードに変換する。 + * Converts a Y.XmlElement or Y.XmlText into a ProseMirror node. + * + * XmlText がブロック要素の子の場合はインライン(text ノード)の配列として返すため、 + * 親の `content` にそのままマージする。ルート直下の XmlText は + * `yXmlFragmentToTiptapJson` で paragraph に包む。 + */ +export function yXmlElementToJson( + element: Y.XmlElement | Y.XmlText | Y.AbstractType, +): Record | null { + if (element instanceof Y.XmlText) { + const inlines = textToInlineNodes(element); + if (inlines.length === 0) return null; + return { type: "paragraph", content: inlines }; + } + if (element instanceof Y.XmlElement) { + const nodeName = element.nodeName; + const attrs = element.getAttributes(); + const children: Record[] = []; + + for (let i = 0; i < element.length; i++) { + const child = element.get(i); + if (child instanceof Y.XmlText) { + const inlines = textToInlineNodes(child); + for (const inline of inlines) { + children.push(inline); + } + } else { + const node = yXmlElementToJson(child); + if (node) children.push(node); + } + } + + const result: Record = { type: nodeName }; + if (Object.keys(attrs).length > 0) result.attrs = attrs; + if (children.length > 0) result.content = children; + return result; + } + return null; +} + +/** + * Y.XmlText を ProseMirror インライン(text ノード)の配列に変換する。 + * Converts Y.XmlText to an array of ProseMirror inline (text) nodes. + */ +export function textToInlineNodes(text: Y.XmlText): Record[] { + const delta = text.toDelta(); + if (!delta || delta.length === 0) return []; + + return delta.map((op: { insert?: string; attributes?: Record }) => { + const mark: Record = { type: "text", text: op.insert ?? "" }; + if (op.attributes) { + mark.marks = Object.entries(op.attributes) + .filter(([, v]) => v) + .map(([type, attrs]) => { + if (typeof attrs === "boolean") return { type }; + return { type, attrs }; + }); + } + return mark; + }); +} + +/** + * @deprecated テスト互換用。通常は `textToInlineNodes` と親の paragraph を使う。 + * Test-only convenience: wraps inline nodes in a paragraph. + */ +export function textToJson(text: Y.XmlText): Record | null { + const inlines = textToInlineNodes(text); + if (inlines.length === 0) return null; + return { type: "paragraph", content: inlines }; +} diff --git a/src/types/pageSnapshot.ts b/src/types/pageSnapshot.ts new file mode 100644 index 00000000..ee3aa415 --- /dev/null +++ b/src/types/pageSnapshot.ts @@ -0,0 +1,20 @@ +/** + * ページスナップショット(バージョン履歴)の型定義 + * Type definitions for page snapshots (version history) + */ + +/** スナップショット一覧用 / Snapshot list item */ +export interface PageSnapshot { + id: string; + version: number; + contentText: string | null; + createdBy: string | null; + createdByEmail: string | null; + trigger: "auto" | "restore" | "pre-restore"; + createdAt: string; +} + +/** スナップショット詳細(Y.Doc 含む)/ Snapshot detail with Y.Doc state */ +export interface PageSnapshotDetail extends PageSnapshot { + ydocState: string; // base64 +}