ultisuite-client/lib/mail-search/search-engine.ts
R3D347HR4Y 07d57f13a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add Contact Avatar Features and Improve UI Components
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views.
- Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts.
- Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface.
- Added loading and error states in ContactsListView for better user feedback during data fetching.
- Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list.
- Updated package.json to include @formkit/auto-animate for improved UI animations.
2026-06-06 20:26:51 +02:00

288 lines
8.2 KiB
TypeScript

import type { Email } from "@/lib/email-data"
import type { FullContact } from "@/lib/contacts/types"
import { fullContactDisplayName } from "@/lib/contacts/types"
import {
fieldMatchScore,
normalizeContactSearchText,
} from "@/lib/contacts/contact-match-score"
import type { SearchParams } from "./search-params"
// ---------------------------------------------------------------------------
// Suggestion types
// ---------------------------------------------------------------------------
export type ContactSuggestion = {
kind: "contact"
contact: FullContact
email: string
displayName: string
/** Score 0-1 (higher = better match). */
score: number
}
export type EmailSuggestion = {
kind: "email"
email: string
displayName: string
score: number
}
export type SearchSuggestion = ContactSuggestion | EmailSuggestion
// ---------------------------------------------------------------------------
// Prefix matching helpers
// ---------------------------------------------------------------------------
function normalize(s: string): string {
return normalizeContactSearchText(s)
}
// ---------------------------------------------------------------------------
// matchContacts
// ---------------------------------------------------------------------------
export function matchContacts(
query: string,
contacts: FullContact[],
limit = 5
): ContactSuggestion[] {
if (!query.trim()) return []
const results: ContactSuggestion[] = []
for (const c of contacts) {
const fullName = fullContactDisplayName(c)
let bestScore = 0
let bestEmail = c.emails[0]?.value ?? ""
bestScore = Math.max(bestScore, fieldMatchScore(fullName, query))
bestScore = Math.max(bestScore, fieldMatchScore(c.firstName, query))
bestScore = Math.max(bestScore, fieldMatchScore(c.lastName, query))
for (const e of c.emails) {
const s = fieldMatchScore(e.value, query)
if (s > bestScore) {
bestScore = s
bestEmail = e.value
}
}
if (bestScore > 0) {
results.push({
kind: "contact",
contact: c,
email: bestEmail,
displayName: fullName,
score: bestScore,
})
}
}
results.sort((a, b) => b.score - a.score)
return results.slice(0, limit)
}
// ---------------------------------------------------------------------------
// matchEmails (unique sender emails from email data)
// ---------------------------------------------------------------------------
export function matchEmails(
query: string,
emails: Email[],
limit = 5
): EmailSuggestion[] {
if (!query.trim()) return []
const seen = new Map<string, { name: string; score: number }>()
for (const e of emails) {
if (!e.senderEmail) continue
const addr = e.senderEmail.toLowerCase()
const name = e.sender
const s1 = fieldMatchScore(addr, query)
const s2 = fieldMatchScore(name, query)
const s = Math.max(s1, s2)
if (s > 0) {
const existing = seen.get(addr)
if (!existing || s > existing.score) {
seen.set(addr, { name, score: s })
}
}
}
const results: EmailSuggestion[] = []
for (const [addr, { name, score }] of seen) {
results.push({ kind: "email", email: addr, displayName: name, score })
}
results.sort((a, b) => b.score - a.score)
return results.slice(0, limit)
}
// ---------------------------------------------------------------------------
// Ghost text (best completion for the input)
// ---------------------------------------------------------------------------
export function bestCompletion(
query: string,
suggestions: SearchSuggestion[]
): string {
if (!query.trim() || suggestions.length === 0) return ""
const top = suggestions[0]!
const q = normalize(query)
const candidates =
top.kind === "contact"
? [top.email, top.displayName]
: [top.email, top.displayName]
for (const c of candidates) {
const cn = normalize(c)
if (cn.startsWith(q)) {
return c.slice(query.length)
}
}
return ""
}
// ---------------------------------------------------------------------------
// filterEmailsBySearchParams
// ---------------------------------------------------------------------------
function withinToMs(within: string): number {
const map: Record<string, number> = {
"1d": 86_400_000,
"3d": 3 * 86_400_000,
"1w": 7 * 86_400_000,
"2w": 14 * 86_400_000,
"1m": 30 * 86_400_000,
"2m": 60 * 86_400_000,
"6m": 180 * 86_400_000,
"1y": 365 * 86_400_000,
}
return map[within] ?? 0
}
function textMatchesEmail(email: Email, text: string): boolean {
const t = normalize(text)
if (!t) return true
const fields = [
email.subject,
email.preview,
email.sender,
email.senderEmail ?? "",
email.body ?? "",
]
for (const f of fields) {
if (normalize(f).includes(t)) return true
}
if (email.conversation) {
for (const msg of email.conversation) {
if (normalize(msg.sender).includes(t)) return true
if (normalize(msg.senderEmail).includes(t)) return true
if (normalize(msg.preview).includes(t)) return true
if (normalize(msg.body).includes(t)) return true
}
}
return false
}
export function filterEmailsBySearchParams(
emails: Email[],
params: SearchParams,
opts?: {
starredIds?: string[]
importantIds?: string[]
}
): Email[] {
const now = Date.now()
return emails.filter((email) => {
if (params.in === "all" && email.spam) return false
if (params.in === "inbox" && !email.labels?.includes("inbox")) return false
if (params.in === "sent" && !email.labels?.includes("sent")) return false
if (params.in === "drafts" && !email.labels?.includes("drafts")) return false
if (params.in === "spam" && !email.spam) return false
if (params.in === "trash" && !email.deleted) return false
if (params.in === "starred") {
const isStarred =
email.starred || (opts?.starredIds?.includes(email.id) ?? false)
if (!isStarred) return false
}
if (params.q && !textMatchesEmail(email, params.q)) return false
if (params.hasWords && !textMatchesEmail(email, params.hasWords))
return false
if (params.doesNotHave) {
if (textMatchesEmail(email, params.doesNotHave)) return false
}
if (params.from) {
const f = normalize(params.from)
const senderMatch =
normalize(email.sender).includes(f) ||
normalize(email.senderEmail ?? "").includes(f)
if (!senderMatch) return false
}
if (params.to) {
const t = normalize(params.to)
let found = false
if (email.conversation) {
for (const msg of email.conversation) {
if (
normalize(msg.sender).includes(t) ||
normalize(msg.senderEmail).includes(t)
)
found = true
}
}
if (
normalize(email.sender).includes(t) ||
normalize(email.senderEmail ?? "").includes(t)
)
found = true
if (!found) return false
}
if (params.subject) {
if (!normalize(email.subject).includes(normalize(params.subject)))
return false
}
if (params.has.includes("attachment") && !email.hasAttachment) return false
if (params.within) {
const ms = withinToMs(params.within)
if (ms > 0) {
const emailDate = new Date(email.date).getTime()
if (now - emailDate > ms) return false
}
}
if (params.after) {
const afterDate = new Date(params.after).getTime()
if (Number.isFinite(afterDate) && new Date(email.date).getTime() < afterDate)
return false
}
if (params.before) {
const beforeDate = new Date(params.before).getTime()
if (Number.isFinite(beforeDate) && new Date(email.date).getTime() > beforeDate)
return false
}
if (params.size) {
const sizeNum = parseFloat(params.size)
if (Number.isFinite(sizeNum) && sizeNum > 0) {
const multiplier = params.sizeUnit === "Mo" ? 1_048_576 : 1024
const thresholdBytes = sizeNum * multiplier
const totalSize =
email.attachments?.reduce((a, att) => a + (att.sizeBytes ?? 0), 0) ?? 0
if (params.sizeOp === "gt" && totalSize <= thresholdBytes) return false
if (params.sizeOp === "lt" && totalSize >= thresholdBytes) return false
}
}
return true
})
}