-
Notifications
You must be signed in to change notification settings - Fork 0
feat(search): surface PDF highlight bodies in Global Search (#864) #873
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<AppEnv>(); | ||
|
|
||
| app.get("/", authRequired, async (c) => { | ||
|
|
@@ -65,10 +98,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 +131,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 +154,65 @@ app.get("/", authRequired, async (c) => { | |
| ORDER BY p.updated_at DESC | ||
| LIMIT ${limit} | ||
| `); | ||
| pageRows = ownResults.rows; | ||
| } | ||
|
|
||
| return c.json({ results: results.rows }); | ||
| // 既存ページ行に kind="page" の識別子を付与してから、ハイライト結果と結合する。 | ||
| // Tag every page row with `kind: "page"` before merging with highlight rows. | ||
| const taggedPageRows = pageRows.map((row) => ({ | ||
| ...(row as Record<string, unknown>), | ||
| kind: "page" as const, | ||
| })); | ||
|
|
||
| const highlightRows = await runPdfHighlightSearch(db, userId, pattern, limit); | ||
|
|
||
| return c.json({ results: [...taggedPageRows, ...highlightRows] }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new flow applies Useful? React with 👍 / 👎. |
||
| }); | ||
|
|
||
| /** | ||
| * `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<Array<Record<string, unknown>>> { | ||
| 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<string, unknown>), | ||
| kind: "pdf_highlight" as const, | ||
| })); | ||
| } | ||
|
|
||
| export default app; | ||
Uh oh!
There was an error while loading. Please reload this page.