diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index ca8717199..ae2989594 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -2003,6 +2003,14 @@ function handleCalendarEventGet(args: MethodArgs, callId: string): MethodResult function handleCalendarEventQuery(args: MethodArgs, callId: string): MethodResult { const filter = args.filter as Record | 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 | 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)[cid])); @@ -2369,7 +2377,20 @@ const METHOD_HANDLERS: Record = { 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 | 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; organizations?: Record }; + 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, diff --git a/hooks/use-global-search.ts b/hooks/use-global-search.ts new file mode 100644 index 000000000..a2be73a76 --- /dev/null +++ b/hooks/use-global-search.ts @@ -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(emptyOutcome); + const [mailLimit, setMailLimit] = useState(remoteLimit); + const [runToken, setRunToken] = useState(0); + const searchIdRef = useRef(0); + const controllerRef = useRef(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 }; +} diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 55c3c679c..2e23cf326 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -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; }); } diff --git a/lib/global-search/__tests__/providers.test.ts b/lib/global-search/__tests__/providers.test.ts new file mode 100644 index 000000000..cf55cae0d --- /dev/null +++ b/lib/global-search/__tests__/providers.test.ts @@ -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 = { 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()), + 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 = {}) { + 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 = {}): 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 = {}) => ({ id, name: id, role, ...extra }); + const entry = (mailboxes: unknown[], overrides: Record = {}) => ({ + 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); + }); +}); diff --git a/lib/global-search/__tests__/query-parser.test.ts b/lib/global-search/__tests__/query-parser.test.ts new file mode 100644 index 000000000..de9bd3bad --- /dev/null +++ b/lib/global-search/__tests__/query-parser.test.ts @@ -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); + }); +}); diff --git a/lib/global-search/__tests__/rank.test.ts b/lib/global-search/__tests__/rank.test.ts new file mode 100644 index 000000000..f87821eba --- /dev/null +++ b/lib/global-search/__tests__/rank.test.ts @@ -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 { + 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 { + 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: 'x', 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); + }); +}); diff --git a/lib/global-search/__tests__/run-global-search.test.ts b/lib/global-search/__tests__/run-global-search.test.ts new file mode 100644 index 000000000..1693d2c84 --- /dev/null +++ b/lib/global-search/__tests__/run-global-search.test.ts @@ -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 { + return { + kind, + supports: () => true, + local: () => [], + remote: async () => ({ hits: [], hasMore: false }), + ...impl, + }; +} + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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(); + } + }); +}); diff --git a/lib/global-search/accounts.ts b/lib/global-search/accounts.ts new file mode 100644 index 000000000..97c75f916 --- /dev/null +++ b/lib/global-search/accounts.ts @@ -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 { + return new Map(accounts.map((account) => [account.localAccountId, account])); +} diff --git a/lib/global-search/providers/calendar.ts b/lib/global-search/providers/calendar.ts new file mode 100644 index 000000000..a018c5b43 --- /dev/null +++ b/lib/global-search/providers/calendar.ts @@ -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(); + 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 }; + }, +}; diff --git a/lib/global-search/providers/contacts.ts b/lib/global-search/providers/contacts.ts new file mode 100644 index 000000000..9c91fbd26 --- /dev/null +++ b/lib/global-search/providers/contacts.ts @@ -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 }; + }, +}; diff --git a/lib/global-search/providers/files.ts b/lib/global-search/providers/files.ts new file mode 100644 index 000000000..7f8184860 --- /dev/null +++ b/lib/global-search/providers/files.ts @@ -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; + nodes: FileNode[] | null; +} + +const listings = new Map(); + +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): 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(); + 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 }; + }, +}; diff --git a/lib/global-search/providers/index.ts b/lib/global-search/providers/index.ts new file mode 100644 index 000000000..a2b37f83d --- /dev/null +++ b/lib/global-search/providers/index.ts @@ -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, +]; diff --git a/lib/global-search/providers/mail.ts b/lib/global-search/providers/mail.ts new file mode 100644 index 000000000..fc34a8429 --- /dev/null +++ b/lib/global-search/providers/mail.ts @@ -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; + +/** `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 }; + }, +}; diff --git a/lib/global-search/query-parser.ts b/lib/global-search/query-parser.ts new file mode 100644 index 000000000..dfcbd70f1 --- /dev/null +++ b/lib/global-search/query-parser.ts @@ -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: 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 = { + 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): 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))); +} diff --git a/lib/global-search/rank.ts b/lib/global-search/rank.ts new file mode 100644 index 000000000..7d936ac7b --- /dev/null +++ b/lib/global-search/rank.ts @@ -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(); + 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()]; +} diff --git a/lib/global-search/run-global-search.ts b/lib/global-search/run-global-search.ts new file mode 100644 index 000000000..3ed10e9f6 --- /dev/null +++ b/lib/global-search/run-global-search.ts @@ -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 { + 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(); + + 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((_, 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); +} diff --git a/lib/global-search/types.ts b/lib/global-search/types.ts new file mode 100644 index 000000000..fd6772ec4 Binary files /dev/null and b/lib/global-search/types.ts differ diff --git a/stores/__tests__/global-search-store.test.ts b/stores/__tests__/global-search-store.test.ts new file mode 100644 index 000000000..2438b28a1 --- /dev/null +++ b/stores/__tests__/global-search-store.test.ts @@ -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' }); + }); +}); diff --git a/stores/global-search-store.ts b/stores/global-search-store.ts new file mode 100644 index 000000000..4de10d94e --- /dev/null +++ b/stores/global-search-store.ts @@ -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()( + 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 | undefined; + const scope = saved?.scope && SCOPES.includes(saved.scope) ? saved.scope : 'all'; + return { ...current, scope }; + }, + }, + ), +);