From 95c15d8b53dbce3f3bd8a0796d427d036d995b03 Mon Sep 17 00:00:00 2001 From: tobomobo <57799306+tobomobo@users.noreply.github.com> Date: Mon, 25 May 2026 11:49:00 +0200 Subject: [PATCH] Build typed global search results --- ui-tauri/src/components/kb/AppShell.tsx | 390 +++++-------- .../src/components/kb/search/appSearch.ts | 534 ++++++++++++++++++ ui-tauri/src/components/kb/search/index.ts | 4 + .../src/components/kb/search/ranking.test.ts | 304 ++++++++++ ui-tauri/src/components/kb/search/ranking.ts | 263 +++++++++ .../src/components/kb/search/transactions.ts | 70 +++ ui-tauri/src/components/kb/search/types.ts | 114 ++++ 7 files changed, 1429 insertions(+), 250 deletions(-) create mode 100644 ui-tauri/src/components/kb/search/appSearch.ts create mode 100644 ui-tauri/src/components/kb/search/index.ts create mode 100644 ui-tauri/src/components/kb/search/ranking.test.ts create mode 100644 ui-tauri/src/components/kb/search/ranking.ts create mode 100644 ui-tauri/src/components/kb/search/transactions.ts create mode 100644 ui-tauri/src/components/kb/search/types.ts diff --git a/ui-tauri/src/components/kb/AppShell.tsx b/ui-tauri/src/components/kb/AppShell.tsx index 269fe889..c27f1cd5 100644 --- a/ui-tauri/src/components/kb/AppShell.tsx +++ b/ui-tauri/src/components/kb/AppShell.tsx @@ -20,6 +20,7 @@ import { Database, Eye, EyeOff, + FileSearch, Fingerprint, Gauge, Heart, @@ -113,7 +114,6 @@ import { setSessionUnlockPassphrase, verifySessionUnlockPassphrase, } from "@/store/sessionLock"; -import { isTransactionLookupQuery } from "@/lib/transactionLookup"; import type { OverviewSnapshot } from "@/mocks/seed"; import type { ProfilesSnapshot } from "@/mocks/profiles"; import { AssistantSessionProvider } from "@/components/ai/AssistantSessionProvider"; @@ -125,6 +125,16 @@ import { PreAlphaBanner } from "./PreAlphaBanner"; import { useJournalProcessingAction } from "@/hooks/useJournalProcessingAction"; import { useWalletSyncAction } from "@/hooks/useWalletSyncAction"; import { BookSwitcherPopover } from "./BookSwitcherPopover"; +import { + buildAppSearchResults, + isLikelyTransactionLookupQuery, + isSearchResultActivatable, + searchResultForActivation, + type RankedSearchResult, + type ResolvedTransactionLookup, + type SearchActionId, + type SearchIconKey, +} from "./search"; import { dispatchMenuIntent, @@ -151,29 +161,6 @@ type RouteMeta = { searchPlaceholder: string; }; -type SearchResult = { - id: string; - title: string; - detail: string; - keywords: string[]; - to: AppRoutePath | "/connections/$connectionId"; - connectionId?: string; - transactionId?: string; -}; - -type ResolvedTransactionSearch = { - transaction?: { - id: string; - externalId?: string | null; - explorerId?: string | null; - account?: string; - type?: string; - counter?: string; - date?: string; - } | null; - query: string; -}; - type NotificationItem = Omit & { createdAt?: string; to?: AppRoutePath; @@ -344,218 +331,49 @@ const ROUTE_META: Array<[string, RouteMeta]> = [ ], ]; -const STATIC_SEARCH_RESULTS: SearchResult[] = [ - { - id: "route:overview", - title: "Overview", - detail: "Portfolio, balance, activity", - keywords: ["dashboard", "home", "balance", "portfolio"], - to: "/overview", - }, - { - id: "route:transactions", - title: "Transactions", - detail: "Transaction rows and filters", - keywords: ["tx", "counterparty", "account", "amount", "import"], - to: "/transactions", - }, - { - id: "route:connections", - title: "Wallets", - detail: "Wallets, imports, backends, and sync", - keywords: ["connections", "wallets", "xpub", "backend", "sync"], - to: "/connections", - }, - { - id: "route:books", - title: "Books", - detail: "Books and tax settings", - keywords: ["book", "books", "tax", "country"], - to: "/books", - }, - { - id: "route:source-of-funds", - title: "Source of Funds", - detail: "Wallet sources and local provenance summaries", - keywords: ["source", "funds", "wallet", "balance", "provenance"], - to: "/source-of-funds", - }, - { - id: "route:journals", - title: "Ledger", - detail: "Processed tax ledger", - keywords: ["journal", "process", "entries", "fees", "basis", "ledger"], - to: "/journals", - }, - { - id: "route:reports", - title: "Reports", - detail: "Capital gains and exports", - keywords: ["csv", "pdf", "xlsx", "tax", "austria", "e1kv"], - to: "/reports", - }, - { - id: "route:quarantine", - title: "Quarantine", - detail: "Review ambiguous rows", - keywords: ["review", "issues", "missing", "price"], - to: "/quarantine", - }, - { - id: "route:swaps-transfers", - title: "Swaps & Transfers", - detail: "Review candidate swap and transfer pairings", - keywords: ["swap", "swaps", "transfer", "transfers", "review", "pair"], - to: "/swaps", - }, - { - id: "route:settings", - title: "Settings", - detail: "Preferences, integrations, local data", - keywords: ["preferences", "backends", "providers", "privacy", "lock"], - to: "/settings", - }, - { - id: "route:logs", - title: "Logs", - detail: "Typed local log stream and redacted troubleshooting export", - keywords: ["log", "logs", "error", "daemon", "download"], - to: "/logs", - }, - { - id: "route:assistant", - title: "Assistant", - detail: "Ask Kassiber", - keywords: ["chat", "ai", "tools"], - to: "/assistant", - }, -]; - -function searchMatches(result: SearchResult, query: string) { - const terms = query - .trim() - .toLowerCase() - .split(/\s+/) - .filter(Boolean); - if (!terms.length) return false; - const haystack = [ - result.title, - result.detail, - ...result.keywords, - ] - .join(" ") - .toLowerCase(); - return terms.every((term) => haystack.includes(term)); +function nextSearchIndex(current: number, delta: number, total: number) { + if (total <= 0) return 0; + return (current + delta + total) % total; } -function buildSearchResults( - snapshot: OverviewSnapshot | undefined, - query: string, - aiFeaturesEnabled: boolean, - developerToolsEnabled: boolean, - resolvedTransaction?: ResolvedTransactionSearch | null, -): SearchResult[] { - if (!query.trim()) return []; - - const resolvedTransactionResult: SearchResult[] = - resolvedTransaction?.transaction && - resolvedTransaction.query.trim().toLowerCase() === query.trim().toLowerCase() - ? [ - { - id: `resolved-tx:${resolvedTransaction.transaction.id}`, - title: "Open transaction", - detail: [ - resolvedTransaction.transaction.account, - resolvedTransaction.transaction.type, - resolvedTransaction.transaction.date, - ] - .filter(Boolean) - .join(" · "), - keywords: [ - "transaction", - "tx", - "txid", - resolvedTransaction.transaction.id, - resolvedTransaction.transaction.externalId ?? "", - resolvedTransaction.transaction.explorerId ?? "", - resolvedTransaction.transaction.counter ?? "", - ], - to: "/transactions" as const, - transactionId: resolvedTransaction.transaction.id, - }, - ] - : []; - - const dynamicResults: SearchResult[] = [ - ...(snapshot?.connections.map((connection) => ({ - id: `connection:${connection.id}`, - title: connection.label, - detail: `${connection.kind.toUpperCase()} · ${connection.status}`, - keywords: [ - "connection", - "wallet", - "sync", - connection.kind, - connection.status, - ], - to: "/connections/$connectionId" as const, - connectionId: connection.id, - })) ?? []), - ...(snapshot?.txs.map((tx) => ({ - id: `tx:${tx.id}`, - title: `${tx.id} · ${tx.counter}`, - detail: `${tx.account} · ${tx.type} · ${tx.tag}`, - keywords: [ - "transaction", - "transactions", - tx.id, - tx.account, - tx.counter, - tx.type, - tx.tag, - ], - to: "/transactions" as const, - })) ?? []), - ...(snapshot?.status?.needsJournals - ? [ - { - id: "status:journals", - title: "Ledger needs processing", - detail: "Reports are stale until journal processing runs", - keywords: ["journal", "reports", "stale", "process"], - to: "/journals" as const, - }, - ] - : []), - ...((snapshot?.status?.quarantines ?? 0) > 0 - ? [ - { - id: "status:quarantine", - title: "Transactions quarantined", - detail: `${snapshot?.status?.quarantines ?? 0} rows need review`, - keywords: ["quarantine", "review", "missing", "price"], - to: "/quarantine" as const, - }, - ] - : []), - ]; +const SEARCH_ICON_BY_KEY: Record< + SearchIconKey | string, + React.ComponentType> +> = { + activity: Gauge, + assistant: MessageSquareText, + book: BookOpen, + database: Database, + file_search: FileSearch, + ledger: ClipboardList, + lock: LockKeyhole, + logs: TerminalSquare, + report: BarChart3, + search: Search, + settings: Settings, + shield: ShieldAlert, + sync: ArrowLeftRight, + transaction: ArrowLeftRight, + wallet: Wallet, +}; - return [ - ...resolvedTransactionResult, - ...STATIC_SEARCH_RESULTS.filter((result) => { - if (!aiFeaturesEnabled && result.to === "/assistant") return false; - if (!developerToolsEnabled && result.to === "/logs") return false; - return true; - }), - ...dynamicResults, - ] - .filter((result) => searchMatches(result, query)) - .slice(0, 8); +const SEARCH_CATEGORY_LABELS: Record = { + action: "Action", + page: "Page", + report: "Report", + review_item: "Review", + setting: "Setting", + transaction: "Transaction", + wallet: "Wallet", +}; + +function searchResultIcon(result: RankedSearchResult) { + const key = result.iconKey ?? result.category; + return SEARCH_ICON_BY_KEY[key] ?? Search; } -function nextSearchIndex(current: number, delta: number, total: number) { - if (total <= 0) return 0; - return (current + delta + total) % total; +function exhaustiveSearchAction(actionId: never): never { + throw new Error(`Unhandled search action: ${actionId}`); } function notificationRouteFor(title: string): AppRoutePath | undefined { @@ -1721,6 +1539,9 @@ function AppDashboardHeader({ const clearNotifications = useUiStore((s) => s.clearNotifications); const aiFeaturesEnabled = useUiStore((s) => s.aiFeaturesEnabled); const developerToolsEnabled = useUiStore((s) => s.developerToolsEnabled); + const setDeferredConnectionSetup = useUiStore( + (s) => s.setDeferredConnectionSetup, + ); const { runJournalProcessing, isProcessingJournals } = useJournalProcessingAction(); const [searchQuery, setSearchQuery] = React.useState(""); @@ -1735,55 +1556,104 @@ function AppDashboardHeader({ { enabled: daemonEnabled }, ); const snapshot = data?.data; - const shouldResolveTransaction = isTransactionLookupQuery(searchQuery); - const resolvedTransaction = useDaemon( + const shouldResolveTransaction = isLikelyTransactionLookupQuery(searchQuery); + const resolvedTransaction = useDaemon( "ui.transactions.resolve", { query: searchQuery.trim() }, { enabled: daemonEnabled && shouldResolveTransaction }, ); const searchResults = React.useMemo( () => - buildSearchResults( + buildAppSearchResults({ snapshot, - searchQuery, + query: searchQuery, aiFeaturesEnabled, developerToolsEnabled, - resolvedTransaction.data?.data ?? null, - ), + resolvedTransaction: resolvedTransaction.data?.data ?? null, + isResolvingTransaction: + shouldResolveTransaction && + (resolvedTransaction.isFetching || resolvedTransaction.isLoading), + }), [ snapshot, searchQuery, aiFeaturesEnabled, developerToolsEnabled, resolvedTransaction.data?.data, + resolvedTransaction.isFetching, + resolvedTransaction.isLoading, + shouldResolveTransaction, ], ); const searchListId = React.useId(); const searchActiveId = searchResults[activeSearchIndex]?.id ? `search-result-${searchResults[activeSearchIndex].id.replace(/[^a-zA-Z0-9_-]/g, "-")}` : undefined; + const activateSearchAction = React.useCallback( + (actionId: SearchActionId) => { + switch (actionId) { + case "process-journals": + runJournalProcessing(); + return; + case "add-wallet": + case "import-btcpay": + setDeferredConnectionSetup({ + sourceId: actionId === "import-btcpay" ? "btcpay" : "descriptor", + reason: "Opened from global search", + }); + void navigate({ to: "/connections" }); + return; + default: + exhaustiveSearchAction(actionId); + } + }, + [navigate, runJournalProcessing, setDeferredConnectionSetup], + ); const activateSearchResult = React.useCallback( - (result: SearchResult | undefined) => { + (result: RankedSearchResult | undefined) => { if (!result) return; + const actionId = result.action?.id; + if (actionId) { + setSearchOpen(false); + setSearchQuery(""); + activateSearchAction(actionId); + return; + } + + const route = result.route; + if (!route) return; setSearchOpen(false); setSearchQuery(""); - if (result.to === "/connections/$connectionId" && result.connectionId) { + if ( + route.to === "/connections/$connectionId" && + typeof route.params?.connectionId === "string" + ) { void navigate({ to: "/connections/$connectionId", - params: { connectionId: result.connectionId }, + params: { connectionId: route.params.connectionId }, }); return; } - if (result.to === "/transactions" && result.transactionId) { + if (route.to === "/connections/$connectionId") return; + if (route.to === "/transactions" && typeof route.search?.tx === "string") { void navigate({ to: "/transactions", - search: { tx: result.transactionId }, + search: { tx: route.search.tx }, }); return; } - void navigate({ to: result.to }); + if (route.to === "/settings" && route.hash) { + void navigate({ to: "/settings", hash: route.hash }); + window.dispatchEvent( + new CustomEvent("kassiber:settings-section", { + detail: { section: route.hash }, + }), + ); + return; + } + void navigate({ to: route.to }); }, - [navigate], + [activateSearchAction, navigate], ); React.useEffect(() => { @@ -1996,7 +1866,7 @@ function AppDashboardHeader({ aria-expanded={searchOpen} aria-controls={searchListId} aria-activedescendant={searchActiveId} - placeholder="Search Kassiber" + placeholder="Search pages, actions, transactions..." value={searchQuery} onChange={(event) => { setSearchQuery(event.target.value); @@ -2018,7 +1888,10 @@ function AppDashboardHeader({ ); } else if (event.key === "Enter") { event.preventDefault(); - activateSearchResult(searchResults[activeSearchIndex]); + activateSearchResult( + searchResultForActivation(searchResults, activeSearchIndex) ?? + undefined, + ); } else if (event.key === "Escape") { setSearchOpen(false); searchInputRef.current?.blur(); @@ -2040,6 +1913,8 @@ function AppDashboardHeader({ searchResults.map((result, index) => { const active = index === activeSearchIndex; const itemId = `search-result-${result.id.replace(/[^a-zA-Z0-9_-]/g, "-")}`; + const ResultIcon = searchResultIcon(result); + const activatable = isSearchResultActivatable(result); return ( ); diff --git a/ui-tauri/src/components/kb/search/appSearch.ts b/ui-tauri/src/components/kb/search/appSearch.ts new file mode 100644 index 00000000..32bda51d --- /dev/null +++ b/ui-tauri/src/components/kb/search/appSearch.ts @@ -0,0 +1,534 @@ +import type { OverviewSnapshot, Tx } from "@/mocks/seed"; +import { SETTINGS_SECTIONS } from "@/components/kb/settings/SettingsNavigation"; +import type { SettingsMenuSection } from "@/components/kb/menuIntent"; + +import { + isLikelyTransactionLookupQuery, + rankSearchResults, +} from "./ranking"; +import { + transactionLookupStateForQuery, + type ResolvedTransactionLookup, +} from "./transactions"; +import type { + RankedSearchResult, + SearchResult, +} from "./types"; + +type BuildAppSearchOptions = { + snapshot?: OverviewSnapshot; + query: string; + aiFeaturesEnabled: boolean; + developerToolsEnabled: boolean; + resolvedTransaction?: ResolvedTransactionLookup | null; + isResolvingTransaction?: boolean; + limit?: number; +}; + +const SEARCH_LIMIT = 8; + +const PAGE_RESULTS: SearchResult[] = [ + { + id: "page:overview", + category: "page", + title: "Overview", + subtitle: "Portfolio, balance, activity", + keywords: ["dashboard", "home", "balance", "portfolio"], + iconKey: "activity", + route: { to: "/overview" }, + privacyTier: "public", + }, + { + id: "page:transactions", + category: "page", + title: "Transactions", + subtitle: "Transaction rows and filters", + keywords: ["tx", "counterparty", "account", "amount", "import"], + iconKey: "transaction", + route: { to: "/transactions" }, + privacyTier: "public", + }, + { + id: "page:connections", + category: "page", + title: "Wallets", + subtitle: "Wallets, imports, backends, and sync", + keywords: ["connections", "wallets", "xpub", "backend", "sync"], + iconKey: "wallet", + route: { to: "/connections" }, + privacyTier: "public", + }, + { + id: "page:books", + category: "page", + title: "Books", + subtitle: "Books and tax settings", + keywords: ["book", "books", "tax", "country"], + iconKey: "book", + route: { to: "/books" }, + privacyTier: "public", + }, + { + id: "page:source-of-funds", + category: "page", + title: "Source of Funds", + subtitle: "Wallet sources and local provenance summaries", + keywords: ["source", "funds", "wallet", "balance", "provenance"], + iconKey: "shield", + route: { to: "/source-of-funds" }, + privacyTier: "public", + }, + { + id: "page:journals", + category: "page", + title: "Ledger", + subtitle: "Processed tax ledger", + keywords: ["journal", "process", "entries", "fees", "basis", "ledger"], + iconKey: "ledger", + route: { to: "/journals" }, + privacyTier: "public", + }, + { + id: "page:reports", + category: "page", + title: "Reports", + subtitle: "Capital gains and exports", + keywords: ["csv", "pdf", "xlsx", "tax", "austria", "e1kv"], + iconKey: "report", + route: { to: "/reports" }, + privacyTier: "public", + }, + { + id: "page:quarantine", + category: "page", + title: "Quarantine", + subtitle: "Review ambiguous rows", + keywords: ["review", "issues", "missing", "price"], + iconKey: "shield", + route: { to: "/quarantine" }, + privacyTier: "public", + }, + { + id: "page:swaps-transfers", + category: "page", + title: "Swaps & Transfers", + subtitle: "Review candidate swap and transfer pairings", + keywords: ["swap", "swaps", "transfer", "transfers", "review", "pair"], + iconKey: "transaction", + route: { to: "/swaps" }, + privacyTier: "public", + }, + { + id: "page:settings", + category: "page", + title: "Settings", + subtitle: "Preferences, integrations, local data", + keywords: ["preferences", "backends", "providers", "privacy", "lock"], + iconKey: "settings", + route: { to: "/settings" }, + privacyTier: "public", + }, + { + id: "page:logs", + category: "page", + title: "Logs", + subtitle: "Typed local log stream and redacted troubleshooting export", + keywords: ["log", "logs", "error", "daemon", "download"], + iconKey: "logs", + route: { to: "/logs" }, + privacyTier: "public", + }, + { + id: "page:assistant", + category: "page", + title: "Assistant", + subtitle: "Ask Kassiber", + keywords: ["chat", "ai", "tools"], + iconKey: "assistant", + route: { to: "/assistant" }, + privacyTier: "public", + }, +]; + +const ACTION_RESULTS: SearchResult[] = [ + { + id: "action:process-journals", + category: "action", + title: "Process journals", + subtitle: "Refresh local report-ready journal state", + keywords: ["ledger", "journal", "journals", "process", "rebuild", "reports"], + iconKey: "ledger", + action: { id: "process-journals", label: "Process journals" }, + privacyTier: "public", + ranking: { priority: 20 }, + }, + { + id: "action:sync-wallets", + category: "action", + title: "Sync wallets", + subtitle: "Open Connections to refresh watch-only sources", + keywords: ["wallet", "wallets", "sync", "refresh", "connections"], + iconKey: "sync", + route: { to: "/connections" }, + privacyTier: "public", + }, + { + id: "action:add-wallet", + category: "action", + title: "Add wallet", + subtitle: "Open the wallet connection dialog", + keywords: ["wallet", "connection", "descriptor", "xpub", "add", "import"], + iconKey: "wallet", + action: { id: "add-wallet", label: "Add wallet" }, + privacyTier: "public", + }, + { + id: "action:import-btcpay", + category: "action", + title: "Import BTCPay", + subtitle: "Open BTCPay wallet-source setup", + keywords: ["btcpay", "merchant", "store", "invoice", "payment", "import"], + iconKey: "wallet", + action: { id: "import-btcpay", label: "Import BTCPay" }, + privacyTier: "public", + }, + { + id: "action:export-report", + category: "action", + title: "Export report", + subtitle: "Open report export tools", + keywords: ["report", "reports", "export", "pdf", "csv", "xlsx", "tax"], + iconKey: "report", + route: { to: "/reports" }, + privacyTier: "public", + }, + { + id: "action:open-logs", + category: "action", + title: "Open logs", + subtitle: "Open redacted local troubleshooting logs", + keywords: ["logs", "log", "daemon", "debug", "troubleshoot", "support"], + iconKey: "logs", + route: { to: "/logs" }, + privacyTier: "public", + }, + { + id: "action:change-passphrase", + category: "action", + title: "Change passphrase", + subtitle: "Open lock and encryption settings", + keywords: ["password", "passphrase", "security", "lock", "encryption"], + iconKey: "lock", + route: { to: "/settings", hash: "security" }, + metadata: { settingSection: "security" }, + privacyTier: "public", + }, +]; + +export function buildAppSearchResults({ + snapshot, + query, + aiFeaturesEnabled, + developerToolsEnabled, + resolvedTransaction, + isResolvingTransaction, + limit = SEARCH_LIMIT, +}: BuildAppSearchOptions): RankedSearchResult[] { + if (!query.trim()) return []; + + const safeResults = [ + ...resolvedTransactionResults(resolvedTransaction, query), + ...PAGE_RESULTS.filter((result) => { + if (!aiFeaturesEnabled && result.route?.to === "/assistant") return false; + if (!developerToolsEnabled && result.route?.to === "/logs") return false; + return true; + }), + ...ACTION_RESULTS.filter((result) => { + if (!developerToolsEnabled && result.id === "action:open-logs") return false; + return true; + }), + ...settingsResults(), + ...snapshotResults(snapshot, query), + ]; + + const resolvedTransactionId = resolvedTransaction?.transaction?.id ?? null; + const ranked = rankSearchResults(safeResults, query) + .filter( + (result) => + !( + resolvedTransactionId && + result.category === "transaction" && + result.id !== `tx:resolved:${resolvedTransactionId}` && + result.metadata?.transactionId === resolvedTransactionId + ), + ) + .slice(0, limit); + const lookupState = transactionLookupStateForQuery(query, { + isFetching: isResolvingTransaction, + resolved: resolvedTransaction, + }); + const transactionRanked = ranked.filter( + (result) => result.category === "transaction", + ); + const statusResult = transactionLookupStatusResult({ + query, + lookupState, + transactionMatchCount: transactionRanked.length, + }); + if (!statusResult) return ranked; + + const rankedStatus = rankSearchResults([statusResult], query, { limit: 1 })[0]; + if (!rankedStatus) return ranked; + return [rankedStatus, ...ranked].slice(0, limit); +} + +export function isSearchResultActivatable( + result: RankedSearchResult | SearchResult | undefined, +) { + return Boolean(result?.action || result?.route); +} + +export function searchResultForActivation( + results: readonly RankedSearchResult[], + activeIndex: number, +) { + const activeResult = results[activeIndex]; + if (isSearchResultActivatable(activeResult)) return activeResult; + return ( + results.slice(activeIndex + 1).find(isSearchResultActivatable) ?? + results.slice(0, activeIndex).find(isSearchResultActivatable) ?? + null + ); +} + +function resolvedTransactionResults( + resolved: ResolvedTransactionLookup | null | undefined, + query: string, +): SearchResult[] { + const transaction = resolved?.transaction; + if (!transaction) return []; + if (resolved.query?.trim().toLowerCase() !== query.trim().toLowerCase()) { + return []; + } + return [ + { + id: `tx:resolved:${transaction.id}`, + category: "transaction", + title: "Open exact transaction", + subtitle: [ + "Exact txid match", + transaction.account, + transaction.type, + transaction.date, + ] + .filter(Boolean) + .join(" · "), + keywords: [ + "transaction", + "tx", + "txid", + transaction.id, + transaction.externalId ?? "", + transaction.explorerId ?? "", + transaction.counter ?? "", + ], + iconKey: "transaction", + route: { to: "/transactions", search: { tx: transaction.id } }, + metadata: { + transactionId: transaction.id, + externalId: transaction.externalId, + explorerId: transaction.explorerId, + searchTokens: [transaction.counter ?? ""].filter(Boolean), + }, + privacyTier: "book_private", + ranking: { priority: 80 }, + }, + ]; +} + +function settingsResults(): SearchResult[] { + return SETTINGS_SECTIONS.map((section) => ({ + id: `setting:${section.id}`, + category: "setting" as const, + title: section.label, + subtitle: `${section.group} settings · ${section.description}`, + keywords: [ + "settings", + "preferences", + section.slug, + section.group, + section.description, + section.label, + ], + iconKey: "settings", + route: { to: "/settings", hash: section.slug }, + metadata: { + settingSection: section.slug as SettingsMenuSection, + searchTokens: [section.id, section.slug], + }, + privacyTier: "public" as const, + })); +} + +function snapshotResults( + snapshot: OverviewSnapshot | undefined, + query: string, +): SearchResult[] { + return [ + ...(snapshot?.connections.map((connection) => ({ + id: `wallet:${connection.id}`, + category: "wallet" as const, + title: connection.label, + subtitle: `${connection.kind.toUpperCase()} · ${connection.status}`, + keywords: [ + "connection", + "wallet", + "sync", + connection.kind, + connection.status, + ], + iconKey: "wallet", + route: { + to: "/connections/$connectionId" as const, + params: { connectionId: connection.id }, + }, + metadata: { + walletId: connection.id, + walletKind: connection.kind, + }, + privacyTier: "local_metadata" as const, + })) ?? []), + ...(snapshot?.txs.map((tx) => transactionResult(tx, query)) ?? []), + ...(snapshot?.status?.needsJournals + ? [ + { + id: "review:journals", + category: "review_item" as const, + title: "Ledger needs processing", + subtitle: "Reports are stale until journal processing runs", + keywords: ["journal", "reports", "stale", "process"], + iconKey: "ledger", + action: { + id: "process-journals" as const, + label: "Process journals", + }, + privacyTier: "local_metadata" as const, + ranking: { priority: 15 }, + }, + ] + : []), + ...((snapshot?.status?.quarantines ?? 0) > 0 + ? [ + { + id: "review:quarantine", + category: "review_item" as const, + title: "Transactions quarantined", + subtitle: `${snapshot?.status?.quarantines ?? 0} rows need review`, + keywords: ["quarantine", "review", "missing", "price"], + iconKey: "shield", + route: { to: "/quarantine" as const }, + privacyTier: "local_metadata" as const, + }, + ] + : []), + ]; +} + +function transactionResult(tx: Tx, query: string): SearchResult { + const partialTxidMatch = isPartialTransactionQuery(tx, query); + return { + id: `tx:recent:${tx.id}`, + category: "transaction", + title: partialTxidMatch ? "Open partial transaction match" : `${tx.id} · ${tx.counter}`, + subtitle: [ + partialTxidMatch ? "Partial txid match" : null, + tx.account, + tx.type, + tx.tag, + ] + .filter(Boolean) + .join(" · "), + keywords: [ + "transaction", + "transactions", + "tx", + tx.id, + tx.externalId ?? "", + tx.explorerId ?? "", + tx.account, + tx.counter, + tx.type, + tx.tag, + ], + iconKey: "transaction", + route: { to: "/transactions", search: { tx: tx.id } }, + metadata: { + transactionId: tx.id, + externalId: tx.externalId, + explorerId: tx.explorerId, + searchTokens: [tx.account, tx.counter, tx.type, tx.tag], + }, + privacyTier: "book_private", + ranking: { priority: partialTxidMatch ? 40 : 0 }, + }; +} + +function transactionLookupStatusResult({ + query, + lookupState, + transactionMatchCount, +}: { + query: string; + lookupState: ReturnType; + transactionMatchCount: number; +}): SearchResult | null { + if (lookupState === "idle" || lookupState === "matched") return null; + if (transactionMatchCount > 1) { + return { + id: "lookup:transaction:multiple", + category: "review_item", + title: "Multiple transaction matches", + subtitle: "Choose the matching transaction below", + keywords: [query, "transaction", "txid", "multiple"], + iconKey: "transaction", + privacyTier: "local_metadata", + ranking: { priority: 70 }, + }; + } + if (transactionMatchCount === 1) return null; + if (lookupState === "looking_up") { + return { + id: "lookup:transaction:loading", + category: "review_item", + title: "Looking up transaction", + subtitle: "Checking local transaction rows", + keywords: [query, "transaction", "txid", "lookup"], + iconKey: "search", + privacyTier: "local_metadata", + ranking: { priority: 70 }, + }; + } + return { + id: "lookup:transaction:not-found", + category: "review_item", + title: "No local transaction match", + subtitle: "This txid is not in the active book", + keywords: [query, "transaction", "txid", "not found"], + iconKey: "search", + privacyTier: "local_metadata", + ranking: { priority: 70 }, + }; +} + +function isPartialTransactionQuery(tx: Tx, query: string) { + if (!isLikelyTransactionLookupQuery(query)) return false; + const normalizedQuery = query.trim().toLowerCase(); + return [tx.id, tx.externalId, tx.explorerId].some((value) => { + const normalized = value?.trim().toLowerCase(); + if (!normalized || normalized === normalizedQuery) return false; + return ( + normalized.startsWith(normalizedQuery) || + normalizedQuery.startsWith(normalized) + ); + }); +} diff --git a/ui-tauri/src/components/kb/search/index.ts b/ui-tauri/src/components/kb/search/index.ts new file mode 100644 index 00000000..438b6c92 --- /dev/null +++ b/ui-tauri/src/components/kb/search/index.ts @@ -0,0 +1,4 @@ +export * from "./appSearch"; +export * from "./ranking"; +export * from "./transactions"; +export * from "./types"; diff --git a/ui-tauri/src/components/kb/search/ranking.test.ts b/ui-tauri/src/components/kb/search/ranking.test.ts new file mode 100644 index 00000000..3deff445 --- /dev/null +++ b/ui-tauri/src/components/kb/search/ranking.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAppSearchResults, + isLikelyTransactionLookupQuery, + rankSearchResults, + searchResultForActivation, + transactionLookupLabelForState, + transactionLookupStateForQuery, + type SearchResult, +} from "."; +import type { OverviewSnapshot } from "@/mocks/seed"; + +const results: SearchResult[] = [ + { + id: "page:transactions", + category: "page", + title: "Transactions", + subtitle: "Transaction rows and filters", + keywords: [ + "tx", + "counterparty", + "account", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ], + iconKey: "transaction", + route: { to: "/transactions" }, + privacyTier: "public", + }, + { + id: "action:sync", + category: "action", + title: "Sync wallets", + subtitle: "Refresh local wallet rows", + keywords: ["scan", "refresh", "connection"], + iconKey: "sync", + action: { id: "process-journals", requiresConsent: true }, + privacyTier: "local_metadata", + }, + { + id: "tx:exact", + category: "transaction", + title: "Deposit from customer", + subtitle: "Cold Storage - Income", + keywords: ["transaction"], + iconKey: "transaction", + route: { to: "/transactions", search: { tx: "tx-exact" } }, + metadata: { + transactionId: "tx-exact", + explorerId: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + searchTokens: ["customer deposit"], + }, + privacyTier: "book_private", + }, + { + id: "tx:candidate", + category: "transaction", + title: "0123456789ab candidate", + subtitle: "Multisig Vault - Transfer", + keywords: ["transaction"], + iconKey: "transaction", + route: { to: "/transactions", search: { tx: "tx-candidate" } }, + metadata: { + transactionId: "tx-candidate", + searchTokens: ["0123456789ab"], + }, + privacyTier: "book_private", + }, + { + id: "setting:secret", + category: "setting", + title: "Reveal descriptor", + subtitle: "Sensitive wallet material", + keywords: ["descriptor"], + privacyTier: "secret", + }, +]; + +describe("search result ranking", () => { + it("orders exact txid matches ahead of transaction candidates and page matches", () => { + const ranked = rankSearchResults( + results, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); + + expect(ranked.map((result) => result.id).slice(0, 3)).toEqual([ + "tx:exact", + "tx:candidate", + "page:transactions", + ]); + expect(ranked[0].match.reason).toBe("exact_txid"); + expect(ranked[1].match.reason).toBe("transaction_candidate"); + }); + + it("matches page and action results with multi-term queries", () => { + const ranked = rankSearchResults(results, "sync wallet"); + + expect(ranked[0]).toMatchObject({ + id: "action:sync", + category: "action", + }); + }); + + it("keeps secret-tier results out of normal local search", () => { + expect(rankSearchResults(results, "descriptor")).toHaveLength(0); + expect( + rankSearchResults(results, "descriptor", { maxPrivacyTier: "secret" }), + ).toHaveLength(1); + }); + + it("recognizes the transaction lookup queries AppShell already issues", () => { + expect(isLikelyTransactionLookupQuery("0123456789ab")).toBe(true); + expect(isLikelyTransactionLookupQuery("tx:local-123")).toBe(true); + expect( + isLikelyTransactionLookupQuery("550e8400-e29b-41d4-a716-446655440000"), + ).toBe(true); + expect(isLikelyTransactionLookupQuery("transactions")).toBe(false); + }); +}); + +describe("transaction lookup labels", () => { + it("labels lookup states without persisting query text", () => { + const query = "tx:abc123"; + + expect( + transactionLookupLabelForState( + transactionLookupStateForQuery(query, { isFetching: true }), + ), + ).toEqual({ state: "looking_up", label: "Looking up transaction" }); + expect( + transactionLookupLabelForState( + transactionLookupStateForQuery(query, { + resolved: { query, transaction: { id: query } }, + }), + ), + ).toEqual({ state: "matched", label: "Transaction found" }); + expect( + transactionLookupLabelForState(transactionLookupStateForQuery(query)), + ).toEqual({ state: "not_found", label: "No local transaction match" }); + expect( + transactionLookupLabelForState( + transactionLookupStateForQuery("reports"), + ), + ).toEqual({ + state: "idle", + label: "Search pages, actions, and local data", + }); + }); +}); + +describe("app search results", () => { + const txid = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const snapshot = { + priceEur: 0, + priceUsd: 0, + connections: [ + { + id: "wallet-1", + label: "Cold Storage", + kind: "descriptor", + last: "2026-04-18", + balance: 1, + status: "synced", + }, + ], + txs: [ + { + id: "tx1", + explorerId: txid, + date: "2026-04-18 14:22", + type: "Income", + account: "Cold Storage", + counter: "ACME GmbH", + amountSat: 1, + eur: 1, + rate: 1, + tag: "Revenue", + conf: 6, + }, + { + id: "tx12", + explorerId: `${txid.slice(0, 20)}ffffffffffffffffffffffffffffffffffffffffffff`, + date: "2026-04-19 14:22", + type: "Income", + account: "Cold Storage", + counter: "Shop", + amountSat: 1, + eur: 1, + rate: 1, + tag: "Revenue", + conf: 6, + }, + ], + balanceSeries: [], + fiat: { + eurBalance: 0, + eurCostBasis: 0, + eurUnrealized: 0, + eurRealizedYTD: 0, + }, + } as OverviewSnapshot; + + it("puts resolved exact txid results first and opens the transaction route", () => { + const ranked = buildAppSearchResults({ + snapshot, + query: txid.toUpperCase(), + aiFeaturesEnabled: true, + developerToolsEnabled: true, + resolvedTransaction: { + query: txid.toUpperCase(), + transaction: { + id: "tx1", + explorerId: txid, + account: "Cold Storage", + type: "Income", + date: "2026-04-18 14:22", + }, + }, + }); + + expect(ranked[0]).toMatchObject({ + id: "tx:resolved:tx1", + title: "Open exact transaction", + route: { to: "/transactions", search: { tx: "tx1" } }, + }); + }); + + it("surfaces multiple partial transaction matches before the candidate rows", () => { + const ranked = buildAppSearchResults({ + snapshot, + query: txid.slice(0, 12), + aiFeaturesEnabled: true, + developerToolsEnabled: true, + resolvedTransaction: { query: txid.slice(0, 12), transaction: null }, + }); + + expect(ranked[0]).toMatchObject({ + id: "lookup:transaction:multiple", + title: "Multiple transaction matches", + }); + expect(ranked.filter((result) => result.category === "transaction")).toHaveLength(2); + expect(searchResultForActivation(ranked, 0)).toMatchObject({ + id: "tx:recent:tx1", + route: { to: "/transactions", search: { tx: "tx1" } }, + }); + }); + + it("shows a no-local-match state for txid-looking queries", () => { + const ranked = buildAppSearchResults({ + snapshot, + query: "abcdefabcdefabcdef", + aiFeaturesEnabled: true, + developerToolsEnabled: true, + resolvedTransaction: { query: "abcdefabcdefabcdef", transaction: null }, + }); + + expect(ranked[0]).toMatchObject({ + id: "lookup:transaction:not-found", + title: "No local transaction match", + }); + expect(searchResultForActivation(ranked, 0)).toBeNull(); + }); + + it("finds local actions and settings sections", () => { + const ranked = buildAppSearchResults({ + snapshot, + query: "change passphrase", + aiFeaturesEnabled: true, + developerToolsEnabled: true, + }); + + expect(ranked[0]).toMatchObject({ + id: "action:change-passphrase", + category: "action", + route: { to: "/settings", hash: "security" }, + }); + }); + + it("only shows the logs action when developer tools are enabled", () => { + const baseOptions = { + snapshot, + query: "open logs", + aiFeaturesEnabled: true, + }; + + expect( + buildAppSearchResults({ + ...baseOptions, + developerToolsEnabled: false, + }).some((result) => result.id === "action:open-logs"), + ).toBe(false); + expect( + buildAppSearchResults({ + ...baseOptions, + developerToolsEnabled: true, + })[0], + ).toMatchObject({ + id: "action:open-logs", + route: { to: "/logs" }, + }); + }); +}); diff --git a/ui-tauri/src/components/kb/search/ranking.ts b/ui-tauri/src/components/kb/search/ranking.ts new file mode 100644 index 00000000..4d20f23e --- /dev/null +++ b/ui-tauri/src/components/kb/search/ranking.ts @@ -0,0 +1,263 @@ +import type { + RankedSearchResult, + SearchMatchExactness, + SearchPrivacyTier, + SearchRankReason, + SearchResult, + SearchResultCategory, +} from "./types"; +import { isTransactionLookupQuery } from "@/lib/transactionLookup"; + +type QueryProfile = { + raw: string; + normalized: string; + terms: string[]; + looksLikeTransactionId: boolean; +}; + +type TextMatch = { + exactness: SearchMatchExactness; + reason: SearchRankReason; + score: number; + matchedText: string; +}; + +export type RankSearchOptions = { + limit?: number; + maxPrivacyTier?: SearchPrivacyTier; +}; + +const PRIVACY_ORDER: Record = { + public: 0, + local_metadata: 1, + book_private: 2, + secret: 3, +}; + +const EXACTNESS_SCORE: Record = { + exact: 800, + prefix: 500, + token: 320, + contains: 160, +}; + +const CATEGORY_SCORE: Record = { + action: 90, + page: 85, + setting: 75, + report: 70, + wallet: 65, + review_item: 60, + transaction: 55, +}; + +export function normalizeSearchQuery(query: string): QueryProfile { + const normalized = query.trim().toLowerCase(); + return { + raw: query, + normalized, + terms: normalized.split(/\s+/).filter(Boolean), + looksLikeTransactionId: isLikelyTransactionLookupQuery(query), + }; +} + +export function isLikelyTransactionLookupQuery(query: string) { + return isTransactionLookupQuery(query); +} + +export function rankSearchResult( + result: SearchResult, + query: string, +): RankedSearchResult | null { + const profile = normalizeSearchQuery(query); + if (!profile.terms.length) return null; + + const exactTxidMatch = exactTransactionIdentifier(result, profile.normalized); + if (exactTxidMatch) { + return withMatch(result, { + score: 20_000 + priorityScore(result), + exactness: "exact", + reason: "exact_txid", + matchedText: exactTxidMatch, + }); + } + + const transactionCandidateMatch = transactionCandidateIdentifier( + result, + profile, + ); + if (transactionCandidateMatch) { + return withMatch(result, { + score: 15_000 + priorityScore(result), + exactness: "prefix", + reason: "transaction_candidate", + matchedText: transactionCandidateMatch, + }); + } + + const textMatch = bestTextMatch(result, profile); + if (!textMatch) return null; + + const score = + CATEGORY_SCORE[result.category] + + textMatch.score + + priorityScore(result); + + return withMatch(result, { + ...textMatch, + score, + }); +} + +export function rankSearchResults( + results: readonly SearchResult[], + query: string, + options: RankSearchOptions = {}, +) { + const maxPrivacyTier = options.maxPrivacyTier ?? "book_private"; + const ranked = results + .filter((result) => privacyAllowed(result, maxPrivacyTier)) + .map((result) => rankSearchResult(result, query)) + .filter((result): result is RankedSearchResult => result !== null) + .sort(compareRankedResults); + + return typeof options.limit === "number" + ? ranked.slice(0, Math.max(0, options.limit)) + : ranked; +} + +export function searchResultMatches(result: SearchResult, query: string) { + return rankSearchResult(result, query) !== null; +} + +function compareRankedResults(a: RankedSearchResult, b: RankedSearchResult) { + if (b.match.score !== a.match.score) return b.match.score - a.match.score; + const titleOrder = a.title.localeCompare(b.title); + if (titleOrder !== 0) return titleOrder; + return a.id.localeCompare(b.id); +} + +function privacyAllowed(result: SearchResult, maxPrivacyTier: SearchPrivacyTier) { + const tier = result.privacyTier ?? "book_private"; + return PRIVACY_ORDER[tier] <= PRIVACY_ORDER[maxPrivacyTier]; +} + +function priorityScore(result: SearchResult) { + const priority = result.ranking?.priority ?? 0; + if (!Number.isFinite(priority)) return 0; + return Math.max(-100, Math.min(100, priority)); +} + +function exactTransactionIdentifier(result: SearchResult, normalizedQuery: string) { + if (result.category !== "transaction") return null; + return transactionIdentifiers(result).find( + (value) => value.trim().toLowerCase() === normalizedQuery, + ); +} + +function transactionIdentifiers(result: SearchResult) { + return [ + result.metadata?.transactionId, + result.metadata?.externalId, + result.metadata?.explorerId, + ...identifierLikeKeywords(result.keywords ?? []), + ...identifierLikeKeywords(result.metadata?.searchTokens ?? []), + ].filter((value): value is string => Boolean(value)); +} + +function identifierLikeKeywords(keywords: readonly string[]) { + return keywords.filter((keyword) => isLikelyTransactionLookupQuery(keyword)); +} + +function transactionCandidateIdentifier( + result: SearchResult, + profile: QueryProfile, +) { + if (result.category !== "transaction" || !profile.looksLikeTransactionId) { + return null; + } + return transactionIdentifiers(result).find((value) => { + const normalized = value.trim().toLowerCase(); + return ( + normalized.startsWith(profile.normalized) || + profile.normalized.startsWith(normalized) + ); + }); +} + +function bestTextMatch(result: SearchResult, profile: QueryProfile) { + const candidates: TextMatch[] = []; + for (const field of searchableFields(result)) { + const match = matchTextField(field.value, field.reason, profile); + if (match) candidates.push(match); + } + if (!candidates.length) return null; + return candidates.sort((a, b) => b.score - a.score)[0]; +} + +function searchableFields(result: SearchResult) { + return [ + { value: result.title, reason: "title" as const }, + { value: result.subtitle ?? "", reason: "subtitle" as const }, + ...(result.keywords ?? []).map((value) => ({ + value, + reason: "keyword" as const, + })), + ...(result.metadata?.searchTokens ?? []).map((value) => ({ + value, + reason: "metadata" as const, + })), + ...(result.action?.label + ? [{ value: result.action.label, reason: "metadata" as const }] + : []), + ]; +} + +function matchTextField( + value: string, + reason: SearchRankReason, + profile: QueryProfile, +): TextMatch | null { + const normalized = value.trim().toLowerCase(); + if (!normalized) return null; + if (normalized === profile.normalized) { + return textMatch("exact", reason, value); + } + if (normalized.startsWith(profile.normalized)) { + return textMatch("prefix", reason, value); + } + + const tokens = normalized.split(/[^a-z0-9:_-]+/).filter(Boolean); + if ( + profile.terms.every((term) => + tokens.some((token) => token === term || token.startsWith(term)), + ) + ) { + return textMatch("token", reason, value); + } + + if (profile.terms.every((term) => normalized.includes(term))) { + return textMatch("contains", reason, value); + } + return null; +} + +function textMatch( + exactness: SearchMatchExactness, + reason: SearchRankReason, + matchedText: string, +) { + return { + exactness, + reason, + score: EXACTNESS_SCORE[exactness], + matchedText, + }; +} + +function withMatch(result: SearchResult, match: RankedSearchResult["match"]) { + return { + ...result, + match, + }; +} diff --git a/ui-tauri/src/components/kb/search/transactions.ts b/ui-tauri/src/components/kb/search/transactions.ts new file mode 100644 index 00000000..517123f5 --- /dev/null +++ b/ui-tauri/src/components/kb/search/transactions.ts @@ -0,0 +1,70 @@ +import { isLikelyTransactionLookupQuery } from "./ranking"; + +export type ResolvedTransactionLookup = { + transaction?: { + id: string; + externalId?: string | null; + explorerId?: string | null; + account?: string | null; + type?: string | null; + counter?: string | null; + date?: string | null; + } | null; + query?: string | null; +}; + +export type TransactionLookupState = + | "idle" + | "looking_up" + | "matched" + | "not_found"; + +export type TransactionLookupLabel = { + state: TransactionLookupState; + label: string; +}; + +export function transactionLookupStateForQuery( + query: string, + options: { + isFetching?: boolean; + resolved?: ResolvedTransactionLookup | null; + } = {}, +): TransactionLookupState { + if (!isLikelyTransactionLookupQuery(query)) return "idle"; + if (options.isFetching) return "looking_up"; + if (resolvedMatchesQuery(query, options.resolved)) return "matched"; + return "not_found"; +} + +export function transactionLookupLabelForState( + state: TransactionLookupState, +): TransactionLookupLabel { + switch (state) { + case "looking_up": + return { state, label: "Looking up transaction" }; + case "matched": + return { state, label: "Transaction found" }; + case "not_found": + return { state, label: "No local transaction match" }; + case "idle": + default: + return { state: "idle", label: "Search pages, actions, and local data" }; + } +} + +function resolvedMatchesQuery( + query: string, + resolved: ResolvedTransactionLookup | null | undefined, +) { + const transaction = resolved?.transaction; + if (!transaction) return false; + const resolvedQuery = resolved?.query?.trim().toLowerCase(); + const normalizedQuery = query.trim().toLowerCase(); + if (resolvedQuery && resolvedQuery !== normalizedQuery) return false; + return [ + transaction.id, + transaction.externalId, + transaction.explorerId, + ].some((value) => value?.trim().toLowerCase() === normalizedQuery); +} diff --git a/ui-tauri/src/components/kb/search/types.ts b/ui-tauri/src/components/kb/search/types.ts new file mode 100644 index 00000000..e10f02b4 --- /dev/null +++ b/ui-tauri/src/components/kb/search/types.ts @@ -0,0 +1,114 @@ +import type { + AppRoutePath, + SettingsMenuSection, +} from "@/components/kb/menuIntent"; + +export const SEARCH_RESULT_CATEGORIES = [ + "page", + "action", + "transaction", + "wallet", + "report", + "review_item", + "setting", +] as const; + +export type SearchResultCategory = (typeof SEARCH_RESULT_CATEGORIES)[number]; + +export type SearchPrivacyTier = + | "public" + | "local_metadata" + | "book_private" + | "secret"; + +export type SearchIconKey = + | "activity" + | "assistant" + | "book" + | "database" + | "file_search" + | "ledger" + | "lock" + | "logs" + | "report" + | "search" + | "settings" + | "shield" + | "sync" + | "transaction" + | "wallet"; + +export type SearchMatchExactness = + | "exact" + | "prefix" + | "token" + | "contains"; + +export type SearchRankReason = + | "exact_txid" + | "transaction_candidate" + | "title" + | "subtitle" + | "keyword" + | "metadata"; + +export type SearchRouteTarget = { + to: AppRoutePath | "/connections/$connectionId"; + params?: Record; + search?: Record; + hash?: string; +}; + +export type SearchActionId = + | "add-wallet" + | "import-btcpay" + | "process-journals"; + +export type SearchActionTarget = { + id: SearchActionId; + label?: string; + requiresConsent?: boolean; + args?: Record; +}; + +export type SearchResultMetadata = { + transactionId?: string | null; + externalId?: string | null; + explorerId?: string | null; + walletId?: string | null; + walletKind?: string | null; + reportId?: string | null; + reviewItemId?: string | null; + settingSection?: SettingsMenuSection | null; + sourceKind?: string | null; + searchTokens?: readonly string[]; +}; + +export type SearchRankingHints = { + priority?: number; + exactness?: SearchMatchExactness; + reason?: SearchRankReason; +}; + +export type SearchResult = { + id: string; + category: SearchResultCategory; + title: string; + subtitle?: string; + keywords?: readonly string[]; + iconKey?: SearchIconKey | string; + route?: SearchRouteTarget; + action?: SearchActionTarget; + metadata?: SearchResultMetadata; + privacyTier?: SearchPrivacyTier; + ranking?: SearchRankingHints; +}; + +export type RankedSearchResult = SearchResult & { + match: { + score: number; + exactness: SearchMatchExactness; + reason: SearchRankReason; + matchedText: string; + }; +};