Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
451 changes: 424 additions & 27 deletions server/api/src/__tests__/routes/search.test.ts

Large diffs are not rendered by default.

177 changes: 170 additions & 7 deletions server/api/src/routes/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) => {
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<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;
62 changes: 6 additions & 56 deletions src/components/layout/Header/HeaderSearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<HTMLInputElement | null>;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<HTMLInputElement>(null);
/**
*
*/
const listRef = useRef<HTMLUListElement>(null);
/**
*
*/
const footerRef = useRef<HTMLButtonElement>(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(() => {
Expand All @@ -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 (
Expand Down
Loading
Loading