/** * Search — shared pure logic (TASK-0098). * * One result model for BOTH search surfaces: the ⌘K dialog * (`components/search/search-dialog.tsx`) and the `/w/:workspace/search` page * share `SearchResult` (lib/types.ts), `groupResultsByType`, `deepLinkFor` * and the recent-list semantics here. Pure functions only — no DOM, no * network — so they are unit-testable in node-env (see __tests__). */ import type { SearchResult, SearchType } from "./types"; /* ------------------------------------------------------------- constants */ /** The 7 searchable types in a stable display order (fixed for grouping). */ export const SEARCH_TYPES: SearchType[] = [ "page", "database", "record", "block", "comment", "file", "user" ]; /** Human labels for the type filter dropdown + group headers. */ export const SEARCH_TYPE_LABELS: Record = { page: "Pages", database: "Databases", record: "Records", block: "Blocks", comment: "Comments", file: "Files", user: "Users" }; /** The server-side cap on remembered searches (Redis, per user). */ export const RECENT_CAP = 20; /** Debounce window for the input → committed query (the dialog). */ export const SEARCH_DEBOUNCE_MS = 225; /* ----------------------------------------------------------------- types */ export interface SearchGroup { type: SearchType; label: string; items: SearchResult[]; } /* ------------------------------------------------------------- grouping */ /** * Group flat results by type, in the fixed `SEARCH_TYPES` order, preserving * intra-group order (server relevance order). Items of a type not in the * known 7 are dropped (defensive — the API never emits them). */ export function groupResultsByType(items: readonly SearchResult[]): SearchGroup[] { const groups = new Map(); for (const item of items) { if (!SEARCH_TYPES.includes(item.type)) continue; const arr = groups.get(item.type); if (arr) arr.push(item); else groups.set(item.type, [item]); } return SEARCH_TYPES.filter((t) => groups.has(t)).map((t) => ({ type: t, label: SEARCH_TYPE_LABELS[t], items: groups.get(t) as SearchResult[] })); } /* ------------------------------------------------------------ deep links */ /** * The deep link for a result row (route segments use the workspace SLUG — * the app's route convention; the slug is also what the [workspace] param * carries when the user navigated by slug): * page → /w/{ws}/page/{id} * database → /w/{ws}/databases/{id} * record → /w/{ws}/databases/{databaseId ?? id} * block → /w/{ws}/page/{pageId} (null when the parent page is unknown — * the row renders as no-op with a tooltip instead) * others → the item's `url` (comment/file/user) when present, else the * containing page when derivable (pageId), else null. * * Returns null when no meaningful target exists (the caller shows a muted * no-op row; nothing to click through to). */ export function deepLinkFor(item: SearchResult, workspaceSlug: string | null): string | null { if (!workspaceSlug) return null; const ws = encodeURIComponent(workspaceSlug); switch (item.type) { case "page": return item.id ? `/w/${ws}/page/${encodeURIComponent(item.id)}` : null; case "database": return item.id ? `/w/${ws}/databases/${encodeURIComponent(item.id)}` : null; case "record": return `/w/${ws}/databases/${encodeURIComponent(item.databaseId ?? item.id)}`; case "block": return item.pageId ? `/w/${ws}/page/${encodeURIComponent(item.pageId)}` : null; default: if (item.url) return item.url; return item.pageId ? `/w/${ws}/page/${encodeURIComponent(item.pageId)}` : null; } } /* ------------------------------------------------- command palette map */ /** * The command palette's read shape (its two read sites: `r.id/r.title/r.type` * + `r.path` hint + `r.snippet` keywords + the navigate target). The Phase-8 * wire `SearchResult` carries the same fields (no `path` on the wire), so the * palette keeps its logic untouched: `path` is derived client-side from the * parent pointers, and `id` is resolved to the palette's navigate target — * the containing PAGE for a block, the containing DATABASE for a record * (the /databases/:id route); page and database hits keep their own id. */ export function paletteSearchResult(item: SearchResult): SearchResult { const path: string[] = []; if (item.type === "block") path.push("Block"); if (item.type === "record") path.push("Database record"); if (item.type === "database") path.push("Database"); const id = item.type === "block" ? item.pageId ?? item.id : item.type === "record" ? item.databaseId ?? item.id : item.id; return { ...item, id, ...(path.length > 0 ? { path } : {}) }; } /* -------------------------------------------------------- recent searches */ /** * Recent-list reducer step mirroring the server semantics of * `PUT /search/recent`: the query moves to the FRONT (newest first), exact * duplicates are removed from their previous position, the list is capped at * `RECENT_CAP`. `null` input = unchanged. Pure — testable without the API. */ export function addRecent(list: readonly string[], query: string | null): string[] { if (!query) return [...list]; const q = query.trim(); if (!q) return [...list]; return [q, ...list.filter((entry) => entry !== q)].slice(0, RECENT_CAP); } /** Whether `q` is stable enough to record (non-empty after trim). */ export function isRecordableQuery(q: string): boolean { return q.trim().length > 0; } /* --------------------------------------------- recently-viewed + saved (T-153/T-154) */ /** The four viewable content kinds the server records (`PUT /users/me/recent`). */ export type RecentViewType = "page" | "database" | "whiteboard" | "task"; /** A recently-viewed row (the `GET /users/me/recent` wire shape, newest first). */ export interface RecentViewItem { itemType: RecentViewType; refId: string; at: string; } /** The dialog shows only this many recently-viewed items (keeps title * resolution to a small parallel fetch; the server cap is 50). */ export const RECENT_VIEWED_CAP = 5; /** The query a saved search stores (the subset of `POST /search` we surface). */ export interface SavedSearchQuery { q: string; types?: SearchType[]; workspaceId?: string; } /** A saved-search row (`GET /users/me/saved-searches`). `queryJson` is the * same shape as the `POST /search` body. */ export interface SavedSearch { id: string; name: string; workspaceId: string | null; queryJson: SavedSearchQuery & Record; createdAt: string; updatedAt: string; } /** A saved search's display label: its query (truncated), else a fallback. * Pure — the save button derives the default name from the current query. */ export function savedSearchLabel(query: { q?: string } | null | undefined, fallback = "Saved search"): string { const q = query?.q?.trim(); if (!q) return fallback; return q.length > 40 ? `${q.slice(0, 39)}…` : q; } /** The route a recently-viewed item opens (page/whiteboard/task → the page * route; database → the database route). `null` when there is no workspace * to scope it to. Pure. */ export function recentItemHref(item: RecentViewItem, slug: string): string | null { if (!slug) return null; const ws = encodeURIComponent(slug); if (item.itemType === "database") return `/w/${ws}/databases/${encodeURIComponent(item.refId)}`; return `/w/${ws}/page/${encodeURIComponent(item.refId)}`; }