feat: global search core: query parser, ranking, cross-account providers #641

This commit is contained in:
Linus Rath
2026-09-01 20:55:12 +02:00
parent a210b22e49
commit 0db6bfdc64
19 changed files with 1973 additions and 1 deletions
+22 -1
View File
@@ -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,
+173
View File
@@ -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 };
}
+5
View File
@@ -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);
});
});
+105
View File
@@ -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();
}
});
});
+39
View File
@@ -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]));
}
+108
View File
@@ -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 };
},
};
+100
View File
@@ -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 };
},
};
+143
View File
@@ -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 };
},
};
+12
View File
@@ -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,
];
+155
View File
@@ -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 };
},
};
+238
View File
@@ -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)));
}
+85
View File
@@ -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()];
}
+147
View File
@@ -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' });
});
});
+56
View File
@@ -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 };
},
},
),
);