diff --git a/server/api/src/__tests__/routes/search.test.ts b/server/api/src/__tests__/routes/search.test.ts index b5b3c0c6..6315573a 100644 --- a/server/api/src/__tests__/routes/search.test.ts +++ b/server/api/src/__tests__/routes/search.test.ts @@ -9,8 +9,15 @@ * Issue #823: `scope=own` restricts to the caller's default note; `scope=shared` spans * pages in notes reachable via owner / member / domain access. The `note_pages` table * is gone. + * + * Issue #864: ハイライト検索 (`pdf_highlights`) も同エンドポイントから返す。 + * 所有検証 (`owner_id = userId`) と `kind="pdf_local"` の二重防御を確認する。 + * + * Issue #864: this endpoint now also surfaces `pdf_highlights` rows. The tests + * below assert the owner filter and the `kind="pdf_local"` defense-in-depth + * check, and that the discriminator (`kind`) is set on every row. */ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Context, Next } from "hono"; import type { AppEnv } from "../../types/index.js"; @@ -64,9 +71,26 @@ function createSearchApp(dbResults: unknown[]) { return { app, chains }; } +/** + * デフォルトのモック DB 結果。1 番目がページ検索、2 番目が pdf_highlights 検索。 + * いずれも空配列を返すデフォルト。 + * + * Default mock results — first call answers the page query, second answers the + * pdf_highlights query. Both default to empty rows. + */ +function emptyDbResults() { + return [{ rows: [] }, { rows: [] }]; +} + describe("GET /api/search", () => { + beforeEach(() => { + // テスト間で env がリークしないようキルスイッチを毎回クリア。 + // Reset the kill switch between tests so env state does not leak. + delete process.env.PDF_HIGHLIGHT_SEARCH_DISABLED; + }); + it("returns 401 without auth header", async () => { - const { app } = createSearchApp([{ rows: [] }]); + const { app } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello", { method: "GET" }); @@ -88,7 +112,7 @@ describe("GET /api/search", () => { }); it("scope=own binds listing to default note id from getDefaultNoteOrNull (issue #823)", async () => { - const { app, chains } = createSearchApp([{ rows: [] }]); + const { app, chains } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello&scope=own", { method: "GET", @@ -96,9 +120,10 @@ describe("GET /api/search", () => { }); expect(res.status).toBe(200); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + expect(executeChains.length).toBeGreaterThanOrEqual(1); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).toContain("default-note-search-mock"); expect(serialised).toContain("p.note_id"); expect(serialised).not.toContain("is_default"); @@ -106,7 +131,7 @@ describe("GET /api/search", () => { }); it("defaults to scope=own when scope query parameter is omitted", async () => { - const { app, chains } = createSearchApp([{ rows: [] }]); + const { app, chains } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello", { method: "GET", @@ -114,16 +139,18 @@ describe("GET /api/search", () => { }); expect(res.status).toBe(200); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).toContain("default-note-search-mock"); expect(serialised).toContain("p.note_id"); }); - it("scope=own returns empty results when default note is missing", async () => { + it("scope=own runs highlight search even when default note is missing", async () => { vi.mocked(getDefaultNoteOrNull).mockResolvedValueOnce(null); - const { app, chains } = createSearchApp([]); + // ページ検索は走らない(default note 無し)が、ハイライト検索は走る。 + // The page query is skipped (no default note), but the highlight query still runs. + const { app, chains } = createSearchApp([{ rows: [] }]); const res = await app.request("/api/search?q=hello&scope=own", { method: "GET", @@ -133,11 +160,14 @@ describe("GET /api/search", () => { expect(res.status).toBe(200); const body = (await res.json()) as { results: unknown[] }; expect(body.results).toEqual([]); - expect(chains.find((c) => c.startMethod === "execute")).toBeUndefined(); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + expect(executeChains).toHaveLength(1); + const serialised = JSON.stringify(executeChains[0]?.startArgs); + expect(serialised).toContain("pdf_highlights"); }); it("scope=shared uses note ownership / member / domain EXISTS branches without note_pages", async () => { - const { app, chains } = createSearchApp([{ rows: [] }]); + const { app, chains } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello&scope=shared", { method: "GET", @@ -145,16 +175,16 @@ describe("GET /api/search", () => { }); expect(res.status).toBe(200); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).not.toContain("note_pages"); expect(serialised).toContain("note_members"); expect(serialised).toContain("OR EXISTS"); }); it("falls back to the default limit when the limit query is non-numeric", async () => { - const { app } = createSearchApp([{ rows: [] }]); + const { app } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello&scope=shared&limit=abc", { method: "GET", @@ -165,7 +195,7 @@ describe("GET /api/search", () => { }); it("scope=shared keeps note-scoped EXISTS predicates (no note_pages join)", async () => { - const { app, chains } = createSearchApp([{ rows: [] }]); + const { app, chains } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello&scope=shared", { method: "GET", @@ -173,15 +203,15 @@ describe("GET /api/search", () => { }); expect(res.status).toBe(200); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).toContain(TEST_USER_ID); expect(serialised).not.toContain("note_pages"); expect(serialised).toContain("p.note_id"); }); - it("response rows include note_id so callers can distinguish pages by owning note", async () => { + it("response page rows are tagged with kind='page' and include note_id", async () => { const defaultNotePageId = "11111111-1111-1111-1111-111111111111"; const { app, chains } = createSearchApp([ { @@ -195,6 +225,7 @@ describe("GET /api/search", () => { }, ], }, + { rows: [] }, ]); const res = await app.request("/api/search?q=hello&scope=shared", { @@ -206,9 +237,11 @@ describe("GET /api/search", () => { const body = (await res.json()) as { results: Array> }; expect(body.results).toHaveLength(1); expect(body.results[0]).toHaveProperty("note_id"); + expect(body.results[0]).toHaveProperty("kind", "page"); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).toContain("p.note_id"); }); @@ -226,6 +259,7 @@ describe("GET /api/search", () => { }, ], }, + { rows: [] }, ]); const res = await app.request("/api/search?q=hello&scope=own", { @@ -237,11 +271,374 @@ describe("GET /api/search", () => { const body = (await res.json()) as { results: Array> }; expect(body.results).toHaveLength(1); expect(body.results[0]).toHaveProperty("note_id", defaultNotePageId); + expect(body.results[0]).toHaveProperty("kind", "page"); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).toContain("p.note_id"); expect(serialised).toContain("default-note-search-mock"); expect(serialised).not.toContain("is_default"); }); + + // ── Issue #864: PDF ハイライト統合 ───────────────────────────────────────── + // PDF highlight integration tests (Issue #864). + + it("scope=own includes pdf_highlights rows filtered by owner_id (no leak of other users')", async () => { + const { app, chains } = createSearchApp([ + { rows: [] }, // ページ検索 / page query + { + rows: [ + { + highlight_id: "h-1", + source_id: "s-1", + owner_id: TEST_USER_ID, + pdf_page: 5, + text: "highlighted passage about hello", + derived_page_id: null, + updated_at: new Date("2026-05-01T00:00:00Z").toISOString(), + source_display_name: "paper.pdf", + source_title: null, + }, + ], + }, + ]); + + const res = await app.request("/api/search?q=hello&scope=own", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: Array> }; + expect(body.results).toHaveLength(1); + expect(body.results[0]).toMatchObject({ + kind: "pdf_highlight", + highlight_id: "h-1", + source_id: "s-1", + pdf_page: 5, + derived_page_id: null, + source_display_name: "paper.pdf", + }); + + // 所有検証 (owner_id = userId) と `kind="pdf_local"` の両方が SQL に存在する。 + // Both the owner filter and the `kind="pdf_local"` defensive filter live in the SQL. + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + expect(executeChains.length).toBe(2); + const highlightChain = executeChains[1]; + const serialised = JSON.stringify(highlightChain?.startArgs); + expect(serialised).toContain("h.owner_id"); + expect(serialised).toContain(TEST_USER_ID); + expect(serialised).toContain("pdf_local"); + }); + + it("scope=shared still scopes pdf_highlights to the caller's own rows only", async () => { + const { app, chains } = createSearchApp([ + { rows: [] }, + { + rows: [ + { + highlight_id: "h-2", + source_id: "s-2", + owner_id: TEST_USER_ID, + pdf_page: 1, + text: "shared lookup result text", + derived_page_id: "p-derived-1", + updated_at: new Date("2026-05-02T00:00:00Z").toISOString(), + source_display_name: "notes.pdf", + source_title: "Notes", + }, + ], + }, + ]); + + const res = await app.request("/api/search?q=shared&scope=shared", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: Array> }; + expect(body.results).toHaveLength(1); + expect(body.results[0]).toMatchObject({ + kind: "pdf_highlight", + highlight_id: "h-2", + derived_page_id: "p-derived-1", + }); + + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + expect(executeChains).toHaveLength(2); + const highlightChain = executeChains[1]; + const serialised = JSON.stringify(highlightChain?.startArgs); + // 他ユーザーのハイライトを掴まないよう owner_id 比較が必ず入る。 + // The owner filter is always present regardless of scope. + expect(serialised).toContain("h.owner_id"); + expect(serialised).toContain(TEST_USER_ID); + }); + + it("pdf_highlight branch JOINs sources and restricts to kind='pdf_local'", async () => { + const { app, chains } = createSearchApp(emptyDbResults()); + + const res = await app.request("/api/search?q=hello&scope=own", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + expect(executeChains).toHaveLength(2); + const highlightChain = executeChains[1]; + const serialised = JSON.stringify(highlightChain?.startArgs); + expect(serialised).toContain("pdf_highlights"); + expect(serialised).toContain("INNER JOIN sources"); + expect(serialised).toContain("pdf_local"); + }); + + it("PDF_HIGHLIGHT_SEARCH_DISABLED=1 skips the highlight query entirely", async () => { + process.env.PDF_HIGHLIGHT_SEARCH_DISABLED = "1"; + const { app, chains } = createSearchApp([{ rows: [] }]); + + const res = await app.request("/api/search?q=hello&scope=own", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: unknown[] }; + expect(body.results).toEqual([]); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + // ページ検索のみ実行され、ハイライト検索は走らない。 + // Only the page query runs; the highlight query is short-circuited. + expect(executeChains).toHaveLength(1); + const serialised = JSON.stringify(executeChains[0]?.startArgs); + expect(serialised).not.toContain("pdf_highlights"); + }); + + it("does not leak content_text into the API response (PR #873 review: CodeRabbit)", async () => { + // SQL から `pc.content_text` を引っ張る場合でも、レスポンスには含めない。 + // Even when the SQL row carries `content_text`, it must not appear in the + // outbound JSON. + const pageId = "55555555-5555-5555-5555-555555555555"; + const { app } = createSearchApp([ + { + rows: [ + { + id: pageId, + title: "Page", + content_preview: "snippet", + updated_at: new Date("2026-04-01T00:00:00Z").toISOString(), + note_id: "default-note-search-mock", + // 仮に過去の SELECT が content_text を含んでいた場合のシミュレーション。 + // Simulate a row that still carries full body text. + content_text: "FULL PAGE BODY MUST NOT LEAK", + }, + ], + }, + { rows: [] }, + ]); + + const res = await app.request("/api/search?q=hello&scope=own", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: Array> }; + expect(body.results).toHaveLength(1); + expect(body.results[0]).not.toHaveProperty("content_text"); + expect(JSON.stringify(body)).not.toContain("FULL PAGE BODY"); + }); + + it("page-query SELECT no longer pulls pc.content_text into the outbound payload", async () => { + // WHERE には残るが、SELECT 列から content_text が消えている。 + // The WHERE clause still references `content_text`, but the SELECT list + // doesn't carry it anymore (PR #873 review: CodeRabbit). + const { app, chains } = createSearchApp(emptyDbResults()); + + await app.request("/api/search?q=hello&scope=own", { + method: "GET", + headers: authHeaders(), + }); + + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); + // WHERE 句では content_text 比較が残っている必要がある (検索可能性は維持)。 + // The WHERE branch still uses `content_text` (search reach preserved). + expect(serialised).toContain("pc.content_text ILIKE"); + // SELECT 句には `pc.content_text` リストアップが残らない。 + // The SELECT list no longer references `pc.content_text`. + expect(serialised).not.toContain("pc.content_text,"); + expect(serialised).not.toMatch(/SELECT[\s\S]*pc\.content_text\b[\s\S]*FROM/); + }); + + it("caps the merged result list at `limit` so page+highlight never exceed the hard bound (PR #873 review: codex)", async () => { + // 各クエリが LIMIT を持つので、極端な場合 page+highlight = 2*limit になる。 + // 結合後に再度クリップしてレスポンス契約 `limit` をハード上限として維持する。 + // + // Each query carries `LIMIT`, so naïve concat could return 2*limit rows. + // The merge clip enforces `limit` as a hard cap on the API response. + const pageRows = Array.from({ length: 20 }, (_, i) => ({ + id: `p-${i}`, + title: `Page ${i}`, + content_preview: null, + updated_at: new Date(2026, 3, 1, 0, i).toISOString(), + note_id: "default-note-search-mock", + })); + const highlightRows = Array.from({ length: 20 }, (_, i) => ({ + highlight_id: `h-${i}`, + source_id: `s-${i}`, + owner_id: TEST_USER_ID, + pdf_page: i + 1, + text: "hit", + derived_page_id: null, + updated_at: new Date(2026, 3, 2, 0, i).toISOString(), + source_display_name: "doc.pdf", + source_title: null, + })); + const { app } = createSearchApp([{ rows: pageRows }, { rows: highlightRows }]); + + const res = await app.request("/api/search?q=hit&scope=own&limit=20", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: Array> }; + expect(body.results.length).toBeLessThanOrEqual(20); + }); + + it("reserves slots for pdf_highlights so pages never starve them out (PR #873 review: CodeRabbit)", async () => { + // ページが limit を埋め切る場合でも、ハイライトに最低限の枠 + // (ceil(limit / 4) = 5 件) が確保される。 + // + // Even when pages would fill `limit` on their own, the merge reserves + // a minimum slot count (`ceil(limit / 4)` = 5) for highlights so the + // feature stays visible (PR #873 review: CodeRabbit). + const pageRows = Array.from({ length: 20 }, (_, i) => ({ + id: `p-${i}`, + title: `Page ${i}`, + content_preview: null, + updated_at: new Date(2026, 3, 1, 0, i).toISOString(), + note_id: "default-note-search-mock", + })); + const highlightRows = Array.from({ length: 20 }, (_, i) => ({ + highlight_id: `h-${i}`, + source_id: `s-${i}`, + owner_id: TEST_USER_ID, + pdf_page: i + 1, + text: "hit", + derived_page_id: null, + updated_at: new Date(2026, 3, 2, 0, i).toISOString(), + source_display_name: "doc.pdf", + source_title: null, + })); + const { app } = createSearchApp([{ rows: pageRows }, { rows: highlightRows }]); + + const res = await app.request("/api/search?q=hit&scope=own&limit=20", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: Array> }; + const highlights = body.results.filter((r) => r.kind === "pdf_highlight"); + const pages = body.results.filter((r) => r.kind === "page"); + expect(highlights.length).toBe(5); + expect(pages.length).toBe(15); + expect(body.results.length).toBe(20); + }); + + it("spills the highlight reserve back to pages when there are no highlights", async () => { + // ハイライトが 0 件のケースではページが limit までフルに載る。 + // When there are no highlights, pages get the full `limit` budget. + const pageRows = Array.from({ length: 20 }, (_, i) => ({ + id: `p-${i}`, + title: `Page ${i}`, + content_preview: null, + updated_at: new Date(2026, 3, 1, 0, i).toISOString(), + note_id: "default-note-search-mock", + })); + const { app } = createSearchApp([{ rows: pageRows }, { rows: [] }]); + + const res = await app.request("/api/search?q=hit&scope=own&limit=20", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: Array> }; + expect(body.results.length).toBe(20); + expect(body.results.every((r) => r.kind === "page")).toBe(true); + }); + + it("fills the merge with highlights when there are no pages", async () => { + // ページが 0 件のケースではハイライトが limit までフルに載る。 + // When there are no pages, highlights take the full `limit` budget. + const highlightRows = Array.from({ length: 20 }, (_, i) => ({ + highlight_id: `h-${i}`, + source_id: `s-${i}`, + owner_id: TEST_USER_ID, + pdf_page: i + 1, + text: "hit", + derived_page_id: null, + updated_at: new Date(2026, 3, 2, 0, i).toISOString(), + source_display_name: "doc.pdf", + source_title: null, + })); + const { app } = createSearchApp([{ rows: [] }, { rows: highlightRows }]); + + const res = await app.request("/api/search?q=hit&scope=own&limit=20", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: Array> }; + expect(body.results.length).toBe(20); + expect(body.results.every((r) => r.kind === "pdf_highlight")).toBe(true); + }); + + it("merges page rows (kind='page') and highlight rows (kind='pdf_highlight') in one response", async () => { + const pageId = "33333333-3333-3333-3333-333333333333"; + const { app } = createSearchApp([ + { + rows: [ + { + id: pageId, + title: "Page with hello", + content_preview: "hello there", + updated_at: new Date("2026-04-01T00:00:00Z").toISOString(), + note_id: "default-note-search-mock", + }, + ], + }, + { + rows: [ + { + highlight_id: "h-3", + source_id: "s-3", + owner_id: TEST_USER_ID, + pdf_page: 7, + text: "hello in PDF", + derived_page_id: null, + updated_at: new Date("2026-04-02T00:00:00Z").toISOString(), + source_display_name: "doc.pdf", + source_title: null, + }, + ], + }, + ]); + + const res = await app.request("/api/search?q=hello&scope=own", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: Array> }; + expect(body.results).toHaveLength(2); + expect(body.results[0]).toMatchObject({ kind: "page", id: pageId }); + expect(body.results[1]).toMatchObject({ kind: "pdf_highlight", highlight_id: "h-3" }); + }); }); diff --git a/server/api/src/routes/search.ts b/server/api/src/routes/search.ts index adeb044f..2de3630c 100644 --- a/server/api/src/routes/search.ts +++ b/server/api/src/routes/search.ts @@ -12,6 +12,30 @@ * - `scope=own` restricts to pages under the caller's default note. * - `scope=shared` spans pages in notes the caller can access (owner, accepted * member, or domain rule). + * + * PDF ハイライト統合 (Issue #864 / #389 follow-up): + * - 同じ検索 q を用いて `pdf_highlights.text` も対象に含める。 + * - ハイライトは常に呼び出し元 (`owner_id = userId`) 所有のみを返し、scope に依らず + * 他ユーザーに漏れない(テーブル単体で所有者を持つ前提)。 + * - 元ソースが `kind="pdf_local"` のもののみ対象(JOIN で防御的にフィルタ)。 + * - レスポンスは `kind` 識別子付きの discriminated union で返す + * (`kind="page"` / `kind="pdf_highlight"`)。クライアントはこれで分岐する。 + * - 環境変数 `PDF_HIGHLIGHT_SEARCH_DISABLED=1` をセットすると、ハイライト検索だけを + * 無効化できる(運用上のセーフティ)。ページ検索には影響しない。 + * + * PDF highlight integration (Issue #864, follow-up to #389): + * - The same query string also probes `pdf_highlights.text`. + * - Highlights are only returned to their owner — scope does NOT widen this; the + * table has a denormalized `owner_id` precisely for this case. + * - Defensive `JOIN sources` ensures we never surface highlights whose owning + * source row is somehow not `kind="pdf_local"` anymore. + * - The response is now a discriminated union tagged by `kind`: + * `"page"` (existing rows) and `"pdf_highlight"` (new rows). Clients branch on + * `kind` and the highlight rows carry `source_id` / `pdf_page` / `highlight_id` + * / `derived_page_id` so the UI can deep-link back to the PDF viewer or the + * derived Zedi page. + * - Set `PDF_HIGHLIGHT_SEARCH_DISABLED=1` to disable just the highlight part + * (kill switch); page search is unaffected. */ import { Hono } from "hono"; import { sql } from "drizzle-orm"; @@ -30,6 +54,15 @@ function clampLimit(raw: string | undefined): number { return Math.min(Math.max(safe, 1), 100); } +/** + * ハイライト検索のキルスイッチ。`PDF_HIGHLIGHT_SEARCH_DISABLED=1` or `=true` で有効。 + * Kill switch for the highlight search branch. + */ +function isPdfHighlightSearchDisabled(): boolean { + const v = (process.env.PDF_HIGHLIGHT_SEARCH_DISABLED ?? "").trim().toLowerCase(); + return v === "1" || v === "true"; +} + const app = new Hono(); app.get("/", authRequired, async (c) => { @@ -46,8 +79,14 @@ app.get("/", authRequired, async (c) => { const limit = clampLimit(c.req.query("limit")); const pattern = `%${escapeLike(query)}%`; - const searchColumns = sql`p.id, p.title, p.content_preview, p.updated_at, p.note_id, - pc.content_text`; + // 検索条件用にだけ `content_text` を WHERE に登場させるが、SELECT には含めない。 + // SELECT に流すと API 経由でページ本文が丸ごと露出し得る(PR #873 review: + // CodeRabbit)。クライアントが消費するのは `content_preview` のみ。 + // + // `content_text` is used in the WHERE clause for matching but is NOT in the + // SELECT list — otherwise the API would leak full page bodies (PR #873 review: + // CodeRabbit). Clients only consume `content_preview`. + const searchColumns = sql`p.id, p.title, p.content_preview, p.updated_at, p.note_id`; const normalizedEmail = typeof userEmailRaw === "string" ? userEmailRaw.trim().toLowerCase() : ""; const emailDomain = extractEmailDomain(normalizedEmail); @@ -65,10 +104,10 @@ app.get("/", authRequired, async (c) => { )` : sql``; - let results; + let pageRows: unknown[] = []; if (scope === "shared") { - results = await db.execute(sql` + const sharedResults = await db.execute(sql` SELECT ${searchColumns} FROM pages p LEFT JOIN page_contents pc ON pc.page_id = p.id @@ -98,12 +137,17 @@ app.get("/", authRequired, async (c) => { ORDER BY p.updated_at DESC LIMIT ${limit} `); + pageRows = sharedResults.rows; } else { const defaultNote = await getDefaultNoteOrNull(db, userId); if (!defaultNote) { - return c.json({ results: [] }); + // デフォルトノートが無い場合でもハイライト検索は走り得るので、ページ部だけ空配列に。 + // Even without a default note, highlight search can still run, so only the + // page branch short-circuits here. + const highlightRows = await runPdfHighlightSearch(db, userId, pattern, limit); + return c.json({ results: highlightRows }); } - results = await db.execute(sql` + const ownResults = await db.execute(sql` SELECT ${searchColumns} FROM pages p LEFT JOIN page_contents pc ON pc.page_id = p.id @@ -116,9 +160,128 @@ app.get("/", authRequired, async (c) => { ORDER BY p.updated_at DESC LIMIT ${limit} `); + pageRows = ownResults.rows; } - return c.json({ results: results.rows }); + // 契約フィールドのみを明示マップして response に流す。SQL の SELECT に直接含まれない + // カラム (owner_id / thumbnail_url / source_url) は明示的に null/undefined で埋めて + // 型 (`SearchPageResultRow`) との整合を取る。raw row を spread で流すと将来 SELECT + // を増やしたとき静かに API が漏れるので、PR #873 review (CodeRabbit) で明示化した。 + // + // Map only the contracted fields explicitly. We do not spread raw SQL rows + // because any future SELECT addition would silently widen the API payload + // (PR #873 review: CodeRabbit). Columns not in the current SELECT are + // emitted as `null`/`undefined` to stay aligned with `SearchPageResultRow`. + const taggedPageRows = pageRows.map((row) => { + const r = row as { + id: string; + note_id: string; + title: string | null; + content_preview: string | null; + updated_at: string; + }; + return { + kind: "page" as const, + id: r.id, + note_id: r.note_id, + // `owner_id` / `thumbnail_url` / `source_url` は将来 SELECT に追加する想定の + // プレースホルダ。現状の SQL では返らないので明示的に null/undefined を入れる。 + // Placeholders for columns not yet in SELECT; emitted explicitly so the + // payload shape stays stable for the discriminated union. + owner_id: null, + title: r.title, + content_preview: r.content_preview, + thumbnail_url: null, + source_url: null, + updated_at: r.updated_at, + }; + }); + + const highlightRows = await runPdfHighlightSearch(db, userId, pattern, limit); + + // PR #873 review (codex, CodeRabbit): 両 review の指摘を両立させるため予約枠方式を採る。 + // + // - codex: 各クエリが `LIMIT ${limit}` を持ち、連結すると最大 2*limit 行返り得る。 + // `limit` をハード上限として尊重する必要がある。 + // - CodeRabbit: 単純な `.slice(0, limit)` だとページが `limit` 件以上ある状況で + // ハイライトが全て削られ、Issue #864 の目的(ハイライトを検索結果に乗せる)が壊れる。 + // + // 解決策: ハイライトに最低 `ceil(limit / HIGHLIGHT_RESERVED_RATIO)` 件の予約枠を + // 確保し、残りをページで埋める。ハイライトが予約枠より少なければ余りはページに + // 戻す。結果として `総数 ≤ limit` を守りつつ、ハイライトが必ず一定数は載る。 + // 種別を跨いだ最終ランキングはクライアント側のスコアリング層に任せる。 + // + // PR #873 review (codex, CodeRabbit): reserved-budget merge that satisfies + // both concerns simultaneously. + // + // - codex: each branch applies `LIMIT ${limit}`, so a naïve concat could + // return up to 2*limit rows and break the contract. + // - CodeRabbit: a naïve `.slice(0, limit)` lets pages crowd out highlights + // entirely once there are >= `limit` pages, defeating Issue #864. + // + // The fix reserves a minimum of `ceil(limit / HIGHLIGHT_RESERVED_RATIO)` + // slots for highlights and gives the remainder to pages; the reserved + // budget shrinks if there aren't that many highlights so pages can spill + // back into it. Total is always <= `limit`, and highlights are never + // starved when they exist. Cross-kind ranking still happens on the client. + const HIGHLIGHT_RESERVED_RATIO = 4; + const highlightReserved = Math.min( + highlightRows.length, + Math.max(1, Math.ceil(limit / HIGHLIGHT_RESERVED_RATIO)), + ); + const pageQuota = limit - highlightReserved; + const cappedPages = taggedPageRows.slice(0, pageQuota); + // ページが quota より少なかった場合、余り枠をハイライトに回す。 + // Spill leftover capacity to highlights when there are fewer pages than the quota. + const cappedHighlights = highlightRows.slice(0, limit - cappedPages.length); + + return c.json({ results: [...cappedPages, ...cappedHighlights] }); }); +/** + * `pdf_highlights` を所有検証付きで検索し、`kind="pdf_highlight"` 行を返す。 + * 戻り値は `c.json` にそのまま流せる形に整える(snake_case のキー)。 + * + * Searches `pdf_highlights` for the caller's own highlights only and returns + * a list of `kind: "pdf_highlight"` rows shaped for `c.json` (snake_case). + * + * @param db リクエストの drizzle DB ハンドル。Drizzle DB handle from the request. + * @param userId 呼び出し元ユーザー ID。Owner filter — only the caller's rows are returned. + * @param pattern ILIKE 用にエスケープ済みのパターン。Pre-escaped ILIKE pattern. + * @param limit 結果上限。Maximum number of rows to return. + */ +async function runPdfHighlightSearch( + db: AppEnv["Variables"]["db"], + userId: string, + pattern: string, + limit: number, +): Promise>> { + if (isPdfHighlightSearchDisabled()) return []; + + const result = await db.execute(sql` + SELECT + h.id AS highlight_id, + h.source_id AS source_id, + h.owner_id AS owner_id, + h.pdf_page AS pdf_page, + h.text AS text, + h.derived_page_id AS derived_page_id, + h.updated_at AS updated_at, + s.display_name AS source_display_name, + s.title AS source_title + FROM pdf_highlights h + INNER JOIN sources s ON s.id = h.source_id + WHERE h.owner_id = ${userId} + AND s.kind = 'pdf_local' + AND h.text ILIKE ${pattern} + ORDER BY h.updated_at DESC + LIMIT ${limit} + `); + + return result.rows.map((row) => ({ + ...(row as Record), + kind: "pdf_highlight" as const, + })); +} + export default app; diff --git a/src/components/layout/Header/HeaderSearchBar.tsx b/src/components/layout/Header/HeaderSearchBar.tsx index d98bc1fe..3478ab0a 100644 --- a/src/components/layout/Header/HeaderSearchBar.tsx +++ b/src/components/layout/Header/HeaderSearchBar.tsx @@ -3,6 +3,7 @@ import { Search } from "lucide-react"; import { Popover, PopoverAnchor } from "@zedi/ui"; import { Input } from "@zedi/ui"; import { useGlobalSearchContext } from "@/contexts/GlobalSearchContext"; +import type { GlobalSearchResultItem } from "@/hooks/useGlobalSearch"; import { useGlobalSearchShortcut } from "@/hooks/useGlobalSearchShortcut"; import { HeaderSearchDropdownContent } from "./HeaderSearchDropdownContent"; import { cn } from "@zedi/ui"; @@ -15,10 +16,10 @@ interface SearchKeyDownParams { totalItems: number; activeIndex: number; itemCount: number; - searchResults: Array<{ pageId: string; noteId?: string }>; + searchResults: GlobalSearchResultItem[]; setActiveIndex: (value: number | ((prev: number) => number)) => void; closeDropdown: () => void; - onSelectItem: (pageId: string, noteId?: string) => void; + onSelectItem: (item: GlobalSearchResultItem) => void; handleSearchSubmit: () => void; inputRef: React.RefObject; } @@ -65,7 +66,7 @@ function handleSearchKeyDown( handleSearchSubmit(); } else if (activeIndex >= 0 && activeIndex < itemCount) { const item = searchResults[activeIndex]; - if (item) onSelectItem(item.pageId, item.noteId); + if (item) onSelectItem(item); } break; } @@ -81,61 +82,25 @@ function handleSearchKeyDown( * */ export function HeaderSearchBar() { - /** - * - */ const { query, setQuery, searchResults, hasQuery, handleSelect, handleSearchSubmit } = useGlobalSearchContext(); - /** - * - */ const [dropdownOpen, setDropdownOpen] = useState(false); - /** - * - */ const [activeIndex, setActiveIndex] = useState(-1); - /** - * - */ const inputRef = useRef(null); - /** - * - */ const listRef = useRef(null); - /** - * - */ const footerRef = useRef(null); - /** - * - */ const handleShortcutFocus = useCallback(() => { inputRef.current?.focus(); inputRef.current?.select(); }, []); useGlobalSearchShortcut(handleShortcutFocus); - /** - * - */ const showResults = hasQuery && searchResults.length > 0; - /** - * - */ const showEmpty = hasQuery && searchResults.length === 0; - /** - * - */ const hasContent = showResults || showEmpty; - /** - * - */ const itemCount = showResults ? searchResults.length : 0; - /** - * - */ const totalItems = hasQuery ? itemCount + 1 : itemCount; useEffect(() => { @@ -151,45 +116,30 @@ export function HeaderSearchBar() { if (activeIndex === itemCount) { footerRef.current?.scrollIntoView({ block: "nearest" }); } else { - /** - * - */ const items = listRef.current?.querySelectorAll("[role='option']"); items?.[activeIndex]?.scrollIntoView({ block: "nearest" }); } }, [activeIndex, itemCount]); - /** - * - */ const closeDropdown = useCallback(() => { setDropdownOpen(false); setActiveIndex(-1); }, []); - /** - * - */ const onSelectItem = useCallback( - (pageId: string, noteId?: string) => { - handleSelect(pageId, noteId); + (item: GlobalSearchResultItem) => { + handleSelect(item); closeDropdown(); }, [handleSelect, closeDropdown], ); - /** - * - */ const getOptionId = useCallback( (index: number) => index === itemCount ? "header-search-footer" : `header-search-option-${index}`, [itemCount], ); - /** - * - */ const activeDescendant = activeIndex >= 0 ? getOptionId(activeIndex) : undefined; return ( diff --git a/src/components/layout/Header/HeaderSearchDropdownContent.tsx b/src/components/layout/Header/HeaderSearchDropdownContent.tsx index 1d562526..40ade765 100644 --- a/src/components/layout/Header/HeaderSearchDropdownContent.tsx +++ b/src/components/layout/Header/HeaderSearchDropdownContent.tsx @@ -1,6 +1,7 @@ -import { FileText, Link as LinkIcon, ArrowRight } from "lucide-react"; +import { FileText, Link as LinkIcon, ArrowRight, BookOpen } from "lucide-react"; import { PopoverContent } from "@zedi/ui"; import { cn } from "@zedi/ui"; +import type { GlobalSearchResultItem } from "@/hooks/useGlobalSearch"; const EMPTY_MESSAGE = "ページが見つかりません"; @@ -11,7 +12,7 @@ export interface HeaderSearchDropdownContentProps { hasContent: boolean; showEmpty: boolean; showResults: boolean; - searchResults: Array<{ pageId: string; noteId?: string; title: string; sourceUrl?: string }>; + searchResults: GlobalSearchResultItem[]; itemCount: number; activeIndex: number; query: string; @@ -19,12 +20,28 @@ export interface HeaderSearchDropdownContentProps { listRef: React.RefObject; footerRef: React.RefObject; getOptionId: (index: number) => string; - onSelectItem: (pageId: string, noteId?: string) => void; + onSelectItem: (item: GlobalSearchResultItem) => void; setActiveIndex: (value: number | ((prev: number) => number)) => void; closeDropdown: () => void; handleSearchSubmit: () => void; } +/** + * Issue #864 でグローバル検索結果に PDF ハイライト型 (`kind="pdf_highlight"`) が + * 加わったので、`key` と `aria-label` をアイテム種別ごとに分岐して安定化させる。 + * 既存ノートページ / 個人ページの key 形式は維持し、追加分だけ別 prefix にする。 + * + * Issue #864 introduced `kind="pdf_highlight"` rows; the React `key` and the + * accessible label branch on `kind` so the existing page-row keys stay stable + * and the new highlight rows get a distinct, collision-free namespace. + */ +function getResultKey(item: GlobalSearchResultItem): string { + if (item.kind === "pdf_highlight") { + return `pdf-${item.sourceId}-${item.highlightId}`; + } + return item.noteId ? `shared-${item.noteId}-${item.pageId}` : `personal-${item.pageId}`; +} + /** * */ @@ -73,29 +90,37 @@ export function HeaderSearchDropdownContent({ 候補 ({searchResults.length}件)

    - {searchResults.map(({ pageId, noteId, title, sourceUrl }, index) => ( -
  • - -
  • - ))} + {searchResults.map((item, index) => { + const isPdf = item.kind === "pdf_highlight"; + return ( +
  • + +
  • + ); + })}
)} diff --git a/src/components/search/SearchResultCard.tsx b/src/components/search/SearchResultCard.tsx index 6e1bfc76..d732d6b7 100644 --- a/src/components/search/SearchResultCard.tsx +++ b/src/components/search/SearchResultCard.tsx @@ -1,4 +1,4 @@ -import { FileText, Link as LinkIcon } from "lucide-react"; +import { FileText, Link as LinkIcon, BookOpen } from "lucide-react"; import { HighlightedSnippet } from "@/components/search/HighlightedSnippet"; import { MatchTypeBadge } from "@/components/search/MatchTypeBadge"; import type { MatchType } from "@/lib/searchUtils"; @@ -6,19 +6,63 @@ import { cn } from "@zedi/ui"; import { useAuthenticatedImageUrl } from "@/hooks/useAuthenticatedImageUrl"; /** + * 検索結果カードの表示用アイテム。判別可能 union で `kind` が `"page"` と + * `"pdf_highlight"` の 2 種を持つ。`kind="page"` は `pageId` が必須、 + * `kind="pdf_highlight"` は `sourceId` / `highlightId` / `pdfPage` を持つ。 * + * Discriminated union: `kind="page"` rows must carry `pageId`; PDF highlight + * rows carry `sourceId`/`highlightId`/`pdfPage` so the click handler can route + * to the derived page or the PDF viewer (Issue #864). */ -export interface SearchResultCardItem { - pageId: string; - noteId?: string; +export type SearchResultCardItem = SearchResultCardPageItem | SearchResultCardPdfHighlightItem; + +interface SearchResultCardBase { title: string; highlightedSnippet: string; matchType: MatchType; - sourceUrl?: string; thumbnailUrl?: string; updatedAt: number; } +/** + * ページ系(個人 / 共有ノート)検索結果用のカード項目。 + * + * Card item for a page-kind search result (personal page or shared note page). + * + * @property pageId - 対象ページ ID。Target page id. + * @property noteId - 共有ノート所属ページの場合に設定されるノート ID。 + * Owning note id when the page is note-native (used for /notes/:noteId/:pageId routing). + * @property sourceUrl - クリップ系ページに紐づく出典 URL。Source URL for clipped pages. + */ +export interface SearchResultCardPageItem extends SearchResultCardBase { + kind: "page"; + pageId: string; + noteId?: string; + sourceUrl?: string; +} + +/** + * Issue #864: PDF ハイライト本文ヒット用のカード項目。 + * クリック時は派生 Zedi ページがあればそちらへ、なければ PDF ビューアへ deep-link。 + * + * Issue #864: card item for a `pdf_highlights.text` match. Clicking routes to + * the derived Zedi page when one exists, otherwise deep-links to the PDF + * viewer at `/sources/:sourceId/pdf#page=N`. + * + * @property highlightId - ハイライトの一意 ID。Highlight UUID. + * @property sourceId - 元 PDF ソース ID。Owning PDF source id (`sources.id`). + * @property pdfPage - 1 始まりの PDF ページ番号。1-indexed PDF page number. + * @property derivedPageId - 派生 Zedi ページがあれば ID、なければ null。 + * Derived Zedi page id when one exists, otherwise null. + */ +export interface SearchResultCardPdfHighlightItem extends SearchResultCardBase { + kind: "pdf_highlight"; + highlightId: string; + sourceId: string; + pdfPage: number; + derivedPageId: string | null; +} + function formatDate(ts: number): string { if (ts <= 0 || !Number.isFinite(ts)) return ""; const d = new Date(ts); @@ -38,13 +82,14 @@ interface SearchResultCardProps { * */ export function SearchResultCard({ item, onClick }: SearchResultCardProps) { - /** - * - */ const { resolvedUrl: thumbnailSrc, hasError: thumbnailError } = useAuthenticatedImageUrl( item.thumbnailUrl, ); + const isPdf = item.kind === "pdf_highlight"; + const isShared = item.kind === "page" && Boolean(item.noteId); + const hasSourceUrl = item.kind === "page" && Boolean(item.sourceUrl); + return (