mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: global search core: query parser, ranking, cross-account providers #641
This commit is contained in:
@@ -2003,6 +2003,14 @@ function handleCalendarEventGet(args: MethodArgs, callId: string): MethodResult
|
||||
function handleCalendarEventQuery(args: MethodArgs, callId: string): MethodResult {
|
||||
const filter = args.filter as Record<string, unknown> | undefined;
|
||||
let filtered = [...calendarEvents];
|
||||
const needle = [filter?.text, filter?.title].find((v): v is string => typeof v === 'string')?.toLowerCase();
|
||||
if (needle) {
|
||||
filtered = filtered.filter((e) => {
|
||||
const event = e as { title?: string; description?: string; locations?: Record<string, { name?: string }> | null };
|
||||
const locations = Object.values(event.locations ?? {}).map((l) => l?.name ?? '').join(' ');
|
||||
return ((event.title ?? '') + ' ' + (event.description ?? '') + ' ' + locations).toLowerCase().includes(needle);
|
||||
});
|
||||
}
|
||||
if (filter?.inCalendars) {
|
||||
const calIds = filter.inCalendars as string[];
|
||||
filtered = filtered.filter((e) => calIds.some((cid) => (e.calendarIds as Record<string, boolean>)[cid]));
|
||||
@@ -2369,7 +2377,20 @@ const METHOD_HANDLERS: Record<string, MethodHandler> = {
|
||||
destroyed: destroyed.length > 0 ? destroyed : null,
|
||||
}, callId];
|
||||
},
|
||||
'ContactCard/query': (_args, callId) => ['ContactCard/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: contacts.map(c => c.id), total: contacts.length, position: 0 }, callId],
|
||||
'ContactCard/query': (args, callId) => {
|
||||
const filter = args.filter as Record<string, unknown> | undefined;
|
||||
const needle = [filter?.text, filter?.name, filter?.email].find((v): v is string => typeof v === 'string')?.toLowerCase();
|
||||
const list = needle
|
||||
? contacts.filter((c) => {
|
||||
const card = c as { name?: { components?: Array<{ value?: string }> }; emails?: Record<string, { address?: string }>; organizations?: Record<string, { name?: string }> };
|
||||
const names = (card.name?.components ?? []).map((p) => p.value ?? '').join(' ');
|
||||
const emails = Object.values(card.emails ?? {}).map((e) => e.address ?? '').join(' ');
|
||||
const orgs = Object.values(card.organizations ?? {}).map((o) => o.name ?? '').join(' ');
|
||||
return (names + ' ' + emails + ' ' + orgs).toLowerCase().includes(needle);
|
||||
})
|
||||
: contacts;
|
||||
return ['ContactCard/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: list.map(c => c.id), total: list.length, position: 0 }, callId];
|
||||
},
|
||||
'AddressBook/get': handleAddressBookGet,
|
||||
'AddressBook/set': handleAddressBookSet,
|
||||
'Calendar/get': handleCalendarGet,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getSearchAccounts } from '@/lib/global-search/accounts';
|
||||
import { GLOBAL_SEARCH_PROVIDERS } from '@/lib/global-search/providers';
|
||||
import { parseSearchQuery, type ParsedQuery, type SearchScope } from '@/lib/global-search/query-parser';
|
||||
import { mergeHits, rankHits } from '@/lib/global-search/rank';
|
||||
import { hasRemoteQuery, runGlobalSearch } from '@/lib/global-search/run-global-search';
|
||||
import {
|
||||
emptyOutcome,
|
||||
type SearchAccount,
|
||||
type SearchKind,
|
||||
type SearchOutcome,
|
||||
type SearchProvider,
|
||||
} from '@/lib/global-search/types';
|
||||
|
||||
export interface UseGlobalSearchOptions {
|
||||
query: string;
|
||||
/** Scope chip; an explicit `in:` in the query wins over it. */
|
||||
scope: SearchScope;
|
||||
/** Account chip (`AccountEntry.id`); null = every login. */
|
||||
accountId: string | null;
|
||||
/** Hits per kind from the local caches. */
|
||||
localLimit: number;
|
||||
/** Hits per kind per login from the server. */
|
||||
remoteLimit: number;
|
||||
/** Delay before the server pass; the local pass runs immediately. */
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
/** Injection point for tests. */
|
||||
providers?: readonly SearchProvider[];
|
||||
accounts?: () => SearchAccount[];
|
||||
}
|
||||
|
||||
export interface UseGlobalSearchResult {
|
||||
parsed: ParsedQuery;
|
||||
outcome: SearchOutcome;
|
||||
/** Any kind still waiting on a server response. */
|
||||
isSearching: boolean;
|
||||
/** True when the query has nothing to search for (empty, or operators only with no server-side meaning). */
|
||||
isEmpty: boolean;
|
||||
/** Re-run the server pass now (Enter in the palette, refresh in the tab). */
|
||||
rerun: () => void;
|
||||
/** Mail pagination for the search tab: fetches the next page and appends. */
|
||||
loadMoreMail: () => void;
|
||||
mailLimit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a global search for a query and keeps the merged outcome up to date:
|
||||
* local cache hits synchronously on every keystroke, server hits after the
|
||||
* debounce, partial results published as each login answers. Stale responses
|
||||
* are dropped by search id, so a fast typist never sees results for an
|
||||
* earlier query flash in.
|
||||
*/
|
||||
export function useGlobalSearch(options: UseGlobalSearchOptions): UseGlobalSearchResult {
|
||||
const {
|
||||
query, scope, accountId, localLimit, remoteLimit,
|
||||
debounceMs = 300, enabled = true,
|
||||
providers = GLOBAL_SEARCH_PROVIDERS,
|
||||
accounts: accountsSource = getSearchAccounts,
|
||||
} = options;
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
const base = parseSearchQuery(query);
|
||||
return base.scope === 'all' && scope !== 'all' ? { ...base, scope } : base;
|
||||
}, [query, scope]);
|
||||
|
||||
const [outcome, setOutcome] = useState<SearchOutcome>(emptyOutcome);
|
||||
const [mailLimit, setMailLimit] = useState(remoteLimit);
|
||||
const [runToken, setRunToken] = useState(0);
|
||||
const searchIdRef = useRef(0);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const outcomeRef = useRef(outcome);
|
||||
outcomeRef.current = outcome;
|
||||
|
||||
const resolveAccounts = useCallback(() => {
|
||||
const all = accountsSource();
|
||||
return accountId ? all.filter((a) => a.localAccountId === accountId) : all;
|
||||
}, [accountsSource, accountId]);
|
||||
|
||||
const hasAnything = parsed.terms.length > 0
|
||||
|| (['mail', 'contacts', 'calendar', 'files'] as SearchKind[]).some((kind) => hasRemoteQuery(parsed, kind));
|
||||
const isEmpty = !hasAnything;
|
||||
|
||||
// Reset pagination whenever the query itself changes.
|
||||
useEffect(() => {
|
||||
setMailLimit(remoteLimit);
|
||||
}, [query, scope, accountId, remoteLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
controllerRef.current?.abort();
|
||||
const searchId = ++searchIdRef.current;
|
||||
if (!enabled || isEmpty) {
|
||||
setOutcome(emptyOutcome());
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
const accounts = resolveAccounts();
|
||||
|
||||
// Instant pass: whatever the caches already hold, no round-trip.
|
||||
const local = emptyOutcome();
|
||||
for (const provider of providers) {
|
||||
if (parsed.scope !== 'all' && provider.kind !== parsed.scope) continue;
|
||||
if (parsed.terms.length > 0) {
|
||||
try {
|
||||
local.hits[provider.kind] = rankHits(provider.local(parsed, accounts, localLimit), parsed);
|
||||
} catch {
|
||||
// A cache mid-mutation must not break typing; the server pass follows.
|
||||
}
|
||||
}
|
||||
const willAsk = hasRemoteQuery(parsed, provider.kind) && accounts.some((a) => provider.supports(a));
|
||||
local.status[provider.kind].status = willAsk ? 'loading' : 'done';
|
||||
}
|
||||
setOutcome(local);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
void runGlobalSearch({
|
||||
parsed,
|
||||
accounts,
|
||||
providers: [...providers],
|
||||
localLimit,
|
||||
remoteLimit: mailLimit > remoteLimit ? mailLimit : remoteLimit,
|
||||
signal: controller.signal,
|
||||
onUpdate: (next) => {
|
||||
if (searchId !== searchIdRef.current) return;
|
||||
setOutcome(next);
|
||||
},
|
||||
});
|
||||
}, debounceMs);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
// `mailLimit` intentionally drives re-runs through loadMoreMail (rerun via runToken).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parsed, enabled, isEmpty, resolveAccounts, providers, localLimit, remoteLimit, debounceMs, runToken]);
|
||||
|
||||
const rerun = useCallback(() => setRunToken((n) => n + 1), []);
|
||||
|
||||
const loadMoreMail = useCallback(() => {
|
||||
const nextLimit = mailLimit + remoteLimit;
|
||||
setMailLimit(nextLimit);
|
||||
const controller = new AbortController();
|
||||
const searchId = searchIdRef.current;
|
||||
const accounts = resolveAccounts();
|
||||
const mailProviders = providers.filter((p) => p.kind === 'mail');
|
||||
void runGlobalSearch({
|
||||
parsed,
|
||||
accounts,
|
||||
providers: [...mailProviders],
|
||||
localLimit,
|
||||
remoteLimit: nextLimit,
|
||||
includeLocal: false,
|
||||
signal: controller.signal,
|
||||
onUpdate: (next) => {
|
||||
if (searchId !== searchIdRef.current) return;
|
||||
const current = outcomeRef.current;
|
||||
setOutcome({
|
||||
hits: { ...current.hits, mail: rankHits(mergeHits(current.hits.mail, next.hits.mail), parsed) },
|
||||
status: { ...current.status, mail: next.status.mail },
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [mailLimit, remoteLimit, resolveAccounts, providers, parsed, localLimit]);
|
||||
|
||||
const isSearching = Object.values(outcome.status).some((s) => s.status === 'loading');
|
||||
|
||||
return { parsed, outcome, isSearching, isEmpty, rerun, loadMoreMail, mailLimit };
|
||||
}
|
||||
@@ -944,6 +944,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return this.data.calendarEvents.filter(e => {
|
||||
if (filter.after && e.start < filter.after) return false;
|
||||
if (filter.before && e.start > filter.before) return false;
|
||||
const q = (filter.text ?? filter.title)?.toLowerCase();
|
||||
if (q) {
|
||||
const locations = Object.values(e.locations ?? {}).map(l => l?.name ?? '').join(' ');
|
||||
if (!(e.title + ' ' + e.description + ' ' + locations).toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { parseSearchQuery } from '../query-parser';
|
||||
import type { SearchAccount } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store mocks. Providers read caches via `useXStore.getState()` and resolve
|
||||
// logins via account/auth stores; all of that is replaced with plain state.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const emailState = { emails: [] as unknown[], mailboxes: [] as unknown[] };
|
||||
const contactState = { contacts: [] as unknown[], addressBooks: [] as unknown[] };
|
||||
const calendarState = { events: [] as unknown[], calendars: [] as unknown[] };
|
||||
const fileState: Record<string, unknown> = { lastAction: null, uploadProgress: null, isLoading: false, currentAccountId: null };
|
||||
const authState = { activeAccountId: 'login-a', client: null as unknown, getClientForAccount: () => undefined };
|
||||
|
||||
const buildUnifiedAccountClients = vi.fn(async () => [] as unknown[]);
|
||||
|
||||
function storeHook(state: object) {
|
||||
const hook = (selector?: (s: object) => unknown) => (typeof selector === 'function' ? selector(state) : state);
|
||||
hook.getState = () => state;
|
||||
hook.setState = (patch: object) => Object.assign(state, patch);
|
||||
hook.subscribe = () => () => {};
|
||||
return hook;
|
||||
}
|
||||
|
||||
vi.mock('@/stores/email-store', () => ({
|
||||
useEmailStore: storeHook(emailState),
|
||||
buildUnifiedAccountClients: (...args: unknown[]) => buildUnifiedAccountClients(...args),
|
||||
}));
|
||||
vi.mock('@/stores/contact-store', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
useContactStore: storeHook(contactState),
|
||||
}));
|
||||
vi.mock('@/stores/calendar-store', () => ({ useCalendarStore: storeHook(calendarState) }));
|
||||
vi.mock('@/stores/file-store', () => ({ useFileStore: storeHook(fileState) }));
|
||||
vi.mock('@/stores/auth-store', () => ({ useAuthStore: storeHook(authState) }));
|
||||
vi.mock('@/stores/account-store', () => ({ useAccountStore: storeHook({ accounts: [] }) }));
|
||||
vi.mock('@/stores/settings-store', () => ({ useSettingsStore: storeHook({ includeGroupInUnified: true }) }));
|
||||
|
||||
const { mailProvider, andFilters, emailMatchesFilters, mailFilterFor } = await import('../providers/mail');
|
||||
const { contactsProvider } = await import('../providers/contacts');
|
||||
const { calendarProvider, calendarFilterFor } = await import('../providers/calendar');
|
||||
const { filesProvider, invalidateFileSearchCache, pathOfNode, rawFileNodeId } = await import('../providers/files');
|
||||
|
||||
function fakeClient(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getAccountId: () => 'jmap-a',
|
||||
getContactsAccountId: () => 'jmap-a',
|
||||
getCalendarsAccountId: () => 'jmap-a',
|
||||
getFilesAccountId: () => 'jmap-a',
|
||||
supportsContacts: () => true,
|
||||
supportsCalendars: () => true,
|
||||
supportsFiles: () => true,
|
||||
...overrides,
|
||||
} as unknown as SearchAccount['client'];
|
||||
}
|
||||
|
||||
function account(id: string, overrides: Record<string, unknown> = {}): SearchAccount {
|
||||
return { localAccountId: id, label: id.toUpperCase(), email: `${id}@example.org`, client: fakeClient(overrides) };
|
||||
}
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
beforeEach(() => {
|
||||
emailState.emails = [];
|
||||
emailState.mailboxes = [];
|
||||
contactState.contacts = [];
|
||||
contactState.addressBooks = [];
|
||||
calendarState.events = [];
|
||||
calendarState.calendars = [];
|
||||
authState.activeAccountId = 'login-a';
|
||||
buildUnifiedAccountClients.mockReset();
|
||||
invalidateFileSearchCache();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('mail provider', () => {
|
||||
const mailbox = (id: string, role?: string, extra: Record<string, unknown> = {}) => ({ id, name: id, role, ...extra });
|
||||
const entry = (mailboxes: unknown[], overrides: Record<string, unknown> = {}) => ({
|
||||
accountId: 'login-a', accountLabel: 'A', clientAccountId: 'login-a', jmapAccountId: 'jmap-a',
|
||||
mailboxes, isShared: false,
|
||||
client: fakeClient(overrides),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('excludes Trash and Junk by default and lifts the exclusion for is:anything', () => {
|
||||
const boxes = [mailbox('in', 'inbox'), mailbox('tr', 'trash'), mailbox('ju', 'junk')];
|
||||
const filter = mailFilterFor(parseSearchQuery('report'), entry(boxes) as never);
|
||||
expect(filter).toEqual({ operator: 'AND', conditions: [{ text: 'report*' }, { inMailboxOtherThan: ['tr', 'ju'] }] });
|
||||
const anything = mailFilterFor(parseSearchQuery('report is:anything'), entry(boxes) as never);
|
||||
expect(anything).toEqual({ text: 'report*' });
|
||||
});
|
||||
|
||||
it('searches only the role folder for in:trash, using originalId on shared entries', () => {
|
||||
const boxes = [mailbox('ns:tr', 'trash', { originalId: 'tr' })];
|
||||
const shared = entry(boxes, { isShared: true });
|
||||
const filter = mailFilterFor(parseSearchQuery('in:trash x'), shared as never);
|
||||
expect(filter).toEqual({ operator: 'AND', conditions: [{ text: 'x*' }, { inMailbox: 'tr' }] });
|
||||
});
|
||||
|
||||
it('flattens extra conditions into an existing AND', () => {
|
||||
expect(andFilters({ operator: 'AND', conditions: [{ a: 1 }] }, { b: 2 }))
|
||||
.toEqual({ operator: 'AND', conditions: [{ a: 1 }, { b: 2 }] });
|
||||
expect(andFilters({}, { b: 2 })).toEqual({ b: 2 });
|
||||
expect(andFilters({ a: 1 }, null)).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('applies structured operators to cached emails', () => {
|
||||
const email = {
|
||||
id: 'm1', subject: 'Invoice', preview: '', receivedAt: '2026-05-05T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@x' }], to: [], keywords: { $seen: true }, hasAttachment: true, mailboxIds: {},
|
||||
};
|
||||
expect(emailMatchesFilters(email as never, parseSearchQuery('from:alice has:attachment is:read'))).toBe(true);
|
||||
expect(emailMatchesFilters(email as never, parseSearchQuery('is:unread'))).toBe(false);
|
||||
expect(emailMatchesFilters(email as never, parseSearchQuery('after:2026-06-01'))).toBe(false);
|
||||
});
|
||||
|
||||
it('stamps local hits with login + owner and never returns another login’s cache rows', () => {
|
||||
emailState.emails = [
|
||||
{ id: 'm1', subject: 'Report A', receivedAt: '2026-01-01T00:00:00Z', keywords: {}, mailboxIds: {}, sourceClientAccountId: 'login-a', sourceAccountId: 'jmap-a', sourceFolder: 'Inbox' },
|
||||
{ id: 'm2', subject: 'Report B', receivedAt: '2026-01-02T00:00:00Z', keywords: {}, mailboxIds: {}, sourceClientAccountId: 'login-b', sourceAccountId: 'jmap-b' },
|
||||
];
|
||||
const hits = mailProvider.local(parseSearchQuery('report'), [account('login-a')], 10);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]).toMatchObject({ kind: 'mail', id: 'm1', localAccountId: 'login-a', jmapAccountId: 'jmap-a', subtitle: 'Inbox' });
|
||||
});
|
||||
|
||||
it('remote fans out over the login’s JMAP accounts and stamps source fields', async () => {
|
||||
const advancedSearchEmails = vi.fn(async () => ({
|
||||
emails: [{ id: 'm9', subject: 'Zebra', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { in: true }, keywords: {} }],
|
||||
hasMore: true,
|
||||
total: 51,
|
||||
}));
|
||||
buildUnifiedAccountClients.mockResolvedValue([
|
||||
entry([{ id: 'in', name: 'Inbox', role: 'inbox' }], { advancedSearchEmails }),
|
||||
]);
|
||||
const result = await mailProvider.remote(parseSearchQuery('zebra'), account('login-a'), { limit: 25, signal });
|
||||
expect(buildUnifiedAccountClients).toHaveBeenCalledWith({ includeGroup: true, scopeToClientAccountId: 'login-a' });
|
||||
// The stub login has no trash/junk role folders, so no exclusion is added.
|
||||
expect(advancedSearchEmails).toHaveBeenCalledWith({ text: 'zebra*' }, undefined, 25, 0);
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.hits[0]).toMatchObject({
|
||||
id: 'm9', localAccountId: 'login-a', jmapAccountId: 'jmap-a', source: 'remote', subtitle: 'Inbox',
|
||||
});
|
||||
expect((result.hits[0] as { email: { sourceClientAccountId: string } }).email.sourceClientAccountId).toBe('login-a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts provider', () => {
|
||||
it('matches cached contacts by name/email substring and keeps store + raw ids apart', () => {
|
||||
contactState.contacts = [
|
||||
{ id: 'login-a::c1', originalId: 'c1', localAccountId: 'login-a', name: { full: 'Bob Miller' }, emails: { e: { address: 'bob@x' } }, addressBookIds: { 'login-a::b1': true } },
|
||||
{ id: 'login-a::c2', originalId: 'c2', localAccountId: 'login-a', name: { full: 'Alice' }, emails: {}, addressBookIds: {} },
|
||||
{ id: 'login-b::c1', originalId: 'c1', localAccountId: 'login-b', name: { full: 'Bobby' }, emails: {}, addressBookIds: {} },
|
||||
];
|
||||
contactState.addressBooks = [{ id: 'login-a::b1', originalId: 'b1', name: 'Personal' }];
|
||||
const hits = contactsProvider.local(parseSearchQuery('bob'), [account('login-a')], 10);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]).toMatchObject({ id: 'c1', storeId: 'login-a::c1', localAccountId: 'login-a', title: 'Bob Miller', subtitle: 'Personal · bob@x' });
|
||||
});
|
||||
|
||||
it('remote namespaces the store id under the login and keeps the raw id', async () => {
|
||||
const searchContacts = vi.fn(async () => [
|
||||
{ id: 'c7', originalId: 'c7', accountId: 'jmap-a', name: { full: 'Bob' }, emails: {}, addressBookIds: {} },
|
||||
{ id: 'owner:c8', originalId: 'c8', accountId: 'owner', isShared: true, name: { full: 'Bob Shared' }, emails: {}, addressBookIds: {} },
|
||||
]);
|
||||
const result = await contactsProvider.remote(parseSearchQuery('bob'), account('login-a', { searchContacts }), { limit: 10, signal });
|
||||
expect(searchContacts).toHaveBeenCalledWith('bob');
|
||||
expect(result.hits[0]).toMatchObject({ id: 'c7', storeId: 'login-a::c7', jmapAccountId: 'jmap-a' });
|
||||
expect(result.hits[1]).toMatchObject({ id: 'c8', storeId: 'login-a::owner:c8', jmapAccountId: 'owner' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar provider', () => {
|
||||
it('translates day bounds into instants for the server filter', () => {
|
||||
expect(calendarFilterFor(parseSearchQuery('sync after:2026-01-01 before:2026-02-01')))
|
||||
.toEqual({ text: 'sync', after: '2026-01-01T00:00:00Z', before: '2026-02-01T23:59:59Z' });
|
||||
});
|
||||
|
||||
it('matches cached events on title/location and applies day bounds locally', () => {
|
||||
calendarState.events = [
|
||||
{ id: 'login-a::e1', originalId: 'e1', localAccountId: 'login-a', accountId: 'jmap-a', title: 'Team sync', start: '2026-03-01T10:00:00', calendarIds: { k: true }, recurrenceRules: null, recurrenceId: null },
|
||||
{ id: 'login-a::e2', originalId: 'e2', localAccountId: 'login-a', accountId: 'jmap-a', title: 'Sync later', start: '2026-06-01T10:00:00', calendarIds: {}, recurrenceRules: null, recurrenceId: null },
|
||||
];
|
||||
const hits = calendarProvider.local(parseSearchQuery('sync before:2026-04-01'), [account('login-a')], 10);
|
||||
expect(hits.map((h) => h.id)).toEqual(['e1']);
|
||||
});
|
||||
|
||||
it('lists a recurring series once even when several occurrences match', () => {
|
||||
calendarState.events = [
|
||||
{ id: 'e1-o1', originalId: 'e1-o1', uid: 'series-1', localAccountId: 'login-a', accountId: 'jmap-a', title: 'Weekly sync', start: '2026-03-01T10:00:00', calendarIds: {}, recurrenceRules: null, recurrenceId: '2026-03-01T10:00:00' },
|
||||
{ id: 'e1-o2', originalId: 'e1-o2', uid: 'series-1', localAccountId: 'login-a', accountId: 'jmap-a', title: 'Weekly sync', start: '2026-03-08T10:00:00', calendarIds: {}, recurrenceRules: null, recurrenceId: '2026-03-08T10:00:00' },
|
||||
];
|
||||
const hits = calendarProvider.local(parseSearchQuery('sync'), [account('login-a')], 10);
|
||||
expect(hits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('remote queries every JMAP account of the login and flags recurring masters', async () => {
|
||||
const queryAllCalendarEvents = vi.fn(async () => [
|
||||
{ id: 'e9', title: 'Weekly sync', start: '2026-04-01T09:00:00', calendarIds: {}, accountId: 'jmap-a', recurrenceRules: [{}], recurrenceId: null },
|
||||
]);
|
||||
const result = await calendarProvider.remote(parseSearchQuery('sync'), account('login-a', { queryAllCalendarEvents }), { limit: 10, signal });
|
||||
expect(queryAllCalendarEvents).toHaveBeenCalledWith({ text: 'sync' }, [{ property: 'start', isAscending: false }], 10);
|
||||
expect(result.hits[0]).toMatchObject({ id: 'e9', isRecurring: true, localAccountId: 'login-a' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('files provider', () => {
|
||||
const nodes = [
|
||||
{ id: 'root1', parentId: null, name: 'Documents', type: 'd', blobId: null, size: 0, created: '', modified: '' },
|
||||
{ id: 'f1', parentId: 'root1', name: 'zebra notes.txt', type: 'text/plain', blobId: 'b1', size: 3, created: '', modified: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'owner:s1', parentId: null, name: 'zebra shared.txt', type: 'text/plain', blobId: 'b2', size: 3, created: '', modified: '', accountId: 'owner', isShared: true },
|
||||
];
|
||||
|
||||
it('derives the folder path and the raw id', () => {
|
||||
expect(pathOfNode(nodes as never, nodes[1] as never)).toBe('/Documents');
|
||||
expect(pathOfNode(nodes as never, nodes[0] as never)).toBe('/');
|
||||
expect(rawFileNodeId(nodes[2] as never)).toBe('s1');
|
||||
expect(rawFileNodeId(nodes[1] as never)).toBe('f1');
|
||||
});
|
||||
|
||||
it('filters the full listing by name substring and caches it per login', async () => {
|
||||
const listAllFileNodesAcrossAccounts = vi.fn(async () => nodes);
|
||||
const acc = account('login-a', { listAllFileNodesAcrossAccounts });
|
||||
const first = await filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal });
|
||||
expect(first.hits.map((h) => h.id)).toEqual(['f1', 's1']);
|
||||
expect(first.hits[0]).toMatchObject({ folderPath: '/Documents', isFolder: false, jmapAccountId: 'jmap-a' });
|
||||
expect(first.hits[1]).toMatchObject({ jmapAccountId: 'owner' });
|
||||
|
||||
await filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal });
|
||||
expect(listAllFileNodesAcrossAccounts).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The synchronous pass serves the same listing without a round-trip.
|
||||
const local = filesProvider.local(parseSearchQuery('notes'), [acc], 10);
|
||||
expect(local.map((h) => h.id)).toEqual(['f1']);
|
||||
|
||||
invalidateFileSearchCache('login-a');
|
||||
await filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal });
|
||||
expect(listAllFileNodesAcrossAccounts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not cache a failed listing', async () => {
|
||||
const listAllFileNodesAcrossAccounts = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(nodes);
|
||||
const acc = account('login-a', { listAllFileNodesAcrossAccounts });
|
||||
await expect(filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal })).rejects.toThrow('boom');
|
||||
const retry = await filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal });
|
||||
expect(retry.hits).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { accountMatches, matchesTerms, parseSearchQuery, tokenizeQuery, withScopeAndAccount } from '../query-parser';
|
||||
|
||||
describe('parseSearchQuery', () => {
|
||||
it('treats plain words as free text terms', () => {
|
||||
const parsed = parseSearchQuery(' quarterly Report ');
|
||||
expect(parsed.text).toBe('quarterly Report');
|
||||
expect(parsed.terms).toEqual(['quarterly', 'report']);
|
||||
expect(parsed.scope).toBe('all');
|
||||
expect(parsed.hasOperators).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps quoted phrases intact for the server and lower-cases them for local matching', () => {
|
||||
const parsed = parseSearchQuery('"Q3 planning" budget');
|
||||
expect(parsed.text).toBe('"Q3 planning" budget');
|
||||
expect(parsed.terms).toEqual(['q3 planning', 'budget']);
|
||||
});
|
||||
|
||||
it('tolerates an unterminated quote while the user is still typing', () => {
|
||||
expect(parseSearchQuery('"half typed').terms).toEqual(['half typed']);
|
||||
});
|
||||
|
||||
it('parses in: with aliases, including trash/junk as mail-only roles', () => {
|
||||
expect(parseSearchQuery('in:contacts bob').scope).toBe('contacts');
|
||||
expect(parseSearchQuery('in:events bob').scope).toBe('calendar');
|
||||
expect(parseSearchQuery('in:drive bob').scope).toBe('files');
|
||||
const trash = parseSearchQuery('in:trash invoice');
|
||||
expect(trash.scope).toBe('mail');
|
||||
expect(trash.mailboxRole).toBe('trash');
|
||||
expect(parseSearchQuery('in:spam x').mailboxRole).toBe('junk');
|
||||
});
|
||||
|
||||
it('maps mail operators onto SearchFilters', () => {
|
||||
const parsed = parseSearchQuery('from:alice to:bob subject:"Q3 report" has:attachment is:unread is:starred invoice');
|
||||
expect(parsed.mail.from).toBe('alice');
|
||||
expect(parsed.mail.to).toBe('bob');
|
||||
expect(parsed.mail.subject).toBe('Q3 report');
|
||||
expect(parsed.mail.hasAttachment).toBe(true);
|
||||
expect(parsed.mail.isUnread).toBe(true);
|
||||
expect(parsed.mail.isStarred).toBe(true);
|
||||
expect(parsed.text).toBe('invoice');
|
||||
expect(parsed.hasOperators).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts valid dates and feeds them to both mail and calendar', () => {
|
||||
const parsed = parseSearchQuery('after:2026-01-01 before:2026-02-28 sync');
|
||||
expect(parsed.after).toBe('2026-01-01');
|
||||
expect(parsed.before).toBe('2026-02-28');
|
||||
expect(parsed.mail.dateAfter).toBe('2026-01-01');
|
||||
expect(parsed.mail.dateBefore).toBe('2026-02-28');
|
||||
});
|
||||
|
||||
it('keeps malformed dates and unknown operators as free text', () => {
|
||||
const parsed = parseSearchQuery('after:2026-02-31 size:>5MB before:yesterday tag:x hello');
|
||||
expect(parsed.after).toBeNull();
|
||||
expect(parsed.before).toBeNull();
|
||||
expect(parsed.terms).toEqual(['after:2026-02-31', 'size:>5mb', 'before:yesterday', 'tag:x', 'hello']);
|
||||
expect(parsed.hasOperators).toBe(false);
|
||||
});
|
||||
|
||||
it('is:anything lifts the trash/junk exclusion', () => {
|
||||
expect(parseSearchQuery('is:anything x').includeTrashAndJunk).toBe(true);
|
||||
expect(parseSearchQuery('x').includeTrashAndJunk).toBe(false);
|
||||
});
|
||||
|
||||
it('lower-cases the account operator and supports quoted labels', () => {
|
||||
expect(parseSearchQuery('account:Work x').account).toBe('work');
|
||||
expect(parseSearchQuery('account:"Linus Rath" x').account).toBe('linus rath');
|
||||
});
|
||||
|
||||
it('handles a colon inside an ordinary word', () => {
|
||||
const parsed = parseSearchQuery('re: meeting 10:30');
|
||||
expect(parsed.terms).toEqual(['re:', 'meeting', '10:30']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeQuery', () => {
|
||||
it('splits operators, quoted values and words', () => {
|
||||
expect(tokenizeQuery('a from:"x y" "p q" k:v')).toEqual([
|
||||
{ key: null, value: 'a', quoted: false, original: 'a' },
|
||||
{ key: 'from', value: 'x y', quoted: true, original: 'from:"x y"' },
|
||||
{ key: null, value: 'p q', quoted: true, original: '"p q"' },
|
||||
{ key: 'k', value: 'v', quoted: false, original: 'k:v' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withScopeAndAccount', () => {
|
||||
it('rewrites in: and account: without touching the rest', () => {
|
||||
expect(withScopeAndAccount('in:mail from:bob hello account:old', 'contacts', 'Work')).toBe('from:bob hello in:contacts account:Work');
|
||||
expect(withScopeAndAccount('in:contacts hello', 'all', null)).toBe('hello');
|
||||
expect(withScopeAndAccount('hello', 'files', 'Linus Rath')).toBe('hello in:files account:"Linus Rath"');
|
||||
});
|
||||
|
||||
it('keeps in:trash because it is a folder, not a scope', () => {
|
||||
expect(withScopeAndAccount('in:trash x', 'all', null)).toBe('in:trash x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountMatches', () => {
|
||||
const account = { label: 'Work mail', email: 'linus@example.org', localAccountId: 'linus@example.org@mail.example.org' };
|
||||
it('matches label substrings, email prefixes and the login id', () => {
|
||||
expect(accountMatches(null, account)).toBe(true);
|
||||
expect(accountMatches('work', account)).toBe(true);
|
||||
expect(accountMatches('linus@', account)).toBe(true);
|
||||
expect(accountMatches('linus@example.org', account)).toBe(true);
|
||||
expect(accountMatches('other', account)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesTerms', () => {
|
||||
it('requires every term as a substring of some field', () => {
|
||||
expect(matchesTerms(['bob', 'partner'], ['Bob Miller', 'bob@partner.example'])).toBe(true);
|
||||
expect(matchesTerms(['bob', 'zzz'], ['Bob Miller'])).toBe(false);
|
||||
expect(matchesTerms([], ['x'])).toBe(false);
|
||||
expect(matchesTerms(['x'], [null, undefined, ''])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSearchQuery } from '../query-parser';
|
||||
import { mergeHits, rankHits, scoreHit } from '../rank';
|
||||
import type { ContactHit, GlobalSearchHit, MailHit } from '../types';
|
||||
|
||||
function mail(overrides: Partial<MailHit>): MailHit {
|
||||
return {
|
||||
kind: 'mail',
|
||||
localAccountId: 'login-a',
|
||||
jmapAccountId: 'a',
|
||||
id: 'm1',
|
||||
accountLabel: 'A',
|
||||
title: 'Subject',
|
||||
subtitle: 'Inbox',
|
||||
date: '2026-01-01T00:00:00Z',
|
||||
source: 'local',
|
||||
email: { id: 'm1' } as MailHit['email'],
|
||||
snippet: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function contact(overrides: Partial<ContactHit>): ContactHit {
|
||||
return {
|
||||
kind: 'contacts',
|
||||
localAccountId: 'login-a',
|
||||
jmapAccountId: 'a',
|
||||
id: 'c1',
|
||||
accountLabel: 'A',
|
||||
title: 'Bob Miller',
|
||||
subtitle: 'Personal',
|
||||
date: null,
|
||||
source: 'local',
|
||||
contact: { id: 'c1' } as ContactHit['contact'],
|
||||
storeId: 'login-a::c1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('scoreHit', () => {
|
||||
const parsed = parseSearchQuery('bob miller');
|
||||
it('prefers exact, then prefix, then whole-word, then substring, then subtitle', () => {
|
||||
expect(scoreHit(contact({ title: 'Bob Miller' }), parsed)).toBe(100);
|
||||
expect(scoreHit(contact({ title: 'Bob Miller Jr' }), parsed)).toBe(80);
|
||||
expect(scoreHit(contact({ title: 'Miller, Bob' }), parsed)).toBe(60);
|
||||
expect(scoreHit(contact({ title: 'Jobob Smiller' }), parsed)).toBe(50);
|
||||
expect(scoreHit(contact({ title: 'Bob Smith' }), parsed)).toBe(30);
|
||||
expect(scoreHit(contact({ title: 'Someone', subtitle: 'bob@x' }), parsed)).toBe(20);
|
||||
expect(scoreHit(contact({ title: 'Someone', subtitle: 'zzz' }), parsed)).toBe(10);
|
||||
});
|
||||
|
||||
it('gives every hit the floor score for operator-only queries', () => {
|
||||
expect(scoreHit(contact({ title: 'Anything' }), parseSearchQuery('is:unread'))).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankHits', () => {
|
||||
it('orders by score, then recency, then title', () => {
|
||||
const parsed = parseSearchQuery('report');
|
||||
const hits: GlobalSearchHit[] = [
|
||||
mail({ id: '1', title: 'Old report', date: '2025-01-01T00:00:00Z' }),
|
||||
mail({ id: '2', title: 'report', date: '2024-01-01T00:00:00Z' }),
|
||||
mail({ id: '3', title: 'New report', date: '2026-01-01T00:00:00Z' }),
|
||||
mail({ id: '4', title: 'Nothing here', date: '2027-01-01T00:00:00Z' }),
|
||||
mail({ id: '5', title: 'Undated report', date: null }),
|
||||
];
|
||||
expect(rankHits(hits, parsed).map((h) => h.id)).toEqual(['2', '3', '1', '5', '4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeHits', () => {
|
||||
it('lets a remote hit replace its local twin but not the other way round', () => {
|
||||
const local = mail({ id: 'm1', source: 'local' });
|
||||
const remote = mail({ id: 'm1', source: 'remote', snippet: { subject: '<mark>x</mark>', preview: null } });
|
||||
expect(mergeHits([local], [remote])).toEqual([remote]);
|
||||
expect(mergeHits([remote], [local])).toEqual([remote]);
|
||||
});
|
||||
|
||||
it('keeps same ids from different logins apart (#847)', () => {
|
||||
const a = mail({ id: 'm1', localAccountId: 'login-a' });
|
||||
const b = mail({ id: 'm1', localAccountId: 'login-b' });
|
||||
expect(mergeHits([a], [b])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the same id across kinds apart', () => {
|
||||
expect(mergeHits([mail({ id: 'x' })], [contact({ id: 'x' })])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('collapses a local occurrence and the remote master of the same series', () => {
|
||||
const occurrence = {
|
||||
kind: 'calendar', localAccountId: 'login-a', jmapAccountId: 'a', id: 'e1-occ2', accountLabel: 'A',
|
||||
title: 'Weekly sync', subtitle: 'Work', date: '2026-02-08T09:00:00', source: 'local',
|
||||
event: { id: 'e1-occ2', uid: 'series-1' }, isRecurring: true,
|
||||
} as unknown as GlobalSearchHit;
|
||||
const master = {
|
||||
...occurrence, id: 'e1', source: 'remote', event: { id: 'e1', uid: 'series-1' },
|
||||
} as unknown as GlobalSearchHit;
|
||||
const merged = mergeHits([occurrence], [master]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].id).toBe('e1');
|
||||
// Different logins with the same invite stay apart (#847).
|
||||
const otherLogin = { ...master, localAccountId: 'login-b' } as GlobalSearchHit;
|
||||
expect(mergeHits([master], [otherLogin])).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { parseSearchQuery } from '../query-parser';
|
||||
import { hasRemoteQuery, runGlobalSearch } from '../run-global-search';
|
||||
import type { GlobalSearchHit, SearchAccount, SearchOutcome, SearchProvider } from '../types';
|
||||
|
||||
function account(id: string): SearchAccount {
|
||||
return { localAccountId: id, label: id.toUpperCase(), email: `${id}@example.org`, client: {} as SearchAccount['client'] };
|
||||
}
|
||||
|
||||
function hit(kind: GlobalSearchHit['kind'], localAccountId: string, id: string, source: 'local' | 'remote' = 'remote'): GlobalSearchHit {
|
||||
return {
|
||||
kind, localAccountId, id, jmapAccountId: 'j', accountLabel: localAccountId, title: id, subtitle: '', date: null, source,
|
||||
contact: {} as never, storeId: id,
|
||||
} as GlobalSearchHit;
|
||||
}
|
||||
|
||||
function provider(kind: GlobalSearchHit['kind'], impl: Partial<SearchProvider>): SearchProvider {
|
||||
return {
|
||||
kind,
|
||||
supports: () => true,
|
||||
local: () => [],
|
||||
remote: async () => ({ hits: [], hasMore: false }),
|
||||
...impl,
|
||||
};
|
||||
}
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
describe('hasRemoteQuery', () => {
|
||||
it('needs text for non-mail kinds but accepts mail operators alone', () => {
|
||||
expect(hasRemoteQuery(parseSearchQuery('is:unread'), 'mail')).toBe(true);
|
||||
expect(hasRemoteQuery(parseSearchQuery('is:unread'), 'contacts')).toBe(false);
|
||||
expect(hasRemoteQuery(parseSearchQuery('in:trash'), 'mail')).toBe(true);
|
||||
expect(hasRemoteQuery(parseSearchQuery('bob'), 'files')).toBe(true);
|
||||
expect(hasRemoteQuery(parseSearchQuery(''), 'mail')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runGlobalSearch', () => {
|
||||
it('publishes local hits first, then merges each login as it lands', async () => {
|
||||
const a = deferred<{ hits: GlobalSearchHit[]; hasMore: boolean }>();
|
||||
const b = deferred<{ hits: GlobalSearchHit[]; hasMore: boolean }>();
|
||||
const contacts = provider('contacts', {
|
||||
local: () => [hit('contacts', 'a', 'c1', 'local')],
|
||||
remote: (_p, acc) => (acc.localAccountId === 'a' ? a.promise : b.promise),
|
||||
});
|
||||
const updates: SearchOutcome[] = [];
|
||||
const run = runGlobalSearch({
|
||||
parsed: parseSearchQuery('bob'),
|
||||
accounts: [account('a'), account('b')],
|
||||
providers: [contacts],
|
||||
localLimit: 5,
|
||||
remoteLimit: 5,
|
||||
signal: new AbortController().signal,
|
||||
onUpdate: (o) => updates.push(o),
|
||||
});
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].hits.contacts.map((h) => h.id)).toEqual(['c1']);
|
||||
expect(updates[0].status.contacts.status).toBe('loading');
|
||||
|
||||
b.resolve({ hits: [hit('contacts', 'b', 'c9')], hasMore: false });
|
||||
await vi.waitFor(() => expect(updates).toHaveLength(2));
|
||||
expect(updates[1].hits.contacts.map((h) => `${h.localAccountId}/${h.id}`)).toEqual(['a/c1', 'b/c9']);
|
||||
expect(updates[1].status.contacts.status).toBe('loading');
|
||||
|
||||
a.resolve({ hits: [hit('contacts', 'a', 'c1')], hasMore: true });
|
||||
const final = await run;
|
||||
expect(final.status.contacts.status).toBe('done');
|
||||
expect(final.status.contacts.hasMore).toBe(true);
|
||||
// The remote twin replaced the local one, no duplicate.
|
||||
expect(final.hits.contacts.filter((h) => h.id === 'c1')).toHaveLength(1);
|
||||
expect(final.hits.contacts.find((h) => h.id === 'c1')?.source).toBe('remote');
|
||||
});
|
||||
|
||||
it('turns a failing login into an error row and keeps the others', async () => {
|
||||
const mail = provider('mail', {
|
||||
remote: async (_p, acc) => {
|
||||
if (acc.localAccountId === 'b') throw new Error('boom');
|
||||
return { hits: [hit('mail', acc.localAccountId, 'm1')], hasMore: false };
|
||||
},
|
||||
});
|
||||
const final = await runGlobalSearch({
|
||||
parsed: parseSearchQuery('x'), accounts: [account('a'), account('b')], providers: [mail],
|
||||
localLimit: 5, remoteLimit: 5, signal: new AbortController().signal, onUpdate: () => {},
|
||||
});
|
||||
expect(final.hits.mail.map((h) => h.localAccountId)).toEqual(['a']);
|
||||
expect(final.status.mail.errors).toEqual([{ localAccountId: 'b', accountLabel: 'B', message: 'boom' }]);
|
||||
expect(final.status.mail.status).toBe('done');
|
||||
});
|
||||
|
||||
it('skips logins the provider does not support and kinds outside the scope', async () => {
|
||||
const remote = vi.fn(async () => ({ hits: [], hasMore: false }));
|
||||
const files = provider('files', { supports: (acc) => acc.localAccountId === 'a', remote });
|
||||
const calendar = provider('calendar', { remote });
|
||||
const final = await runGlobalSearch({
|
||||
parsed: parseSearchQuery('in:files x'), accounts: [account('a'), account('b')], providers: [files, calendar],
|
||||
localLimit: 5, remoteLimit: 5, signal: new AbortController().signal, onUpdate: () => {},
|
||||
});
|
||||
expect(remote).toHaveBeenCalledTimes(1);
|
||||
expect(final.status.files.status).toBe('done');
|
||||
expect(final.status.calendar.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('honours account: by dropping non-matching logins', async () => {
|
||||
const remote = vi.fn(async (_p: unknown, acc: SearchAccount) => ({ hits: [hit('mail', acc.localAccountId, 'm')], hasMore: false }));
|
||||
const final = await runGlobalSearch({
|
||||
parsed: parseSearchQuery('account:b x'), accounts: [account('a'), account('b')], providers: [provider('mail', { remote })],
|
||||
localLimit: 5, remoteLimit: 5, signal: new AbortController().signal, onUpdate: () => {},
|
||||
});
|
||||
expect(remote).toHaveBeenCalledTimes(1);
|
||||
expect(final.hits.mail.map((h) => h.localAccountId)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('caps in-flight requests at the concurrency limit', async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const gates: Array<() => void> = [];
|
||||
const remote = () => new Promise<{ hits: GlobalSearchHit[]; hasMore: boolean }>((resolve) => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
gates.push(() => { inFlight--; resolve({ hits: [], hasMore: false }); });
|
||||
});
|
||||
const accounts = ['a', 'b', 'c', 'd', 'e', 'f'].map(account);
|
||||
const run = runGlobalSearch({
|
||||
parsed: parseSearchQuery('x'), accounts, providers: [provider('mail', { remote })],
|
||||
localLimit: 5, remoteLimit: 5, concurrency: 2, signal: new AbortController().signal, onUpdate: () => {},
|
||||
});
|
||||
await vi.waitFor(() => expect(gates).toHaveLength(2));
|
||||
expect(peak).toBe(2);
|
||||
// Release one at a time: the pool must refill to exactly two, never more.
|
||||
let released = 0;
|
||||
while (released < accounts.length) {
|
||||
await vi.waitFor(() => expect(gates.length).toBeGreaterThan(0));
|
||||
gates.shift()!();
|
||||
released++;
|
||||
}
|
||||
await run;
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
|
||||
it('times out a slow login with a "timeout" error and stops publishing after abort', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const never = () => new Promise<{ hits: GlobalSearchHit[]; hasMore: boolean }>(() => {});
|
||||
const updates: SearchOutcome[] = [];
|
||||
const run = runGlobalSearch({
|
||||
parsed: parseSearchQuery('x'), accounts: [account('a')], providers: [provider('mail', { remote: never })],
|
||||
localLimit: 5, remoteLimit: 5, timeoutMs: 1000, signal: new AbortController().signal, onUpdate: (o) => updates.push(o),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
const final = await run;
|
||||
expect(final.status.mail.errors[0]?.message).toBe('timeout');
|
||||
|
||||
const controller = new AbortController();
|
||||
const aborted: SearchOutcome[] = [];
|
||||
const run2 = runGlobalSearch({
|
||||
parsed: parseSearchQuery('x'), accounts: [account('a')], providers: [provider('mail', { remote: never })],
|
||||
localLimit: 5, remoteLimit: 5, timeoutMs: 1000, signal: controller.signal, onUpdate: (o) => aborted.push(o),
|
||||
});
|
||||
controller.abort();
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await run2;
|
||||
expect(aborted).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import type { SearchAccount } from '@/lib/global-search/types';
|
||||
|
||||
/**
|
||||
* Every login the global search fans out to. Shared/delegated JMAP accounts
|
||||
* inside a login are handled by the per-kind enumerators the providers call
|
||||
* (`buildUnifiedAccountClients`, `searchContacts`, `queryAllCalendarEvents`,
|
||||
* `listAllFileNodesAcrossAccounts`), so this only lists logins.
|
||||
*/
|
||||
export function getSearchAccounts(): SearchAccount[] {
|
||||
const { accounts } = useAccountStore.getState();
|
||||
const { getClientForAccount, client: activeClient, activeAccountId } = useAuthStore.getState();
|
||||
const out: SearchAccount[] = [];
|
||||
for (const account of accounts) {
|
||||
if (!account.isConnected) continue;
|
||||
const client = getClientForAccount(account.id) ?? (account.id === activeAccountId ? activeClient : null);
|
||||
if (!client) continue;
|
||||
out.push({
|
||||
localAccountId: account.id,
|
||||
label: account.label || account.email,
|
||||
email: account.email,
|
||||
client,
|
||||
});
|
||||
}
|
||||
// A session without account-store entries (demo, tests): search the active client alone.
|
||||
if (out.length === 0 && activeClient && activeAccountId) {
|
||||
out.push({ localAccountId: activeAccountId, label: activeAccountId, email: activeAccountId, client: activeClient });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getActiveLocalAccountId(): string | null {
|
||||
return useAuthStore.getState().activeAccountId;
|
||||
}
|
||||
|
||||
export function indexAccounts(accounts: SearchAccount[]): Map<string, SearchAccount> {
|
||||
return new Map(accounts.map((account) => [account.localAccountId, account]));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { matchesTerms, type ParsedQuery } from '@/lib/global-search/query-parser';
|
||||
import { getActiveLocalAccountId, indexAccounts } from '@/lib/global-search/accounts';
|
||||
import type { CalendarHit, SearchAccount, SearchProvider } from '@/lib/global-search/types';
|
||||
import type { Calendar, CalendarEvent, CalendarEventFilter } from '@/lib/jmap/types';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
|
||||
function eventFields(event: CalendarEvent): string[] {
|
||||
const fields: string[] = [event.title, event.description];
|
||||
for (const location of Object.values(event.locations ?? {})) if (location?.name) fields.push(location.name);
|
||||
for (const participant of Object.values(event.participants ?? {})) {
|
||||
const p = participant as { name?: string | null; email?: string | null };
|
||||
if (p.name) fields.push(p.name);
|
||||
if (p.email) fields.push(p.email);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function calendarName(event: CalendarEvent, calendars: Calendar[]): string {
|
||||
for (const id of Object.keys(event.calendarIds ?? {})) {
|
||||
const calendar = calendars.find((c) => c.id === id || c.originalId === id);
|
||||
if (calendar?.name) return calendar.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function firstLocation(event: CalendarEvent): string {
|
||||
for (const location of Object.values(event.locations ?? {})) if (location?.name) return location.name;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function isRecurringEvent(event: CalendarEvent): boolean {
|
||||
return Boolean(event.recurrenceId) || (Array.isArray(event.recurrenceRules) && event.recurrenceRules.length > 0);
|
||||
}
|
||||
|
||||
function toHit(event: CalendarEvent, account: SearchAccount, source: 'local' | 'remote', calendars: Calendar[]): CalendarHit {
|
||||
const location = firstLocation(event);
|
||||
return {
|
||||
kind: 'calendar',
|
||||
localAccountId: account.localAccountId,
|
||||
jmapAccountId: event.accountId ?? account.client.getCalendarsAccountId(),
|
||||
id: event.originalId ?? event.id,
|
||||
accountLabel: account.label,
|
||||
title: event.title || '',
|
||||
subtitle: [calendarName(event, calendars), location].filter(Boolean).join(' · '),
|
||||
date: event.start ?? null,
|
||||
source,
|
||||
event,
|
||||
isRecurring: isRecurringEvent(event),
|
||||
};
|
||||
}
|
||||
|
||||
/** `after:`/`before:` are calendar days; the server compares against instants. */
|
||||
export function calendarFilterFor(parsed: ParsedQuery): CalendarEventFilter {
|
||||
const filter: CalendarEventFilter = { text: parsed.text };
|
||||
if (parsed.after) filter.after = `${parsed.after}T00:00:00Z`;
|
||||
if (parsed.before) filter.before = `${parsed.before}T23:59:59Z`;
|
||||
return filter;
|
||||
}
|
||||
|
||||
export const calendarProvider: SearchProvider = {
|
||||
kind: 'calendar',
|
||||
|
||||
supports: (account) => account.client.supportsCalendars(),
|
||||
|
||||
local: (parsed, accounts, limit) => {
|
||||
const byId = indexAccounts(accounts);
|
||||
const active = getActiveLocalAccountId();
|
||||
const { events, calendars } = useCalendarStore.getState();
|
||||
const hits: CalendarHit[] = [];
|
||||
// The store holds expanded occurrences; a matching series should appear
|
||||
// once, not once per occurrence in the visible window.
|
||||
const seenSeries = new Set<string>();
|
||||
for (const event of events) {
|
||||
const localAccountId = event.localAccountId ?? active;
|
||||
if (!localAccountId) continue;
|
||||
const account = byId.get(localAccountId);
|
||||
if (!account) continue;
|
||||
if (!matchesTerms(parsed.terms, eventFields(event))) continue;
|
||||
if (parsed.after && event.start && event.start.slice(0, 10) < parsed.after) continue;
|
||||
if (parsed.before && event.start && event.start.slice(0, 10) > parsed.before) continue;
|
||||
if (event.uid) {
|
||||
const seriesKey = `${localAccountId} ${event.uid}`;
|
||||
if (seenSeries.has(seriesKey)) continue;
|
||||
seenSeries.add(seriesKey);
|
||||
}
|
||||
hits.push(toHit(event, account, 'local', calendars));
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
},
|
||||
|
||||
remote: async (parsed, account, { limit, signal }) => {
|
||||
const events = await account.client.queryAllCalendarEvents(
|
||||
calendarFilterFor(parsed),
|
||||
[{ property: 'start', isAscending: false }],
|
||||
limit,
|
||||
);
|
||||
if (signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
const { calendars } = useCalendarStore.getState();
|
||||
const hits = events.slice(0, limit).map((event) => toHit(
|
||||
{ ...event, localAccountId: account.localAccountId },
|
||||
account,
|
||||
'remote',
|
||||
calendars,
|
||||
));
|
||||
return { hits, hasMore: events.length >= limit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { matchesTerms, type ParsedQuery } from '@/lib/global-search/query-parser';
|
||||
import { getActiveLocalAccountId, indexAccounts } from '@/lib/global-search/accounts';
|
||||
import type { ContactHit, SearchAccount, SearchProvider } from '@/lib/global-search/types';
|
||||
import type { AddressBook, ContactCard } from '@/lib/jmap/types';
|
||||
import { getContactDisplayName, getContactPrimaryEmail, useContactStore } from '@/stores/contact-store';
|
||||
|
||||
/** Pro-shell store namespace: `${localAccountId}::${id}` (see contact-store). */
|
||||
const STORE_DELIMITER = '::';
|
||||
|
||||
function contactFields(contact: ContactCard): string[] {
|
||||
const fields: string[] = [getContactDisplayName(contact)];
|
||||
for (const email of Object.values(contact.emails ?? {})) if (email?.address) fields.push(email.address);
|
||||
for (const org of Object.values(contact.organizations ?? {})) {
|
||||
const name = (org as { name?: string }).name;
|
||||
if (name) fields.push(name);
|
||||
}
|
||||
for (const phone of Object.values(contact.phones ?? {})) {
|
||||
const number = (phone as { number?: string }).number;
|
||||
if (number) fields.push(number);
|
||||
}
|
||||
for (const nick of Object.values(contact.nicknames ?? {})) {
|
||||
const name = (nick as { name?: string }).name;
|
||||
if (name) fields.push(name);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function bookName(contact: ContactCard, books: AddressBook[]): string {
|
||||
const ids = Object.keys(contact.addressBookIds ?? {});
|
||||
for (const id of ids) {
|
||||
const book = books.find((b) => b.id === id || b.originalId === id);
|
||||
if (book?.name) return book.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function toHit(
|
||||
contact: ContactCard,
|
||||
account: SearchAccount,
|
||||
storeId: string,
|
||||
source: 'local' | 'remote',
|
||||
books: AddressBook[],
|
||||
): ContactHit {
|
||||
const title = getContactDisplayName(contact) || getContactPrimaryEmail(contact) || contact.originalId || contact.id;
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const book = bookName(contact, books);
|
||||
return {
|
||||
kind: 'contacts',
|
||||
localAccountId: account.localAccountId,
|
||||
jmapAccountId: contact.accountId ?? account.client.getContactsAccountId(),
|
||||
id: contact.originalId ?? contact.id,
|
||||
accountLabel: account.label,
|
||||
title,
|
||||
subtitle: [book, email && email !== title ? email : ''].filter(Boolean).join(' · '),
|
||||
date: contact.updated ?? null,
|
||||
source,
|
||||
contact,
|
||||
storeId,
|
||||
};
|
||||
}
|
||||
|
||||
export const contactsProvider: SearchProvider = {
|
||||
kind: 'contacts',
|
||||
|
||||
supports: (account) => account.client.supportsContacts(),
|
||||
|
||||
local: (parsed: ParsedQuery, accounts: SearchAccount[], limit: number) => {
|
||||
const byId = indexAccounts(accounts);
|
||||
const active = getActiveLocalAccountId();
|
||||
const { contacts, addressBooks } = useContactStore.getState();
|
||||
const hits: ContactHit[] = [];
|
||||
for (const contact of contacts) {
|
||||
const localAccountId = contact.localAccountId ?? active;
|
||||
if (!localAccountId) continue;
|
||||
const account = byId.get(localAccountId);
|
||||
if (!account) continue;
|
||||
if (!matchesTerms(parsed.terms, contactFields(contact))) continue;
|
||||
hits.push(toHit(contact, account, contact.id, 'local', addressBooks));
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
},
|
||||
|
||||
remote: async (parsed, account, { limit, signal }) => {
|
||||
const found = await account.client.searchContacts(parsed.text);
|
||||
if (signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
const { addressBooks } = useContactStore.getState();
|
||||
// The store namespaces every aggregated account, so the id the contacts
|
||||
// surface knows is `${login}::${id-as-returned-by-the-client}` (shared
|
||||
// books already carry their `${owner}:` prefix from searchContacts).
|
||||
const hits = found.slice(0, limit).map((contact) => toHit(
|
||||
{ ...contact, localAccountId: account.localAccountId },
|
||||
account,
|
||||
`${account.localAccountId}${STORE_DELIMITER}${contact.id}`,
|
||||
'remote',
|
||||
addressBooks,
|
||||
));
|
||||
return { hits, hasMore: found.length > limit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { matchesTerms, type ParsedQuery } from '@/lib/global-search/query-parser';
|
||||
import type { FileHit, SearchAccount, SearchProvider } from '@/lib/global-search/types';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
|
||||
/**
|
||||
* Files have no usable server search (`FileNode/query` matches `name` exactly
|
||||
* and `text` matches everything - plan §2.1), so both passes filter the full
|
||||
* listing the Files app fetches anyway (`FileNode/get {ids: null}`), cached
|
||||
* per login for a short while. Thousands of nodes filter client-side without
|
||||
* noticeable cost.
|
||||
*/
|
||||
export const FILE_LISTING_TTL_MS = 60_000;
|
||||
|
||||
interface CachedListing {
|
||||
fetchedAt: number;
|
||||
promise: Promise<FileNode[]>;
|
||||
nodes: FileNode[] | null;
|
||||
}
|
||||
|
||||
const listings = new Map<string, CachedListing>();
|
||||
|
||||
export function invalidateFileSearchCache(localAccountId?: string): void {
|
||||
if (localAccountId) listings.delete(localAccountId);
|
||||
else listings.clear();
|
||||
}
|
||||
|
||||
/** Mutations through the Files app (move/delete/upload/rename) leave a stale listing behind. */
|
||||
let subscribed = false;
|
||||
function subscribeToFileMutations(): void {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
useFileStore.subscribe((state, previous) => {
|
||||
if (state.lastAction !== previous.lastAction
|
||||
|| (previous.uploadProgress !== null && state.uploadProgress === null)
|
||||
|| (previous.isLoading && !state.isLoading)) {
|
||||
invalidateFileSearchCache(state.currentAccountId ?? undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadListing(account: SearchAccount): CachedListing {
|
||||
subscribeToFileMutations();
|
||||
const cached = listings.get(account.localAccountId);
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.fetchedAt < FILE_LISTING_TTL_MS) return cached;
|
||||
const entry: CachedListing = {
|
||||
fetchedAt: now,
|
||||
nodes: null,
|
||||
promise: account.client.listAllFileNodesAcrossAccounts().then((nodes) => {
|
||||
entry.nodes = nodes;
|
||||
return nodes;
|
||||
}, (error) => {
|
||||
// A failed fetch must not be served as "no files" for a minute.
|
||||
if (listings.get(account.localAccountId) === entry) listings.delete(account.localAccountId);
|
||||
throw error;
|
||||
}),
|
||||
};
|
||||
listings.set(account.localAccountId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Cached nodes for a login when they are already in memory (the palette's synchronous pass). */
|
||||
export function peekFileListing(localAccountId: string): FileNode[] | null {
|
||||
const cached = listings.get(localAccountId);
|
||||
return cached?.nodes ?? null;
|
||||
}
|
||||
|
||||
export function isFolderNode(node: Pick<FileNode, 'blobId'>): boolean {
|
||||
return node.blobId == null;
|
||||
}
|
||||
|
||||
/** Inverse of the Files store's `resolvePathToId`: the folder path a node lives in. */
|
||||
export function pathOfNode(nodes: FileNode[], node: FileNode): string {
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const segments: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let parentId = node.parentId;
|
||||
while (parentId != null && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (!parent) break;
|
||||
segments.unshift(parent.name);
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
return `/${segments.join('/')}`;
|
||||
}
|
||||
|
||||
/** Raw server id of a node (`listAllFileNodesAcrossAccounts` namespaces shared ones as `${owner}:${id}`). */
|
||||
export function rawFileNodeId(node: FileNode): string {
|
||||
if (node.isShared && node.accountId && node.id.startsWith(`${node.accountId}:`)) {
|
||||
return node.id.slice(node.accountId.length + 1);
|
||||
}
|
||||
return node.id;
|
||||
}
|
||||
|
||||
function filterNodes(parsed: ParsedQuery, nodes: FileNode[], account: SearchAccount, limit: number, source: 'local' | 'remote'): FileHit[] {
|
||||
const hits: FileHit[] = [];
|
||||
for (const node of nodes) {
|
||||
if (!matchesTerms(parsed.terms, [node.name, isFolderNode(node) ? 'folder' : node.type])) continue;
|
||||
const folderPath = pathOfNode(nodes, node);
|
||||
hits.push({
|
||||
kind: 'files',
|
||||
localAccountId: account.localAccountId,
|
||||
jmapAccountId: node.accountId ?? account.client.getFilesAccountId(),
|
||||
id: rawFileNodeId(node),
|
||||
accountLabel: account.label,
|
||||
title: node.name,
|
||||
subtitle: [node.isShared && node.accountName ? node.accountName : '', folderPath].filter(Boolean).join(' · '),
|
||||
date: node.modified || node.created || null,
|
||||
source,
|
||||
node,
|
||||
folderPath,
|
||||
isFolder: isFolderNode(node),
|
||||
});
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
export const filesProvider: SearchProvider = {
|
||||
kind: 'files',
|
||||
|
||||
supports: (account) => account.client.supportsFiles(),
|
||||
|
||||
local: (parsed, accounts, limit) => {
|
||||
const hits: FileHit[] = [];
|
||||
for (const account of accounts) {
|
||||
const nodes = peekFileListing(account.localAccountId);
|
||||
if (!nodes) continue;
|
||||
hits.push(...filterNodes(parsed, nodes, account, limit - hits.length, 'local'));
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
},
|
||||
|
||||
remote: async (parsed, account, { limit, signal }) => {
|
||||
const nodes = await loadListing(account).promise;
|
||||
if (signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
const hits = filterNodes(parsed, nodes, account, limit + 1, 'remote');
|
||||
return { hits: hits.slice(0, limit), hasMore: hits.length > limit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { calendarProvider } from '@/lib/global-search/providers/calendar';
|
||||
import { contactsProvider } from '@/lib/global-search/providers/contacts';
|
||||
import { filesProvider } from '@/lib/global-search/providers/files';
|
||||
import { mailProvider } from '@/lib/global-search/providers/mail';
|
||||
import type { SearchProvider } from '@/lib/global-search/types';
|
||||
|
||||
export const GLOBAL_SEARCH_PROVIDERS: readonly SearchProvider[] = [
|
||||
mailProvider,
|
||||
contactsProvider,
|
||||
calendarProvider,
|
||||
filesProvider,
|
||||
];
|
||||
@@ -0,0 +1,155 @@
|
||||
import { matchesTerms, type ParsedQuery } from '@/lib/global-search/query-parser';
|
||||
import { getActiveLocalAccountId, indexAccounts } from '@/lib/global-search/accounts';
|
||||
import type { MailHit, SearchAccount, SearchProvider } from '@/lib/global-search/types';
|
||||
import { buildJMAPFilter } from '@/lib/jmap/search-utils';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { findMailboxByRole, resolveSourceFolderName, type UnifiedAccountClient } from '@/lib/unified-mailbox';
|
||||
import { buildUnifiedAccountClients, useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
function addressFields(addresses: Email['from']): string[] {
|
||||
const out: string[] = [];
|
||||
for (const address of addresses ?? []) {
|
||||
if (address.name) out.push(address.name);
|
||||
if (address.email) out.push(address.email);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function emailFields(email: Email): string[] {
|
||||
return [email.subject ?? '', email.preview ?? '', ...addressFields(email.from), ...addressFields(email.to)];
|
||||
}
|
||||
|
||||
function includesCi(haystack: string[], needle: string): boolean {
|
||||
const n = needle.toLowerCase();
|
||||
return haystack.some((h) => h.toLowerCase().includes(n));
|
||||
}
|
||||
|
||||
/** The structured operators, applied to an already-loaded message. */
|
||||
export function emailMatchesFilters(email: Email, parsed: ParsedQuery): boolean {
|
||||
const { mail } = parsed;
|
||||
if (mail.from && !includesCi(addressFields(email.from), mail.from)) return false;
|
||||
if (mail.to && !includesCi([...addressFields(email.to), ...addressFields(email.cc)], mail.to)) return false;
|
||||
if (mail.subject && !includesCi([email.subject ?? ''], mail.subject)) return false;
|
||||
if (mail.hasAttachment !== null && Boolean(email.hasAttachment) !== mail.hasAttachment) return false;
|
||||
if (mail.isUnread !== null && Boolean(email.keywords?.$seen) === mail.isUnread) return false;
|
||||
if (mail.isStarred !== null && Boolean(email.keywords?.$flagged) !== mail.isStarred) return false;
|
||||
const day = email.receivedAt?.slice(0, 10) ?? '';
|
||||
if (mail.dateAfter && day < mail.dateAfter) return false;
|
||||
if (mail.dateBefore && day > mail.dateBefore) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function folderOf(email: Email, mailboxes: Mailbox[]): string {
|
||||
if (email.sourceFolder) return email.sourceFolder;
|
||||
return resolveSourceFolderName(email, mailboxes) ?? '';
|
||||
}
|
||||
|
||||
function toHit(email: Email, account: SearchAccount, jmapAccountId: string, folder: string, source: 'local' | 'remote'): MailHit {
|
||||
return {
|
||||
kind: 'mail',
|
||||
localAccountId: account.localAccountId,
|
||||
jmapAccountId,
|
||||
id: email.id,
|
||||
accountLabel: account.label,
|
||||
title: email.subject ?? '',
|
||||
subtitle: folder,
|
||||
date: email.receivedAt ?? null,
|
||||
source,
|
||||
email,
|
||||
snippet: email.searchSnippet ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
type JmapFilter = Record<string, unknown>;
|
||||
|
||||
/** `AND` two JMAP filters, flattening into an existing top-level `AND`. */
|
||||
export function andFilters(base: JmapFilter, extra: JmapFilter | null): JmapFilter {
|
||||
if (!extra || Object.keys(extra).length === 0) return base;
|
||||
if (Object.keys(base).length === 0) return extra;
|
||||
if (base.operator === 'AND' && Array.isArray(base.conditions)) {
|
||||
return { operator: 'AND', conditions: [...(base.conditions as JmapFilter[]), extra] };
|
||||
}
|
||||
return { operator: 'AND', conditions: [base, extra] };
|
||||
}
|
||||
|
||||
function jmapMailboxId(entry: UnifiedAccountClient, mailbox: Mailbox): string {
|
||||
return entry.isShared ? (mailbox.originalId ?? mailbox.id) : mailbox.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default scope is "everything but Trash and Junk" (#641's "Most");
|
||||
* `in:trash` / `in:junk` search that folder alone and `is:anything` lifts the
|
||||
* exclusion. Servers without those role folders just search everything.
|
||||
*/
|
||||
export function mailFilterFor(parsed: ParsedQuery, entry: UnifiedAccountClient): JmapFilter {
|
||||
const base = buildJMAPFilter(parsed.text, parsed.mail);
|
||||
if (parsed.mailboxRole) {
|
||||
const mailbox = findMailboxByRole(entry.mailboxes, parsed.mailboxRole);
|
||||
return mailbox ? andFilters(base, { inMailbox: jmapMailboxId(entry, mailbox) }) : base;
|
||||
}
|
||||
if (parsed.includeTrashAndJunk) return base;
|
||||
const excluded = (['trash', 'junk'] as const)
|
||||
.map((role) => findMailboxByRole(entry.mailboxes, role))
|
||||
.filter((mailbox): mailbox is Mailbox => Boolean(mailbox))
|
||||
.map((mailbox) => jmapMailboxId(entry, mailbox));
|
||||
return excluded.length > 0 ? andFilters(base, { inMailboxOtherThan: excluded }) : base;
|
||||
}
|
||||
|
||||
export const mailProvider: SearchProvider = {
|
||||
kind: 'mail',
|
||||
|
||||
supports: () => true,
|
||||
|
||||
local: (parsed, accounts, limit) => {
|
||||
const byId = indexAccounts(accounts);
|
||||
const active = getActiveLocalAccountId();
|
||||
const { emails, mailboxes } = useEmailStore.getState();
|
||||
const hits: MailHit[] = [];
|
||||
for (const email of emails) {
|
||||
const localAccountId = email.sourceClientAccountId ?? active;
|
||||
if (!localAccountId) continue;
|
||||
const account = byId.get(localAccountId);
|
||||
if (!account) continue;
|
||||
if (!matchesTerms(parsed.terms, emailFields(email))) continue;
|
||||
if (!emailMatchesFilters(email, parsed)) continue;
|
||||
const mailbox = mailboxes.find((mb) => email.mailboxIds?.[mb.id]);
|
||||
const jmapAccountId = email.sourceAccountId
|
||||
?? (mailbox?.isShared && mailbox.accountId ? mailbox.accountId : account.client.getAccountId());
|
||||
hits.push(toHit(email, account, jmapAccountId, folderOf(email, mailboxes), 'local'));
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
},
|
||||
|
||||
remote: async (parsed, account, { limit, position = 0, signal }) => {
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified ?? true;
|
||||
const entries = await buildUnifiedAccountClients({ includeGroup, scopeToClientAccountId: account.localAccountId });
|
||||
const hits: MailHit[] = [];
|
||||
let hasMore = false;
|
||||
// Sequential on purpose: the orchestrator already runs logins in
|
||||
// parallel, and every request here pins one of the login's sockets (#702).
|
||||
for (const entry of entries) {
|
||||
if (signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
const result = await entry.client.advancedSearchEmails(
|
||||
mailFilterFor(parsed, entry),
|
||||
entry.isShared ? entry.jmapAccountId : undefined,
|
||||
limit,
|
||||
position,
|
||||
);
|
||||
if (result.hasMore) hasMore = true;
|
||||
for (const raw of result.emails) {
|
||||
const email: Email = {
|
||||
...raw,
|
||||
accountId: entry.accountId,
|
||||
accountLabel: entry.accountLabel,
|
||||
sourceClientAccountId: entry.clientAccountId,
|
||||
sourceAccountId: entry.jmapAccountId,
|
||||
sourceFolder: resolveSourceFolderName(raw, entry.mailboxes),
|
||||
};
|
||||
hits.push(toHit(email, account, entry.jmapAccountId, email.sourceFolder ?? '', 'remote'));
|
||||
}
|
||||
}
|
||||
return { hits, hasMore };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import { DEFAULT_SEARCH_FILTERS, type SearchFilters } from '@/lib/jmap/search-utils';
|
||||
import type { SearchKind } from '@/lib/global-search/types';
|
||||
|
||||
/**
|
||||
* Query syntax for global search (plan §3.3, the v1 subset of the Pro
|
||||
* features doc §8.2):
|
||||
*
|
||||
* in:mail|contacts|calendar|files scope (also in:trash / in:junk for mail)
|
||||
* account:<label|email> restrict to one login
|
||||
* from: to: subject: mail filters (1:1 onto SearchFilters)
|
||||
* has:attachment mail
|
||||
* is:unread|read|starred|anything mail (`anything` includes Trash/Junk)
|
||||
* after:YYYY-MM-DD before:YYYY-MM-DD mail + calendar date bounds
|
||||
* "quoted phrase" kept as a phrase for the server
|
||||
*
|
||||
* Everything else - including unknown `key:value` pairs and malformed dates -
|
||||
* stays free text, so a colon in a normal query never silently drops words.
|
||||
*/
|
||||
export type SearchScope = 'all' | SearchKind;
|
||||
|
||||
export interface ParsedQuery {
|
||||
raw: string;
|
||||
/** Free text for the server: words and quoted phrases, operators removed. */
|
||||
text: string;
|
||||
/** Lower-cased words and phrases for local substring matching. Empty when the query has only operators. */
|
||||
terms: string[];
|
||||
scope: SearchScope;
|
||||
/** `account:` value, lower-cased; matched against the login label / email. */
|
||||
account: string | null;
|
||||
/** Mail structured filters (`from:` … `is:starred`). Dates are mirrored in `after`/`before`. */
|
||||
mail: SearchFilters;
|
||||
/** `YYYY-MM-DD` bounds shared by mail and calendar. */
|
||||
after: string | null;
|
||||
before: string | null;
|
||||
/** `in:trash` / `in:junk`: search that role folder only. */
|
||||
mailboxRole: 'trash' | 'junk' | null;
|
||||
/** `is:anything`: do not exclude Trash and Junk from the default mail scope. */
|
||||
includeTrashAndJunk: boolean;
|
||||
/** True when any operator narrowed the query (drives the palette's "syntax" hint). */
|
||||
hasOperators: boolean;
|
||||
}
|
||||
|
||||
const SCOPE_ALIASES: Record<string, SearchScope> = {
|
||||
all: 'all',
|
||||
everything: 'all',
|
||||
mail: 'mail',
|
||||
email: 'mail',
|
||||
emails: 'mail',
|
||||
messages: 'mail',
|
||||
contacts: 'contacts',
|
||||
contact: 'contacts',
|
||||
people: 'contacts',
|
||||
calendar: 'calendar',
|
||||
calendars: 'calendar',
|
||||
events: 'calendar',
|
||||
event: 'calendar',
|
||||
files: 'files',
|
||||
file: 'files',
|
||||
drive: 'files',
|
||||
};
|
||||
|
||||
const OPERATORS = new Set(['in', 'account', 'from', 'to', 'subject', 'has', 'is', 'after', 'before']);
|
||||
|
||||
/** Accepts `YYYY-MM-DD` that names a real calendar day. */
|
||||
export function isValidLinkDate(value: string): boolean {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!match) return false;
|
||||
const [, y, m, d] = match;
|
||||
const date = new Date(Number(y), Number(m) - 1, Number(d));
|
||||
return date.getMonth() === Number(m) - 1 && date.getDate() === Number(d);
|
||||
}
|
||||
|
||||
interface Token {
|
||||
key: string | null;
|
||||
value: string;
|
||||
quoted: boolean;
|
||||
/** The token exactly as typed, for putting unknown operators back into the text. */
|
||||
original: string;
|
||||
}
|
||||
|
||||
// `key:"quoted value"`, `key:value`, `"quoted phrase"`, or a bare word. A
|
||||
// trailing unmatched quote is tolerated (the user is still typing).
|
||||
const TOKEN_RE = /([A-Za-z]+):(?:"([^"]*)"?|(\S+))|"([^"]*)"?|(\S+)/g;
|
||||
|
||||
export function tokenizeQuery(raw: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
for (const match of raw.matchAll(TOKEN_RE)) {
|
||||
const [original, key, quotedValue, bareValue, phrase, word] = match;
|
||||
if (key !== undefined) {
|
||||
tokens.push({
|
||||
key: key.toLowerCase(),
|
||||
value: quotedValue ?? bareValue ?? '',
|
||||
quoted: quotedValue !== undefined,
|
||||
original,
|
||||
});
|
||||
} else if (phrase !== undefined) {
|
||||
tokens.push({ key: null, value: phrase, quoted: true, original });
|
||||
} else if (word !== undefined) {
|
||||
tokens.push({ key: null, value: word, quoted: false, original });
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function parseSearchQuery(raw: string): ParsedQuery {
|
||||
const textParts: string[] = [];
|
||||
const terms: string[] = [];
|
||||
const mail: SearchFilters = { ...DEFAULT_SEARCH_FILTERS };
|
||||
let scope: SearchScope = 'all';
|
||||
let account: string | null = null;
|
||||
let after: string | null = null;
|
||||
let before: string | null = null;
|
||||
let mailboxRole: 'trash' | 'junk' | null = null;
|
||||
let includeTrashAndJunk = false;
|
||||
let hasOperators = false;
|
||||
|
||||
const pushText = (value: string, quoted: boolean) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
textParts.push(quoted ? `"${trimmed}"` : trimmed);
|
||||
terms.push(trimmed.toLowerCase());
|
||||
};
|
||||
|
||||
for (const token of tokenizeQuery(raw)) {
|
||||
if (token.key === null || !OPERATORS.has(token.key)) {
|
||||
// Bare word, phrase, or an unknown `foo:bar` - all free text.
|
||||
if (token.key !== null) pushText(token.original, false);
|
||||
else pushText(token.value, token.quoted);
|
||||
continue;
|
||||
}
|
||||
const value = token.value.trim();
|
||||
const lower = value.toLowerCase();
|
||||
let consumed = true;
|
||||
switch (token.key) {
|
||||
case 'in': {
|
||||
const alias = SCOPE_ALIASES[lower];
|
||||
if (alias) {
|
||||
scope = alias;
|
||||
} else if (lower === 'trash' || lower === 'bin') {
|
||||
scope = 'mail';
|
||||
mailboxRole = 'trash';
|
||||
} else if (lower === 'junk' || lower === 'spam') {
|
||||
scope = 'mail';
|
||||
mailboxRole = 'junk';
|
||||
} else {
|
||||
consumed = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'account':
|
||||
if (lower) account = lower;
|
||||
else consumed = false;
|
||||
break;
|
||||
case 'from':
|
||||
if (value) mail.from = value;
|
||||
else consumed = false;
|
||||
break;
|
||||
case 'to':
|
||||
if (value) mail.to = value;
|
||||
else consumed = false;
|
||||
break;
|
||||
case 'subject':
|
||||
if (value) mail.subject = value;
|
||||
else consumed = false;
|
||||
break;
|
||||
case 'has':
|
||||
if (lower === 'attachment' || lower === 'attachments' || lower === 'file') mail.hasAttachment = true;
|
||||
else consumed = false;
|
||||
break;
|
||||
case 'is':
|
||||
if (lower === 'unread') mail.isUnread = true;
|
||||
else if (lower === 'read') mail.isUnread = false;
|
||||
else if (lower === 'starred' || lower === 'flagged') mail.isStarred = true;
|
||||
else if (lower === 'unstarred') mail.isStarred = false;
|
||||
else if (lower === 'anything' || lower === 'any') includeTrashAndJunk = true;
|
||||
else consumed = false;
|
||||
break;
|
||||
case 'after':
|
||||
if (isValidLinkDate(value)) {
|
||||
after = value;
|
||||
mail.dateAfter = value;
|
||||
} else consumed = false;
|
||||
break;
|
||||
case 'before':
|
||||
if (isValidLinkDate(value)) {
|
||||
before = value;
|
||||
mail.dateBefore = value;
|
||||
} else consumed = false;
|
||||
break;
|
||||
}
|
||||
if (consumed) hasOperators = true;
|
||||
else pushText(token.original, false);
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
text: textParts.join(' '),
|
||||
terms,
|
||||
scope,
|
||||
account,
|
||||
mail,
|
||||
after,
|
||||
before,
|
||||
mailboxRole,
|
||||
includeTrashAndJunk,
|
||||
hasOperators,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the `in:` / `account:` operators of a raw query - the palette's
|
||||
* scope and account chips are sugar over the syntax, so the two never drift.
|
||||
*/
|
||||
export function withScopeAndAccount(raw: string, scope: SearchScope, accountLabel: string | null): string {
|
||||
const kept = tokenizeQuery(raw)
|
||||
.filter((t) => !(t.key === 'in' && SCOPE_ALIASES[t.value.toLowerCase()]) && t.key !== 'account')
|
||||
.map((t) => t.original);
|
||||
if (scope !== 'all') kept.push(`in:${scope}`);
|
||||
if (accountLabel) kept.push(/\s/.test(accountLabel) ? `account:"${accountLabel}"` : `account:${accountLabel}`);
|
||||
return kept.join(' ');
|
||||
}
|
||||
|
||||
/** Does a login match the `account:` operator (label substring or email prefix/equality)? */
|
||||
export function accountMatches(pattern: string | null, account: { label: string; email: string; localAccountId: string }): boolean {
|
||||
if (!pattern) return true;
|
||||
const p = pattern.toLowerCase();
|
||||
const email = account.email.toLowerCase();
|
||||
const label = account.label.toLowerCase();
|
||||
return email === p || email.startsWith(p) || label.includes(p) || account.localAccountId.toLowerCase() === p;
|
||||
}
|
||||
|
||||
/** Local matching helper: every term is a substring of at least one of the given fields. */
|
||||
export function matchesTerms(terms: string[], fields: Array<string | null | undefined>): boolean {
|
||||
if (terms.length === 0) return false;
|
||||
const haystack = fields.filter((f): f is string => typeof f === 'string' && f.length > 0).map((f) => f.toLowerCase());
|
||||
if (haystack.length === 0) return false;
|
||||
return terms.every((term) => haystack.some((field) => field.includes(term)));
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ParsedQuery } from '@/lib/global-search/query-parser';
|
||||
import { hitKey, type GlobalSearchHit } from '@/lib/global-search/types';
|
||||
|
||||
/**
|
||||
* Identity used for de-duplication. Calendar hits collapse per series: the
|
||||
* local cache holds expanded occurrences while server FTS returns the master,
|
||||
* and a search result should list a series once - so the uid, not the
|
||||
* occurrence id, is the key (per login; the same invite in two logins stays
|
||||
* two hits, #847).
|
||||
*/
|
||||
function mergeKey(hit: GlobalSearchHit): string {
|
||||
if (hit.kind === 'calendar' && hit.event.uid) {
|
||||
return `calendar ${hit.localAccountId} uid:${hit.event.uid}`;
|
||||
}
|
||||
return hitKey(hit);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function atWordBoundary(field: string, term: string): boolean {
|
||||
return new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(term)}`, 'u').test(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relevance of a hit for the typed terms. Titles are what the user scans, so
|
||||
* exact > prefix > whole-word > substring on the title, then a subtitle match,
|
||||
* and finally hits the server matched on a field we don't show (body text).
|
||||
* Recency breaks ties in `rankHits`.
|
||||
*/
|
||||
export function scoreHit(hit: GlobalSearchHit, parsed: ParsedQuery): number {
|
||||
const query = parsed.terms.join(' ');
|
||||
if (!query) return 10;
|
||||
const title = hit.title.toLowerCase();
|
||||
if (title === query) return 100;
|
||||
if (title.startsWith(query)) return 80;
|
||||
if (parsed.terms.every((term) => atWordBoundary(title, term))) return 60;
|
||||
if (parsed.terms.every((term) => title.includes(term))) return 50;
|
||||
if (parsed.terms.some((term) => title.includes(term))) return 30;
|
||||
const subtitle = hit.subtitle.toLowerCase();
|
||||
if (parsed.terms.some((term) => subtitle.includes(term))) return 20;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function dateValue(hit: GlobalSearchHit): number {
|
||||
if (!hit.date) return Number.NEGATIVE_INFINITY;
|
||||
const ms = new Date(hit.date).getTime();
|
||||
return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms;
|
||||
}
|
||||
|
||||
/** Newest first; hits without a date sink to the bottom. */
|
||||
export function compareByRecency(a: GlobalSearchHit, b: GlobalSearchHit): number {
|
||||
return dateValue(b) - dateValue(a);
|
||||
}
|
||||
|
||||
/** Sorted copy: score desc, recency desc, then title for a stable order. */
|
||||
export function rankHits(hits: GlobalSearchHit[], parsed: ParsedQuery): GlobalSearchHit[] {
|
||||
return hits
|
||||
.map((hit, index) => ({ hit, index, score: scoreHit(hit, parsed) }))
|
||||
.sort((a, b) =>
|
||||
b.score - a.score
|
||||
|| compareByRecency(a.hit, b.hit)
|
||||
|| a.hit.title.localeCompare(b.hit.title)
|
||||
|| a.index - b.index)
|
||||
.map((entry) => entry.hit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupes by identity (see mergeKey). A remote hit replaces a local one for the same
|
||||
* item - it carries the server snippet and the freshest data - while a local
|
||||
* hit never displaces a remote one that already landed. Order of first
|
||||
* appearance is kept; callers re-rank afterwards.
|
||||
*/
|
||||
export function mergeHits(existing: GlobalSearchHit[], incoming: GlobalSearchHit[]): GlobalSearchHit[] {
|
||||
const byKey = new Map<string, GlobalSearchHit>();
|
||||
for (const hit of existing) byKey.set(mergeKey(hit), hit);
|
||||
for (const hit of incoming) {
|
||||
const key = mergeKey(hit);
|
||||
const current = byKey.get(key);
|
||||
if (current && current.source === 'remote' && hit.source === 'local') continue;
|
||||
byKey.set(key, hit);
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { accountMatches, type ParsedQuery } from '@/lib/global-search/query-parser';
|
||||
import { mergeHits, rankHits } from '@/lib/global-search/rank';
|
||||
import {
|
||||
emptyOutcome,
|
||||
type SearchAccount,
|
||||
type SearchKind,
|
||||
type SearchOutcome,
|
||||
type SearchProvider,
|
||||
} from '@/lib/global-search/types';
|
||||
|
||||
export interface RunGlobalSearchOptions {
|
||||
parsed: ParsedQuery;
|
||||
accounts: SearchAccount[];
|
||||
providers: SearchProvider[];
|
||||
/** Cap on hits per kind taken from the local caches. */
|
||||
localLimit: number;
|
||||
/** Cap on hits per kind per login asked from the server. */
|
||||
remoteLimit: number;
|
||||
signal: AbortSignal;
|
||||
/** Called with a fresh snapshot each time hits or statuses change. Never called after `signal` aborts. */
|
||||
onUpdate: (outcome: SearchOutcome) => void;
|
||||
/** In-flight server requests at once. Every login pins HTTP/1.1 sockets already (#702), so keep this small. */
|
||||
concurrency?: number;
|
||||
/** Per-request budget; a slow login must not hold the palette hostage. */
|
||||
timeoutMs?: number;
|
||||
/** False skips the local pass (the search tab re-runs the server only). */
|
||||
includeLocal?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SEARCH_CONCURRENCY = 4;
|
||||
export const DEFAULT_SEARCH_TIMEOUT_MS = 8000;
|
||||
|
||||
/** Whether the parsed query has anything the server can be asked for this kind. */
|
||||
export function hasRemoteQuery(parsed: ParsedQuery, kind: SearchKind): boolean {
|
||||
if (parsed.text.trim()) return true;
|
||||
if (kind !== 'mail') return false;
|
||||
const { mail } = parsed;
|
||||
return Boolean(mail.from || mail.to || mail.subject || mail.body
|
||||
|| mail.hasAttachment !== null || mail.isUnread !== null || mail.isStarred !== null
|
||||
|| mail.dateAfter || mail.dateBefore || parsed.mailboxRole);
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
|
||||
function snapshot(outcome: SearchOutcome): SearchOutcome {
|
||||
return {
|
||||
hits: { ...outcome.hits },
|
||||
status: {
|
||||
mail: { ...outcome.status.mail, errors: [...outcome.status.mail.errors] },
|
||||
contacts: { ...outcome.status.contacts, errors: [...outcome.status.contacts.errors] },
|
||||
calendar: { ...outcome.status.calendar, errors: [...outcome.status.calendar.errors] },
|
||||
files: { ...outcome.status.files, errors: [...outcome.status.files.errors] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fans a parsed query out to every provider × login, publishing partial
|
||||
* results as they land. Local cache hits are published synchronously before
|
||||
* the first server round-trip; each server response is merged, de-duplicated
|
||||
* against what is already shown and re-ranked. Per-account failures become
|
||||
* error rows instead of failing the whole search.
|
||||
*/
|
||||
export async function runGlobalSearch(options: RunGlobalSearchOptions): Promise<SearchOutcome> {
|
||||
const {
|
||||
parsed, providers, signal, onUpdate, localLimit, remoteLimit,
|
||||
concurrency = DEFAULT_SEARCH_CONCURRENCY,
|
||||
timeoutMs = DEFAULT_SEARCH_TIMEOUT_MS,
|
||||
includeLocal = true,
|
||||
} = options;
|
||||
const accounts = options.accounts.filter((account) => accountMatches(parsed.account, account));
|
||||
const outcome = emptyOutcome();
|
||||
const publish = () => {
|
||||
if (!signal.aborted) onUpdate(snapshot(outcome));
|
||||
};
|
||||
|
||||
const active = providers.filter((p) => parsed.scope === 'all' || p.kind === parsed.scope);
|
||||
|
||||
type Task = { provider: SearchProvider; account: SearchAccount };
|
||||
const tasks: Task[] = [];
|
||||
const pendingPerKind = new Map<SearchKind, number>();
|
||||
|
||||
for (const provider of active) {
|
||||
const { kind } = provider;
|
||||
if (includeLocal && parsed.terms.length > 0) {
|
||||
try {
|
||||
outcome.hits[kind] = rankHits(provider.local(parsed, accounts, localLimit), parsed);
|
||||
} catch {
|
||||
// A cache that is mid-mutation must not kill the search; the server pass still runs.
|
||||
}
|
||||
}
|
||||
const targets = hasRemoteQuery(parsed, kind) ? accounts.filter((a) => provider.supports(a)) : [];
|
||||
pendingPerKind.set(kind, targets.length);
|
||||
outcome.status[kind].status = targets.length > 0 ? 'loading' : 'done';
|
||||
for (const account of targets) tasks.push({ provider, account });
|
||||
}
|
||||
publish();
|
||||
|
||||
const runTask = async ({ provider, account }: Task) => {
|
||||
const { kind } = provider;
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
// Client methods don't all take a signal, so a hung request would never
|
||||
// settle on its own - race it against the abort instead of trusting it.
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
controller.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), { once: true });
|
||||
});
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
provider.remote(parsed, account, { limit: remoteLimit, signal: controller.signal }),
|
||||
aborted,
|
||||
]);
|
||||
if (signal.aborted) return;
|
||||
outcome.hits[kind] = rankHits(mergeHits(outcome.hits[kind], result.hits), parsed);
|
||||
if (result.hasMore) outcome.status[kind].hasMore = true;
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
const timedOut = controller.signal.aborted;
|
||||
outcome.status[kind].errors.push({
|
||||
localAccountId: account.localAccountId,
|
||||
accountLabel: account.label,
|
||||
message: timedOut ? 'timeout' : error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
const left = (pendingPerKind.get(kind) ?? 1) - 1;
|
||||
pendingPerKind.set(kind, left);
|
||||
if (left <= 0) outcome.status[kind].status = 'done';
|
||||
publish();
|
||||
}
|
||||
};
|
||||
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, tasks.length)) }, async () => {
|
||||
while (next < tasks.length && !signal.aborted) {
|
||||
const task = tasks[next++];
|
||||
await runTask(task);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return snapshot(outcome);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useGlobalSearchStore } from '@/stores/global-search-store';
|
||||
|
||||
describe('global-search-store', () => {
|
||||
beforeEach(() => {
|
||||
useGlobalSearchStore.setState({ isOpen: false, query: '', scope: 'all', accountId: null });
|
||||
});
|
||||
|
||||
it('opens with an optional initial query and closes without losing it', () => {
|
||||
const s = useGlobalSearchStore.getState();
|
||||
s.openPalette('report');
|
||||
expect(useGlobalSearchStore.getState()).toMatchObject({ isOpen: true, query: 'report' });
|
||||
useGlobalSearchStore.getState().closePalette();
|
||||
expect(useGlobalSearchStore.getState()).toMatchObject({ isOpen: false, query: 'report' });
|
||||
// Re-opening without an initial query keeps the previous one.
|
||||
useGlobalSearchStore.getState().openPalette();
|
||||
expect(useGlobalSearchStore.getState().query).toBe('report');
|
||||
});
|
||||
|
||||
it('toggles', () => {
|
||||
useGlobalSearchStore.getState().togglePalette();
|
||||
expect(useGlobalSearchStore.getState().isOpen).toBe(true);
|
||||
useGlobalSearchStore.getState().togglePalette();
|
||||
expect(useGlobalSearchStore.getState().isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown scopes', () => {
|
||||
useGlobalSearchStore.getState().setScope('mail');
|
||||
expect(useGlobalSearchStore.getState().scope).toBe('mail');
|
||||
useGlobalSearchStore.getState().setScope('bogus' as never);
|
||||
expect(useGlobalSearchStore.getState().scope).toBe('all');
|
||||
});
|
||||
|
||||
it('persists only the scope', () => {
|
||||
useGlobalSearchStore.setState({ isOpen: true, query: 'secret', scope: 'files', accountId: 'a' });
|
||||
const raw = localStorage.getItem('global-search');
|
||||
expect(raw).toBeTruthy();
|
||||
const persisted = JSON.parse(raw as string);
|
||||
expect(persisted.state).toEqual({ scope: 'files' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { SearchScope } from '@/lib/global-search/query-parser';
|
||||
|
||||
/**
|
||||
* State of the global search palette (#641). The palette is a single modal
|
||||
* per shell; the search tab keeps its own results per tab through
|
||||
* `useGlobalSearch`, so only what the palette needs lives here. The last scope
|
||||
* is remembered across reloads; the account chip and the query are not.
|
||||
*/
|
||||
interface GlobalSearchState {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
scope: SearchScope;
|
||||
/** `AccountEntry.id` to restrict to, or null for every login. */
|
||||
accountId: string | null;
|
||||
openPalette: (initialQuery?: string) => void;
|
||||
closePalette: () => void;
|
||||
togglePalette: () => void;
|
||||
setQuery: (query: string) => void;
|
||||
setScope: (scope: SearchScope) => void;
|
||||
setAccountId: (accountId: string | null) => void;
|
||||
}
|
||||
|
||||
const SCOPES: readonly SearchScope[] = ['all', 'mail', 'contacts', 'calendar', 'files'];
|
||||
|
||||
export const useGlobalSearchStore = create<GlobalSearchState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
isOpen: false,
|
||||
query: '',
|
||||
scope: 'all',
|
||||
accountId: null,
|
||||
|
||||
openPalette: (initialQuery) => set({
|
||||
isOpen: true,
|
||||
...(initialQuery !== undefined ? { query: initialQuery } : {}),
|
||||
}),
|
||||
closePalette: () => set({ isOpen: false }),
|
||||
togglePalette: () => set({ isOpen: !get().isOpen }),
|
||||
setQuery: (query) => set({ query }),
|
||||
setScope: (scope) => set({ scope: SCOPES.includes(scope) ? scope : 'all' }),
|
||||
setAccountId: (accountId) => set({ accountId }),
|
||||
}),
|
||||
{
|
||||
name: 'global-search',
|
||||
version: 1,
|
||||
partialize: (state) => ({ scope: state.scope }),
|
||||
merge: (persisted, current) => {
|
||||
const saved = persisted as Partial<GlobalSearchState> | undefined;
|
||||
const scope = saved?.scope && SCOPES.includes(saved.scope) ? saved.scope : 'all';
|
||||
return { ...current, scope };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user