Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
360 changes: 333 additions & 27 deletions server/api/src/__tests__/routes/search.test.ts

Large diffs are not rendered by default.

152 changes: 145 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,103 @@ 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);

// ページ検索とハイライト検索のそれぞれが `LIMIT ${limit}` を持つので、無加工に連結
// すると endpoint は最大 2*limit 行返してしまう (PR #873 review: codex)。クライアント
// 契約上 `limit` はハード上限なので、結合後に再度クリップする。元の DESC 順は
// 種別ごとに維持されるが、種別を跨いだ並び替えはクライアント側のスコアリングに任せる。
//
// The page query and the highlight query each apply `LIMIT ${limit}`, so a
// naïve concat would return up to 2*limit rows (PR #873 review: codex).
// We re-clip to `limit` after the merge to keep the contract a hard cap;
// cross-kind ordering is left to the client-side scoring layer.
const merged = [...taggedPageRows, ...highlightRows].slice(0, limit);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return c.json({ results: merged });
});

/**
* `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