vault-search.ts

4.6 KB · raw

1import { extractDomain } from "../../shared/domain";2import type { VaultEntry } from "../../shared/types";34export type SortOrder = "url-first" | "az" | "za" | "recent";5export type SearchScope = "both" | "emails" | "usernames";67export const VAULT_SORT_OPTIONS: { key: SortOrder; label: string }[] = [8  { key: "url-first", label: "smart" },9  { key: "az", label: "A–Z" },10  { key: "za", label: "Z–A" },11  { key: "recent", label: "recent" },12];1314function fuzzyMatch(query: string, target: string): boolean {15  const q = query.toLowerCase();16  const t = target.toLowerCase();17  let qi = 0;18  for (let i = 0; i < t.length && qi < q.length; i += 1) {19    if (t[i] === q[qi]) qi += 1;20  }21  return qi === q.length;22}2324function isExactPrefix(query: string, target: string): boolean {25  return target.toLowerCase().startsWith(query.toLowerCase());26}2728function compareTitle(a: VaultEntry, b: VaultEntry): number {29  return a.title.localeCompare(b.title) || a.id.localeCompare(b.id);30}3132export function getVaultEntryLastUsedAt(entry: VaultEntry): number {33  return entry.passkey?.lastUsedAt34    ?? entry.lastUsedAt35    ?? entry.modifiedAt36    ?? entry.createdAt37    ?? 0;38}3940export function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {41  if (!domain) return false;42  if (entry.passkey?.rpId === domain) return true;43  if (entry.type !== "login") return false;44  if (entry.url && extractDomain(entry.url) === domain) return true;45  for (const extra of entry.additionalUrls ?? []) {46    if (extractDomain(extra) === domain) return true;47  }48  return false;49}5051export function getVaultEntrySearchText(entry: VaultEntry, scope: SearchScope): string {52  if (entry.type === "note") return `${entry.title} ${entry.notes ?? ""}`;5354  const identityParts: string[] = [];55  if (scope !== "usernames" && entry.email) identityParts.push(entry.email);56  if (scope !== "emails" && entry.username) identityParts.push(entry.username);57  if (scope !== "emails" && entry.passkey?.userName) identityParts.push(entry.passkey.userName);58  if (entry.passkey?.userDisplayName) identityParts.push(entry.passkey.userDisplayName);5960  const passkeyParts = entry.passkey61    ? `${entry.passkey.rpId} ${entry.passkey.userName} ${entry.passkey.userDisplayName}`62    : "";6364  return `${entry.title} ${entry.url ?? ""} ${identityParts.join(" ")} ${passkeyParts}`;65}6667function compareSearchHits(68  a: VaultEntry,69  b: VaultEntry,70  query: string,71  scope: SearchScope72): number {73  const qLower = query.toLowerCase();74  const aSearchable = getVaultEntrySearchText(a, scope);75  const bSearchable = getVaultEntrySearchText(b, scope);76  const aExact = isExactPrefix(qLower, aSearchable);77  const bExact = isExactPrefix(qLower, bSearchable);78  if (aExact && !bExact) return -1;79  if (!aExact && bExact) return 1;80  const recency = getVaultEntryLastUsedAt(b) - getVaultEntryLastUsedAt(a);81  return recency || compareTitle(a, b);82}8384function compareRecent(a: VaultEntry, b: VaultEntry): number {85  const recency = getVaultEntryLastUsedAt(b) - getVaultEntryLastUsedAt(a);86  return recency || compareTitle(a, b);87}8889function compareDomainFirst(a: VaultEntry, b: VaultEntry, domain: string): number {90  const aMatch = entryMatchesDomain(a, domain) ? 0 : 1;91  const bMatch = entryMatchesDomain(b, domain) ? 0 : 1;92  if (aMatch !== bMatch) return aMatch - bMatch;93  return compareRecent(a, b);94}9596export interface VaultListFilters {97  notesOnly: boolean;98  selectedTags: ReadonlySet<string>;99  query: string;100  searchScope: SearchScope;101  sortOrder: SortOrder;102  currentTabDomain?: string;103}104105export function filterAndSortVaultEntries(106  entries: VaultEntry[],107  filters: VaultListFilters108): VaultEntry[] {109  let result = entries;110111  if (filters.notesOnly) {112    result = result.filter((entry) => entry.type === "note");113  }114115  if (filters.selectedTags.size > 0) {116    result = result.filter((entry) =>117      Array.from(filters.selectedTags).every((tag) => entry.tags?.includes(tag))118    );119  }120121  const q = filters.query.trim();122  if (q) {123    result = result124      .filter((entry) => fuzzyMatch(q, getVaultEntrySearchText(entry, filters.searchScope)))125      .sort((a, b) => compareSearchHits(a, b, q, filters.searchScope));126    return result;127  }128129  if (filters.sortOrder === "az") {130    return [...result].sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id));131  }132  if (filters.sortOrder === "za") {133    return [...result].sort((a, b) => b.title.localeCompare(a.title) || b.id.localeCompare(a.id));134  }135  if (filters.sortOrder === "recent") {136    return [...result].sort(compareRecent);137  }138  if (filters.currentTabDomain) {139    return [...result].sort((a, b) => compareDomainFirst(a, b, filters.currentTabDomain!));140  }141  return result;142}