import { extractDomain } from "../../shared/domain"; import type { VaultEntry } from "../../shared/types"; export type SortOrder = "url-first" | "az" | "za" | "recent"; export type SearchScope = "both" | "emails" | "usernames"; export const VAULT_SORT_OPTIONS: { key: SortOrder; label: string }[] = [ { key: "url-first", label: "smart" }, { key: "az", label: "A–Z" }, { key: "za", label: "Z–A" }, { key: "recent", label: "recent" }, ]; function fuzzyMatch(query: string, target: string): boolean { const q = query.toLowerCase(); const t = target.toLowerCase(); let qi = 0; for (let i = 0; i < t.length && qi < q.length; i += 1) { if (t[i] === q[qi]) qi += 1; } return qi === q.length; } function isExactPrefix(query: string, target: string): boolean { return target.toLowerCase().startsWith(query.toLowerCase()); } function compareTitle(a: VaultEntry, b: VaultEntry): number { return a.title.localeCompare(b.title) || a.id.localeCompare(b.id); } export function getVaultEntryLastUsedAt(entry: VaultEntry): number { return entry.passkey?.lastUsedAt ?? entry.lastUsedAt ?? entry.modifiedAt ?? entry.createdAt ?? 0; } export function entryMatchesDomain(entry: VaultEntry, domain: string): boolean { if (!domain) return false; if (entry.passkey?.rpId === domain) return true; if (entry.type !== "login") return false; if (entry.url && extractDomain(entry.url) === domain) return true; for (const extra of entry.additionalUrls ?? []) { if (extractDomain(extra) === domain) return true; } return false; } export function getVaultEntrySearchText(entry: VaultEntry, scope: SearchScope): string { if (entry.type === "note") return `${entry.title} ${entry.notes ?? ""}`; const identityParts: string[] = []; if (scope !== "usernames" && entry.email) identityParts.push(entry.email); if (scope !== "emails" && entry.username) identityParts.push(entry.username); if (scope !== "emails" && entry.passkey?.userName) identityParts.push(entry.passkey.userName); if (entry.passkey?.userDisplayName) identityParts.push(entry.passkey.userDisplayName); const passkeyParts = entry.passkey ? `${entry.passkey.rpId} ${entry.passkey.userName} ${entry.passkey.userDisplayName}` : ""; return `${entry.title} ${entry.url ?? ""} ${identityParts.join(" ")} ${passkeyParts}`; } function compareSearchHits( a: VaultEntry, b: VaultEntry, query: string, scope: SearchScope ): number { const qLower = query.toLowerCase(); const aSearchable = getVaultEntrySearchText(a, scope); const bSearchable = getVaultEntrySearchText(b, scope); const aExact = isExactPrefix(qLower, aSearchable); const bExact = isExactPrefix(qLower, bSearchable); if (aExact && !bExact) return -1; if (!aExact && bExact) return 1; const recency = getVaultEntryLastUsedAt(b) - getVaultEntryLastUsedAt(a); return recency || compareTitle(a, b); } function compareRecent(a: VaultEntry, b: VaultEntry): number { const recency = getVaultEntryLastUsedAt(b) - getVaultEntryLastUsedAt(a); return recency || compareTitle(a, b); } function compareDomainFirst(a: VaultEntry, b: VaultEntry, domain: string): number { const aMatch = entryMatchesDomain(a, domain) ? 0 : 1; const bMatch = entryMatchesDomain(b, domain) ? 0 : 1; if (aMatch !== bMatch) return aMatch - bMatch; return compareRecent(a, b); } export interface VaultListFilters { notesOnly: boolean; selectedTags: ReadonlySet; query: string; searchScope: SearchScope; sortOrder: SortOrder; currentTabDomain?: string; } export function filterAndSortVaultEntries( entries: VaultEntry[], filters: VaultListFilters ): VaultEntry[] { let result = entries; if (filters.notesOnly) { result = result.filter((entry) => entry.type === "note"); } if (filters.selectedTags.size > 0) { result = result.filter((entry) => Array.from(filters.selectedTags).every((tag) => entry.tags?.includes(tag)) ); } const q = filters.query.trim(); if (q) { result = result .filter((entry) => fuzzyMatch(q, getVaultEntrySearchText(entry, filters.searchScope))) .sort((a, b) => compareSearchHits(a, b, q, filters.searchScope)); return result; } if (filters.sortOrder === "az") { return [...result].sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id)); } if (filters.sortOrder === "za") { return [...result].sort((a, b) => b.title.localeCompare(a.title) || b.id.localeCompare(a.id)); } if (filters.sortOrder === "recent") { return [...result].sort(compareRecent); } if (filters.currentTabDomain) { return [...result].sort((a, b) => compareDomainFirst(a, b, filters.currentTabDomain!)); } return result; }