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

Large diffs are not rendered by default.

104 changes: 99 additions & 5 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 Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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] });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce a single response limit after merging result kinds

The new flow applies LIMIT ${limit} to the page query and again to the highlight query, then concatenates both arrays without a final cap, so /api/search can now return up to 2 * limit rows (e.g., 40 when limit is 20). This changes the endpoint’s effective limit semantics and can increase payload size/latency for callers that rely on limit as a hard bound; apply a final sort/slice (or split the limit budget) before returning results.

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;
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
77 changes: 51 additions & 26 deletions src/components/layout/Header/HeaderSearchDropdownContent.tsx
Original file line number Diff line number Diff line change
@@ -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 = "ページが見つかりません";

Expand All @@ -11,20 +12,36 @@ 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;
hasQuery: boolean;
listRef: React.RefObject<HTMLUListElement | null>;
footerRef: React.RefObject<HTMLButtonElement | null>;
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}`;
}

/**
*
*/
Expand Down Expand Up @@ -73,29 +90,37 @@ export function HeaderSearchDropdownContent({
候補 ({searchResults.length}件)
</p>
<ul ref={listRef} className="list-none" role="group" aria-label="検索候補">
{searchResults.map(({ pageId, noteId, title, sourceUrl }, index) => (
<li key={noteId ? `shared-${noteId}-${pageId}` : `personal-${pageId}`} role="none">
<button
id={getOptionId(index)}
type="button"
role="option"
aria-selected={activeIndex === index}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm outline-none",
activeIndex === index ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onClick={() => onSelectItem(pageId, noteId)}
onMouseEnter={() => setActiveIndex(index)}
>
{sourceUrl ? (
<LinkIcon className="text-muted-foreground h-4 w-4 shrink-0" />
) : (
<FileText className="text-muted-foreground h-4 w-4 shrink-0" />
)}
<span className="flex-1 truncate font-medium">{title}</span>
</button>
</li>
))}
{searchResults.map((item, index) => {
const isPdf = item.kind === "pdf_highlight";
return (
<li key={getResultKey(item)} role="none">
<button
id={getOptionId(index)}
type="button"
role="option"
aria-selected={activeIndex === index}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm outline-none",
activeIndex === index ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onClick={() => onSelectItem(item)}
onMouseEnter={() => setActiveIndex(index)}
>
{isPdf ? (
<BookOpen className="text-muted-foreground h-4 w-4 shrink-0" />
) : item.kind === "page" && item.sourceUrl ? (
<LinkIcon className="text-muted-foreground h-4 w-4 shrink-0" />
) : (
<FileText className="text-muted-foreground h-4 w-4 shrink-0" />
)}
<span className="flex-1 truncate font-medium">{item.title}</span>
{isPdf && (
<span className="text-muted-foreground shrink-0 text-[10px]">PDF</span>
)}
</button>
</li>
);
})}
</ul>
</div>
)}
Expand Down
Loading
Loading