1/** 2 * Search — shared pure logic (TASK-0098). 3 * 4 * One result model for BOTH search surfaces: the ⌘K dialog 5 * (`components/search/search-dialog.tsx`) and the `/w/:workspace/search` page 6 * share `SearchResult` (lib/types.ts), `groupResultsByType`, `deepLinkFor` 7 * and the recent-list semantics here. Pure functions only — no DOM, no 8 * network — so they are unit-testable in node-env (see __tests__). 9 */ 10import type { SearchResult, SearchType } from "./types"; 11 12/* ------------------------------------------------------------- constants */ 13 14/** The 7 searchable types in a stable display order (fixed for grouping). */ 15export const SEARCH_TYPES: SearchType[] = [ 16 "page", 17 "database", 18 "record", 19 "block", 20 "comment", 21 "file", 22 "user" 23]; 24 25/** Human labels for the type filter dropdown + group headers. */ 26export const SEARCH_TYPE_LABELS: Record<SearchType, string> = { 27 page: "Pages", 28 database: "Databases", 29 record: "Records", 30 block: "Blocks", 31 comment: "Comments", 32 file: "Files", 33 user: "Users" 34}; 35 36/** The server-side cap on remembered searches (Redis, per user). */ 37export const RECENT_CAP = 20; 38 39/** Debounce window for the input → committed query (the dialog). */ 40export const SEARCH_DEBOUNCE_MS = 225; 41 42/* ----------------------------------------------------------------- types */ 43 44export interface SearchGroup { 45 type: SearchType; 46 label: string; 47 items: SearchResult[]; 48} 49 50/* ------------------------------------------------------------- grouping */ 51 52/** 53 * Group flat results by type, in the fixed `SEARCH_TYPES` order, preserving 54 * intra-group order (server relevance order). Items of a type not in the 55 * known 7 are dropped (defensive — the API never emits them). 56 */ 57export function groupResultsByType(items: readonly SearchResult[]): SearchGroup[] { 58 const groups = new Map<SearchType, SearchResult[]>(); 59 for (const item of items) { 60 if (!SEARCH_TYPES.includes(item.type)) continue; 61 const arr = groups.get(item.type); 62 if (arr) arr.push(item); 63 else groups.set(item.type, [item]); 64 } 65 return SEARCH_TYPES.filter((t) => groups.has(t)).map((t) => ({ 66 type: t, 67 label: SEARCH_TYPE_LABELS[t], 68 items: groups.get(t) as SearchResult[] 69 })); 70} 71 72/* ------------------------------------------------------------ deep links */ 73 74/** 75 * The deep link for a result row (route segments use the workspace SLUG — 76 * the app's route convention; the slug is also what the [workspace] param 77 * carries when the user navigated by slug): 78 * page → /w/{ws}/page/{id} 79 * database → /w/{ws}/databases/{id} 80 * record → /w/{ws}/databases/{databaseId ?? id} 81 * block → /w/{ws}/page/{pageId} (null when the parent page is unknown — 82 * the row renders as no-op with a tooltip instead) 83 * others → the item's `url` (comment/file/user) when present, else the 84 * containing page when derivable (pageId), else null. 85 * 86 * Returns null when no meaningful target exists (the caller shows a muted 87 * no-op row; nothing to click through to). 88 */ 89export function deepLinkFor(item: SearchResult, workspaceSlug: string | null): string | null { 90 if (!workspaceSlug) return null; 91 const ws = encodeURIComponent(workspaceSlug); 92 switch (item.type) { 93 case "page": 94 return item.id ? `/w/${ws}/page/${encodeURIComponent(item.id)}` : null; 95 case "database": 96 return item.id ? `/w/${ws}/databases/${encodeURIComponent(item.id)}` : null; 97 case "record": 98 return `/w/${ws}/databases/${encodeURIComponent(item.databaseId ?? item.id)}`; 99 case "block": 100 return item.pageId ? `/w/${ws}/page/${encodeURIComponent(item.pageId)}` : null; 101 default: 102 if (item.url) return item.url; 103 return item.pageId ? `/w/${ws}/page/${encodeURIComponent(item.pageId)}` : null; 104 } 105} 106 107/* ------------------------------------------------- command palette map */ 108 109/** 110 * The command palette's read shape (its two read sites: `r.id/r.title/r.type` 111 * + `r.path` hint + `r.snippet` keywords + the navigate target). The Phase-8 112 * wire `SearchResult` carries the same fields (no `path` on the wire), so the 113 * palette keeps its logic untouched: `path` is derived client-side from the 114 * parent pointers, and `id` is resolved to the palette's navigate target — 115 * the containing PAGE for a block, the containing DATABASE for a record 116 * (the /databases/:id route); page and database hits keep their own id. 117 */ 118export function paletteSearchResult(item: SearchResult): SearchResult { 119 const path: string[] = []; 120 if (item.type === "block") path.push("Block"); 121 if (item.type === "record") path.push("Database record"); 122 if (item.type === "database") path.push("Database"); 123 const id = 124 item.type === "block" 125 ? item.pageId ?? item.id 126 : item.type === "record" 127 ? item.databaseId ?? item.id 128 : item.id; 129 return { 130 ...item, 131 id, 132 ...(path.length > 0 ? { path } : {}) 133 }; 134} 135 136/* -------------------------------------------------------- recent searches */ 137 138/** 139 * Recent-list reducer step mirroring the server semantics of 140 * `PUT /search/recent`: the query moves to the FRONT (newest first), exact 141 * duplicates are removed from their previous position, the list is capped at 142 * `RECENT_CAP`. `null` input = unchanged. Pure — testable without the API. 143 */ 144export function addRecent(list: readonly string[], query: string | null): string[] { 145 if (!query) return [...list]; 146 const q = query.trim(); 147 if (!q) return [...list]; 148 return [q, ...list.filter((entry) => entry !== q)].slice(0, RECENT_CAP); 149} 150 151/** Whether `q` is stable enough to record (non-empty after trim). */ 152export function isRecordableQuery(q: string): boolean { 153 return q.trim().length > 0; 154} 155 156/* --------------------------------------------- recently-viewed + saved (T-153/T-154) */ 157 158/** The four viewable content kinds the server records (`PUT /users/me/recent`). */ 159export type RecentViewType = "page" | "database" | "whiteboard" | "task"; 160 161/** A recently-viewed row (the `GET /users/me/recent` wire shape, newest first). */ 162export interface RecentViewItem { 163 itemType: RecentViewType; 164 refId: string; 165 at: string; 166} 167 168/** The dialog shows only this many recently-viewed items (keeps title 169 * resolution to a small parallel fetch; the server cap is 50). */ 170export const RECENT_VIEWED_CAP = 5; 171 172/** The query a saved search stores (the subset of `POST /search` we surface). */ 173export interface SavedSearchQuery { 174 q: string; 175 types?: SearchType[]; 176 workspaceId?: string; 177} 178 179/** A saved-search row (`GET /users/me/saved-searches`). `queryJson` is the 180 * same shape as the `POST /search` body. */ 181export interface SavedSearch { 182 id: string; 183 name: string; 184 workspaceId: string | null; 185 queryJson: SavedSearchQuery & Record<string, unknown>; 186 createdAt: string; 187 updatedAt: string; 188} 189 190/** A saved search's display label: its query (truncated), else a fallback. 191 * Pure — the save button derives the default name from the current query. */ 192export function savedSearchLabel(query: { q?: string } | null | undefined, fallback = "Saved search"): string { 193 const q = query?.q?.trim(); 194 if (!q) return fallback; 195 return q.length > 40 ? `${q.slice(0, 39)}…` : q; 196} 197 198/** The route a recently-viewed item opens (page/whiteboard/task → the page 199 * route; database → the database route). `null` when there is no workspace 200 * to scope it to. Pure. */ 201export function recentItemHref(item: RecentViewItem, slug: string): string | null { 202 if (!slug) return null; 203 const ws = encodeURIComponent(slug); 204 if (item.itemType === "database") return `/w/${ws}/databases/${encodeURIComponent(item.refId)}`; 205 return `/w/${ws}/page/${encodeURIComponent(item.refId)}`; 206} 207