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.modifiedAt35 ?? entry.createdAt36 ?? 0;37}3839export function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {40 if (!domain) return false;41 if (entry.passkey?.rpId === domain) return true;42 if (entry.type !== "login") return false;43 if (entry.url && extractDomain(entry.url) === domain) return true;44 for (const extra of entry.additionalUrls ?? []) {45 if (extractDomain(extra) === domain) return true;46 }47 return false;48}4950export function getVaultEntrySearchText(entry: VaultEntry, scope: SearchScope): string {51 if (entry.type === "note") return `${entry.title} ${entry.notes ?? ""}`;5253 const identityParts: string[] = [];54 if (scope !== "usernames" && entry.email) identityParts.push(entry.email);55 if (scope !== "emails" && entry.username) identityParts.push(entry.username);56 if (scope !== "emails" && entry.passkey?.userName) identityParts.push(entry.passkey.userName);57 if (entry.passkey?.userDisplayName) identityParts.push(entry.passkey.userDisplayName);5859 const passkeyParts = entry.passkey60 ? `${entry.passkey.rpId} ${entry.passkey.userName} ${entry.passkey.userDisplayName}`61 : "";6263 return `${entry.title} ${entry.url ?? ""} ${identityParts.join(" ")} ${passkeyParts}`;64}6566function compareSearchHits(67 a: VaultEntry,68 b: VaultEntry,69 query: string,70 scope: SearchScope71): number {72 const qLower = query.toLowerCase();73 const aSearchable = getVaultEntrySearchText(a, scope);74 const bSearchable = getVaultEntrySearchText(b, scope);75 const aExact = isExactPrefix(qLower, aSearchable);76 const bExact = isExactPrefix(qLower, bSearchable);77 if (aExact && !bExact) return -1;78 if (!aExact && bExact) return 1;79 const recency = getVaultEntryLastUsedAt(b) - getVaultEntryLastUsedAt(a);80 return recency || compareTitle(a, b);81}8283function compareRecent(a: VaultEntry, b: VaultEntry): number {84 const recency = getVaultEntryLastUsedAt(b) - getVaultEntryLastUsedAt(a);85 return recency || compareTitle(a, b);86}8788function compareDomainFirst(a: VaultEntry, b: VaultEntry, domain: string): number {89 const aMatch = entryMatchesDomain(a, domain) ? 0 : 1;90 const bMatch = entryMatchesDomain(b, domain) ? 0 : 1;91 if (aMatch !== bMatch) return aMatch - bMatch;92 return compareRecent(a, b);93}9495export interface VaultListFilters {96 notesOnly: boolean;97 selectedTags: ReadonlySet<string>;98 query: string;99 searchScope: SearchScope;100 sortOrder: SortOrder;101 currentTabDomain?: string;102}103104export function filterAndSortVaultEntries(105 entries: VaultEntry[],106 filters: VaultListFilters107): VaultEntry[] {108 let result = entries;109110 if (filters.notesOnly) {111 result = result.filter((entry) => entry.type === "note");112 }113114 if (filters.selectedTags.size > 0) {115 result = result.filter((entry) =>116 Array.from(filters.selectedTags).every((tag) => entry.tags?.includes(tag))117 );118 }119120 const q = filters.query.trim();121 if (q) {122 result = result123 .filter((entry) => fuzzyMatch(q, getVaultEntrySearchText(entry, filters.searchScope)))124 .sort((a, b) => compareSearchHits(a, b, q, filters.searchScope));125 return result;126 }127128 if (filters.sortOrder === "az") {129 return [...result].sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id));130 }131 if (filters.sortOrder === "za") {132 return [...result].sort((a, b) => b.title.localeCompare(a.title) || b.id.localeCompare(a.id));133 }134 if (filters.sortOrder === "recent") {135 return [...result].sort(compareRecent);136 }137 if (filters.currentTabDomain) {138 return [...result].sort((a, b) => compareDomainFirst(a, b, filters.currentTabDomain!));139 }140 return result;141}