Files
webmail/lib/global-search/types.ts
T

148 lines
5.1 KiB
TypeScript

import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { CalendarEvent, ContactCard, Email, FileNode } from '@/lib/jmap/types';
import type { ParsedQuery } from '@/lib/global-search/query-parser';
/**
* Global search ("Search everything", #641): one query across mail, contacts,
* calendar events and files of every logged-in account.
*
* Every hit carries BOTH the login it is reachable through (`localAccountId`,
* an `AccountEntry.id`) and the JMAP account that owns it (`jmapAccountId`) plus
* the raw server id - bare ids collide across accounts (#847), so no code path
* may ever re-find a hit by id alone.
*/
export type SearchKind = 'mail' | 'contacts' | 'calendar' | 'files';
export const SEARCH_KINDS: readonly SearchKind[] = ['mail', 'contacts', 'calendar', 'files'];
/** Where a hit came from. Remote hits win over local ones when both exist (they carry snippets). */
export type HitSource = 'local' | 'remote';
interface HitBase {
kind: SearchKind;
/** Login (`AccountEntry.id`) whose client reaches this item. */
localAccountId: string;
/** JMAP account that owns the item (the login's primary, or a shared owner). */
jmapAccountId: string;
/** Raw server id - never namespaced. */
id: string;
/** Display label of the login (`AccountEntry.label || email`). */
accountLabel: string;
title: string;
/** Folder / address book / calendar / drive path - shown after the account label. */
subtitle: string;
/** ISO timestamp used for recency ordering (receivedAt, event start, file modified). */
date: string | null;
source: HitSource;
/**
* Server the item lives on (the login's serverUrl). Lets the merge collapse
* the same object reached through several logins to ONE result, while ids
* from different servers stay apart (#847). Optional for injected test hits.
*/
serverUrl?: string;
}
export interface MailHit extends HitBase {
kind: 'mail';
email: Email;
/** Server-side snippet with `<mark>` around the matched terms, when the server gave one. */
snippet: { subject: string | null; preview: string | null } | null;
}
export interface ContactHit extends HitBase {
kind: 'contacts';
contact: ContactCard;
/**
* Id under which the contact store knows this card in the Pro shell
* (`${localAccountId}::${id}`, or `${owner}:${id}` for a shared book inside
* that). This is what the contacts surface's deep link expects.
*/
storeId: string;
}
export interface CalendarHit extends HitBase {
kind: 'calendar';
event: CalendarEvent;
/** Master of a recurring series (server FTS returns masters, not occurrences). */
isRecurring: boolean;
}
export interface FileHit extends HitBase {
kind: 'files';
node: FileNode;
/** Absolute folder path the node lives in (`/` for the root), for the files deep link. */
folderPath: string;
isFolder: boolean;
}
export type GlobalSearchHit = MailHit | ContactHit | CalendarHit | FileHit;
/** One login the search fans out to. */
export interface SearchAccount {
localAccountId: string;
label: string;
email: string;
client: IJMAPClient;
/** Server this login talks to (AccountEntry.serverUrl). */
serverUrl?: string;
}
export interface RemoteSearchOptions {
limit: number;
/** Mail only: `Email/query` position for "load more". */
position?: number;
signal: AbortSignal;
}
export interface RemoteSearchResult {
hits: GlobalSearchHit[];
/** More results exist beyond `limit` (mail pagination). */
hasMore: boolean;
}
export interface SearchProvider {
kind: SearchKind;
/** Synchronous hits from the caches already in memory - substring match, so partial words work. */
local: (parsed: ParsedQuery, accounts: SearchAccount[], limit: number) => GlobalSearchHit[];
/** One login's server hits. Throws on failure; the orchestrator turns that into a per-account error row. */
remote: (parsed: ParsedQuery, account: SearchAccount, options: RemoteSearchOptions) => Promise<RemoteSearchResult>;
/** False when the login's server lacks the capability - skipped without an error row. */
supports: (account: SearchAccount) => boolean;
}
export interface SearchAccountError {
localAccountId: string;
accountLabel: string;
message: string;
}
export interface KindStatus {
/** `loading` while any account is still pending for this kind. */
status: 'idle' | 'loading' | 'done';
errors: SearchAccountError[];
/** Mail: some account reported more results than were fetched. */
hasMore: boolean;
}
export interface SearchOutcome {
hits: Record<SearchKind, GlobalSearchHit[]>;
status: Record<SearchKind, KindStatus>;
}
export function emptyOutcome(): SearchOutcome {
return {
hits: { mail: [], contacts: [], calendar: [], files: [] },
status: {
mail: { status: 'idle', errors: [], hasMore: false },
contacts: { status: 'idle', errors: [], hasMore: false },
calendar: { status: 'idle', errors: [], hasMore: false },
files: { status: 'idle', errors: [], hasMore: false },
},
};
}
/** Stable identity of a hit across local/remote sources. */
export function hitKey(hit: Pick<GlobalSearchHit, 'kind' | 'localAccountId' | 'id'>): string {
return `${hit.kind}\0${hit.localAccountId}\0${hit.id}`;
}