/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import ChannelStore from '@app/stores/ChannelStore'; import UserStore from '@app/stores/UserStore'; import type {MessageSearchParams} from '@app/utils/SearchUtils'; import {MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_SECOND} from '@fluxer/date_utils/src/DateConstants'; import {fromTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils'; import {DateTime} from 'luxon'; export interface ParserContext { usersByTag?: Record; channelsByName?: Record; } export interface SearchHints { usersByTag?: Record; channelsByName?: Record; } export interface ParsedToken { key: string; value: string; start: number; end: number; raw: string; quoted: boolean; exclude: boolean; } const KNOWN_KEYS = new Set([ 'from', '-from', 'mentions', '-mentions', 'in', '-in', 'before', 'during', 'on', 'after', 'has', '-has', 'pinned', 'author-type', 'sort', 'order', 'nsfw', 'embed-type', '-embed-type', 'embed-provider', '-embed-provider', 'link', '-link', 'filename', '-filename', 'ext', '-ext', 'last', 'beforeid', 'afterid', 'any', 'scope', ]); const USER_TAG_RE = /^([A-Za-z0-9_]+)#(\d{4})$/; const normalizeSpaces = (s: string) => s.replace(/\s+/g, ' ').trim(); type HasFilter = NonNullable[number]; const HAS_FILTERS: ReadonlySet = new Set([ 'image', 'sound', 'video', 'file', 'sticker', 'embed', 'link', 'poll', 'snapshot', ]); const isHasFilter = (value: string): value is HasFilter => HAS_FILTERS.has(value as HasFilter); type AuthorTypeFilter = NonNullable[number]; const AUTHOR_FILTERS: ReadonlySet = new Set(['user', 'bot', 'webhook']); const isAuthorFilter = (value: string): value is AuthorTypeFilter => AUTHOR_FILTERS.has(value as AuthorTypeFilter); type EmbedTypeFilter = NonNullable[number]; const EMBED_TYPE_FILTERS: ReadonlySet = new Set(['image', 'video', 'sound', 'article']); const isEmbedTypeFilter = (value: string): value is EmbedTypeFilter => EMBED_TYPE_FILTERS.has(value as EmbedTypeFilter); type SortField = NonNullable; const SORT_FIELDS: ReadonlySet = new Set(['timestamp', 'relevance']); const isSortField = (value: string): value is SortField => SORT_FIELDS.has(value as SortField); type SortDirection = NonNullable; const SORT_ORDERS: ReadonlySet = new Set(['asc', 'desc']); const isSortDirection = (value: string): value is SortDirection => SORT_ORDERS.has(value as SortDirection); type SearchScope = NonNullable; const SEARCH_SCOPES: ReadonlySet = new Set([ 'current', 'open_dms', 'all_dms', 'all_guilds', 'all', 'open_dms_and_all_guilds', ]); const isSearchScope = (value: string): value is SearchScope => SEARCH_SCOPES.has(value as SearchScope); export function tokenize(query: string): {tokens: Array; content: string; exactPhrases: Array} { const tokens: Array = []; const n = query['length']; let i = 0; while (i < n) { if (query[i] === ' ') { i++; continue; } const isExclude = query[i] === '-'; const keyStart = isExclude ? i + 1 : i; let j = keyStart; while (j < n && query[j] !== ':' && query[j] !== ' ') j++; if (j >= n || query[j] !== ':') { i++; continue; } const key = query['slice'](keyStart, j); if (!KNOWN_KEYS.has(key)) { i++; continue; } j++; if (j >= n) break; let end = j; let inQuotes = false; let escaped = false; while (end < n) { const ch = query[end]; if (escaped) { escaped = false; end++; continue; } if (ch === '\\') { escaped = true; end++; continue; } if (ch === '"') { inQuotes = !inQuotes; end++; continue; } if (!inQuotes && ch === ' ') break; end++; } const value = query['slice'](j, end); const raw = query['slice'](i, end); const quoted = value.startsWith('"'); tokens.push({key, value, start: i, end, raw, quoted, exclude: isExclude}); i = end; } let remaining = ''; let pos = 0; for (const tok of tokens) { if (tok.start > pos) remaining += query['slice'](pos, tok.start); pos = tok.end; } if (pos < n) remaining += query['slice'](pos); const exactPhrases: Array = []; let content = ''; const remainingTrimmed = remaining; let ri = 0; const rn = remainingTrimmed.length; while (ri < rn) { if (remainingTrimmed[ri] === '"') { ri++; let phrase = ''; let escaped = false; while (ri < rn) { const ch = remainingTrimmed[ri]; if (escaped) { phrase += ch; escaped = false; ri++; continue; } if (ch === '\\') { escaped = true; ri++; continue; } if (ch === '"') { ri++; break; } phrase += ch; ri++; } const trimmedPhrase = phrase.trim(); if (trimmedPhrase) { exactPhrases.push(trimmedPhrase); } } else { content += remainingTrimmed[ri]; ri++; } } return {tokens, content: normalizeSpaces(content), exactPhrases}; } const splitCSV = (input: string): Array => { const items: Array = []; let i = 0; const n = input['length']; while (i < n) { while (i < n && /[\s,]/.test(input[i])) i++; if (i >= n) break; let quoted = false; let buf = ''; if (input[i] === '"') { quoted = true; i++; } let escaped = false; for (; i < n; i++) { const ch = input[i]; if (escaped) { buf += ch; escaped = false; continue; } if (ch === '\\') { escaped = true; continue; } if (quoted) { if (ch === '"') { i++; break; } buf += ch; } else { if (ch === ',') break; buf += ch; } } items.push(buf.trim()); while (i < n && input[i] !== ',') i++; if (i < n && input[i] === ',') i++; } return items.filter(Boolean); }; const normalizeToken = (value: string): string => value.trim().toLowerCase(); const CURRENT_USER_TOKENS = new Set(['@me']); const isCurrentUserToken = (value: string): boolean => CURRENT_USER_TOKENS.has(normalizeToken(value)); const getCurrentUserId = (): string | null => UserStore.getCurrentUser()?.id ?? null; const tryResolveUser = (tag: string, hints?: SearchHints): string | null => { const trimmedTag = tag.trim(); if (!trimmedTag) { return null; } if (isCurrentUserToken(trimmedTag)) { return getCurrentUserId(); } if (hints?.usersByTag?.[trimmedTag]) return hints.usersByTag[trimmedTag]; if (!USER_TAG_RE.test(trimmedTag)) return null; const user = UserStore.getUserByTag(trimmedTag); return user?.id ?? null; }; const tryResolveChannel = (name: string, guildId?: string | null, hints?: SearchHints): string | null => { if (hints?.channelsByName?.[name]) return hints.channelsByName[name]; if (!guildId) return null; const channels = ChannelStore.getGuildChannels(guildId); const matches = channels.filter((c) => (c.name || '').toLowerCase() === name.toLowerCase()); if (matches.length > 0) return matches[0].id; const partial = channels.find((c) => (c.name || '').toLowerCase().includes(name.toLowerCase())); return partial?.id ?? null; }; export interface ParseContext { guildId?: string | null; } const toSnowflakeAt = (dt: DateTime) => fromTimestamp(dt.toMillis()).toString(); export function parseCompactDateTime(input: string, now: DateTime = DateTime.local()): DateTime | null { const trimmed = input['trim'](); if (trimmed.length === 0) return null; if (trimmed.toLowerCase() === 'now') return now; if (trimmed.toLowerCase() === 'today') return now.startOf('day'); if (trimmed.toLowerCase() === 'yesterday') return now.minus({days: 1}).startOf('day'); const maybeIso = trimmed.replace('_', 'T').replace(' ', 'T'); let dt = DateTime.fromISO(maybeIso, {setZone: true}); if (dt.isValid) return dt; const m = trimmed.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?Z?$/); if (m) { const [, y, mo, d, hh, mm, ss] = m; dt = DateTime.fromObject( { year: Number(y), month: Number(mo), day: Number(d), hour: hh ? Number(hh) : 0, minute: mm ? Number(mm) : 0, second: ss ? Number(ss) : 0, }, {zone: now.zone}, ); return dt.isValid ? dt : null; } const js = new Date(trimmed); if (!Number.isNaN(js.getTime())) return DateTime.fromJSDate(js); return null; } const parseDuration = (input: string): {millis: number} | null => { const m = input['trim']().match(/^(\d+)(ms|s|m|h|d|w)$/i); if (!m) return null; const n = Number(m[1]); const unit = m[2].toLowerCase(); const map: Record = { ms: 1, s: MS_PER_SECOND, m: MS_PER_MINUTE, h: MS_PER_HOUR, d: MS_PER_DAY, w: 7 * MS_PER_DAY, }; return {millis: n * map[unit]}; }; export function parseQuery(query: string, hints?: SearchHints, ctx?: ParseContext): MessageSearchParams { const {tokens, content, exactPhrases} = tokenize(query); const params: MessageSearchParams = {}; if (content) params.content = content; if (exactPhrases.length > 0) params.exactPhrases = exactPhrases; function add(arr: Array | undefined, v: T): Array { return arr ? [...arr, v] : [v]; } for (const tok of tokens) { const key = tok.key === 'on' ? 'during' : tok.key; const normalizedKey = key.startsWith('-') ? key.slice(1) : key; const isExcludeKey = key.startsWith('-') || tok.exclude; switch (normalizedKey) { case 'from': { const values = splitCSV(tok.value); for (const tag of values) { const id = tryResolveUser(tag, hints); if (id) { if (isExcludeKey) { params.excludeAuthorId = add(params.excludeAuthorId, id); } else { params.authorId = add(params.authorId, id); } } } break; } case 'mentions': { const values = splitCSV(tok.value); for (const v of values) { const lower = v.toLowerCase(); if (lower === 'everyone' || lower === 'here') { params.mentionEveryone = true; } else { const id = tryResolveUser(v, hints); if (id) { if (isExcludeKey) { params.excludeMentions = add(params.excludeMentions, id); } else { params.mentions = add(params.mentions, id); } } } } break; } case 'in': { const values = splitCSV(tok.value); for (const v of values) { const id = tryResolveChannel(v, ctx?.guildId ?? null, hints); if (id) { if (isExcludeKey) { params.excludeChannelId = add(params.excludeChannelId, id); } else { params.channelId = add(params.channelId, id); } } } break; } case 'has': { const raw = tok.value.trim(); if (!raw) break; const values = raw .split(',') .map((v) => v.trim()) .filter(Boolean); for (const value of values) { const normalized = value.toLowerCase(); if (!isHasFilter(normalized)) continue; if (isExcludeKey) { params.excludeHas = add(params.excludeHas, normalized); } else { params.has = add(params.has, normalized); } } break; } case 'pinned': { const v = tok.value.trim().toLowerCase(); if (['true', 'false', 'yes', 'no', '1', '0'].includes(v)) { params.pinned = v === 'true' || v === 'yes' || v === '1'; } break; } case 'author-type': { const raw = tok.value.trim(); if (!raw) break; const values = raw .split(',') .map((v) => v.trim()) .filter(Boolean); for (const value of values) { const normalized = value.toLowerCase(); if (isAuthorFilter(normalized)) { params.authorType = add(params.authorType, normalized); } } break; } case 'sort': { const v = tok.value.trim().toLowerCase(); if (isSortField(v)) params.sortBy = v; break; } case 'order': { const v = tok.value.trim().toLowerCase(); if (isSortDirection(v)) params.sortOrder = v; break; } case 'nsfw': { const v = tok.value.trim().toLowerCase(); if (v === 'true' || v === 'false') params.includeNsfw = v === 'true'; break; } case 'embed-type': { const values = splitCSV(tok.value).map((v) => v.toLowerCase()); for (const v of values) { if (isExcludeKey) { if (isEmbedTypeFilter(v)) { params.excludeEmbedType = add(params.excludeEmbedType, v); } } else if (isEmbedTypeFilter(v)) { params.embedType = add(params.embedType, v); } } break; } case 'embed-provider': { const values = splitCSV(tok.value); for (const v of values) { if (isExcludeKey) { params.excludeEmbedProvider = add(params.excludeEmbedProvider, v); } else { params.embedProvider = add(params.embedProvider, v); } } break; } case 'link': { const values = splitCSV(tok.value); for (const v of values) { if (isExcludeKey) { params.excludeLinkHostname = add(params.excludeLinkHostname, v); } else { params.linkHostname = add(params.linkHostname, v); } } break; } case 'filename': { const values = splitCSV(tok.value); for (const v of values) { if (isExcludeKey) { params.excludeAttachmentFilename = add(params.excludeAttachmentFilename, v); } else { params.attachmentFilename = add(params.attachmentFilename, v); } } break; } case 'ext': { const values = splitCSV(tok.value).map((v) => v.replace(/^\./, '')); for (const v of values) { if (isExcludeKey) { params.excludeAttachmentExtension = add(params.excludeAttachmentExtension, v); } else { params.attachmentExtension = add(params.attachmentExtension, v); } } break; } case 'scope': { const values = splitCSV(tok.value); for (const value of values) { const normalized = value.toLowerCase(); if (isSearchScope(normalized)) { params.scope = normalized; break; } } break; } case 'last': { const dur = parseDuration(tok.value); if (dur) { const dt = DateTime.local().minus({milliseconds: dur.millis}); params.minId = toSnowflakeAt(dt); } break; } case 'beforeid': { const id = tok.value.trim(); if (id) params.maxId = id; break; } case 'afterid': { const id = tok.value.trim(); if (id) params.minId = id; break; } case 'any': { const values = splitCSV(tok.value); for (const v of values) params.contents = add(params.contents, v); break; } case 'before': case 'after': case 'during': { if (tok.value.includes('..')) { const [a, b] = tok.value.split('..'); const start = parseCompactDateTime(a); const end = parseCompactDateTime(b); if (start) params.minId = toSnowflakeAt(start.startOf('day')); if (end) params.maxId = toSnowflakeAt(end.endOf('day')); break; } const dt = parseCompactDateTime(tok.value); if (!dt) break; if (key === 'before') { params.maxId = toSnowflakeAt(dt); } else if (key === 'after') { params.minId = toSnowflakeAt(dt); } else { const start = dt.startOf('day'); const end = dt.endOf('day'); params.minId = toSnowflakeAt(start); params.maxId = toSnowflakeAt(end); } break; } default: break; } } return params; }