vault-search.ts

4.8 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];1314export const VAULT_SEARCH_SCOPE_OPTIONS: { key: SearchScope; label: string }[] = [15  { key: "both", label: "both" },16  { key: "emails", label: "emails" },17  { key: "usernames", label: "usernames" },18];1920function fuzzyMatch(query: string, target: string): boolean {21  const q = query.toLowerCase();22  const t = target.toLowerCase();23  let qi = 0;24  for (let i = 0; i < t.length && qi < q.length; i += 1) {25    if (t[i] === q[qi]) qi += 1;26  }27  return qi === q.length;28}2930function isExactPrefix(query: string, target: string): boolean {31  return target.toLowerCase().startsWith(query.toLowerCase());32}3334function compareTitle(a: VaultEntry, b: VaultEntry): number {35  return a.title.localeCompare(b.title) || a.id.localeCompare(b.id);36}3738export function getVaultEntryLastUsedAt(entry: VaultEntry): number {39  return entry.passkey?.lastUsedAt40    ?? entry.lastUsedAt41    ?? entry.modifiedAt42    ?? entry.createdAt43    ?? 0;44}4546export function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {47  if (!domain) return false;48  if (entry.passkey?.rpId === domain) return true;49  if (entry.type !== "login") return false;50  if (entry.url && extractDomain(entry.url) === domain) return true;51  for (const extra of entry.additionalUrls ?? []) {52    if (extractDomain(extra) === domain) return true;53  }54  return false;55}5657export function getVaultEntrySearchText(entry: VaultEntry, scope: SearchScope): string {58  if (entry.type === "note") return `${entry.title} ${entry.notes ?? ""}`;5960  const identityParts: string[] = [];61  if (scope !== "usernames" && entry.email) identityParts.push(entry.email);62  if (scope !== "emails" && entry.username) identityParts.push(entry.username);63  if (scope !== "emails" && entry.passkey?.userName) identityParts.push(entry.passkey.userName);64  if (entry.passkey?.userDisplayName) identityParts.push(entry.passkey.userDisplayName);6566  const passkeyParts = entry.passkey67    ? `${entry.passkey.rpId} ${entry.passkey.userName} ${entry.passkey.userDisplayName}`68    : "";6970  return `${entry.title} ${entry.url ?? ""} ${identityParts.join(" ")} ${passkeyParts}`;71}7273function compareSearchHits(74  a: VaultEntry,75  b: VaultEntry,76  query: string,77  scope: SearchScope78): number {79  const qLower = query.toLowerCase();80  const aSearchable = getVaultEntrySearchText(a, scope);81  const bSearchable = getVaultEntrySearchText(b, scope);82  const aExact = isExactPrefix(qLower, aSearchable);83  const bExact = isExactPrefix(qLower, bSearchable);84  if (aExact && !bExact) return -1;85  if (!aExact && bExact) return 1;86  const recency = getVaultEntryLastUsedAt(b) - getVaultEntryLastUsedAt(a);87  return recency || compareTitle(a, b);88}8990function compareRecent(a: VaultEntry, b: VaultEntry): number {91  const recency = getVaultEntryLastUsedAt(b) - getVaultEntryLastUsedAt(a);92  return recency || compareTitle(a, b);93}9495function compareDomainFirst(a: VaultEntry, b: VaultEntry, domain: string): number {96  const aMatch = entryMatchesDomain(a, domain) ? 0 : 1;97  const bMatch = entryMatchesDomain(b, domain) ? 0 : 1;98  if (aMatch !== bMatch) return aMatch - bMatch;99  return compareRecent(a, b);100}101102export interface VaultListFilters {103  notesOnly: boolean;104  selectedTags: ReadonlySet<string>;105  query: string;106  searchScope: SearchScope;107  sortOrder: SortOrder;108  currentTabDomain?: string;109}110111export function filterAndSortVaultEntries(112  entries: VaultEntry[],113  filters: VaultListFilters114): VaultEntry[] {115  let result = entries;116117  if (filters.notesOnly) {118    result = result.filter((entry) => entry.type === "note");119  }120121  if (filters.selectedTags.size > 0) {122    result = result.filter((entry) =>123      Array.from(filters.selectedTags).every((tag) => entry.tags?.includes(tag))124    );125  }126127  const q = filters.query.trim();128  if (q) {129    result = result130      .filter((entry) => fuzzyMatch(q, getVaultEntrySearchText(entry, filters.searchScope)))131      .sort((a, b) => compareSearchHits(a, b, q, filters.searchScope));132    return result;133  }134135  if (filters.sortOrder === "az") {136    return [...result].sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id));137  }138  if (filters.sortOrder === "za") {139    return [...result].sort((a, b) => b.title.localeCompare(a.title) || b.id.localeCompare(a.id));140  }141  if (filters.sortOrder === "recent") {142    return [...result].sort(compareRecent);143  }144  if (filters.currentTabDomain) {145    return [...result].sort((a, b) => compareDomainFirst(a, b, filters.currentTabDomain!));146  }147  return result;148}