import type { MsgTypeFilter } from './useMessageSearch'; /** * [Gitea #106] Discord-style operators typed into the search box. Each one * maps onto a filter the search already has (sender chips, room picker, date * range, "contains link", media type, pinned), so typing and clicking end up * in the same state. Pure and unit-tested. * * from:@user sender (existing) * in:general in:"a b" room, by name or alias (resolved by the caller) * after:2026-09-01 from the start of that day (local time) * before:2026-09-01 up to the start of that day * after:7d before:2w relative: h, d, w, m (30 d), y (365 d) ago * has:link | image | video | file * is:pinned */ export type ParsedSearch = { term: string; senders: string[]; rooms: string[]; fromTs?: number; toTs?: number; containsUrl?: boolean; msgTypes: MsgTypeFilter[]; pinnedOnly?: boolean; /** Operators we didn't understand, left in the term as typed. */ unknown: string[]; }; const UNIT_MS: Record = { h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000, m: 30 * 86_400_000, y: 365 * 86_400_000, }; /** A date operand → timestamp, or undefined if it isn't one. Exported for tests. */ export function parseDateOperand(value: string, now: number): number | undefined { const rel = /^(\d{1,4})([hdwmy])$/i.exec(value); if (rel) return now - Number(rel[1]) * UNIT_MS[rel[2].toLowerCase()]; const abs = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value); if (abs) { const [y, m, d] = [Number(abs[1]), Number(abs[2]), Number(abs[3])]; const date = new Date(y, m - 1, d); if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) { return undefined; } return date.getTime(); } return undefined; } const HAS_TYPES: Record = { image: 'm.image', images: 'm.image', video: 'm.video', videos: 'm.video', file: 'm.file', files: 'm.file', }; // key:"quoted value" or key:value const OPERATOR = /(^|\s)(from|in|after|before|has|is):(?:"([^"]*)"|(\S*))/gi; export function parseSearchQuery(raw: string, now = Date.now()): ParsedSearch { const out: ParsedSearch = { term: '', senders: [], rooms: [], msgTypes: [], unknown: [] }; const kept: string[] = []; let last = 0; raw.replace( OPERATOR, (match, lead: string, keyRaw: string, quoted?: string, bare?: string, offset?: number) => { const key = keyRaw.toLowerCase(); const value = (quoted ?? bare ?? '').trim(); const start = (offset ?? 0) + lead.length; kept.push(raw.slice(last, start)); last = start + match.length - lead.length; let understood = value !== ''; if (understood) { if (key === 'from') { out.senders.push(value.startsWith('@') ? value : `@${value}`); } else if (key === 'in') { out.rooms.push(value); } else if (key === 'after' || key === 'before') { const ts = parseDateOperand(value, now); if (ts === undefined) understood = false; else if (key === 'after') out.fromTs = ts; else out.toTs = ts; } else if (key === 'has') { const v = value.toLowerCase(); if (v === 'link' || v === 'links' || v === 'url') out.containsUrl = true; else if (HAS_TYPES[v]) { if (!out.msgTypes.includes(HAS_TYPES[v])) out.msgTypes.push(HAS_TYPES[v]); } else understood = false; } else if (key === 'is') { if (value.toLowerCase() === 'pinned') out.pinnedOnly = true; else understood = false; } } if (!understood) { out.unknown.push(match.trim()); kept.push(match.trim()); } return match; }, ); kept.push(raw.slice(last)); out.term = kept.join(' ').replace(/\s+/g, ' ').trim(); return out; } /** True when the query contains any operator at all (for the submit path). */ export const hasOperators = (p: ParsedSearch): boolean => p.senders.length > 0 || p.rooms.length > 0 || p.fromTs !== undefined || p.toTs !== undefined || !!p.containsUrl || p.msgTypes.length > 0 || !!p.pinnedOnly; /** Resolve an `in:` value to a room id among `rooms`. Exported for tests. */ export function resolveRoomOperand( value: string, rooms: { roomId: string; name: string; aliases: string[] }[], ): string | undefined { const v = value.toLowerCase().replace(/^#/, ''); const exact = rooms.find( (r) => r.roomId.toLowerCase() === value.toLowerCase() || r.aliases.some((a) => { const al = a.toLowerCase().replace(/^#/, ''); return al === v || al.split(':')[0] === v; }) || r.name.toLowerCase() === v, ); if (exact) return exact.roomId; const partial = rooms.filter((r) => r.name.toLowerCase().includes(v)); return partial.length === 1 ? partial[0].roomId : undefined; }