From a98a9fe6a99330f31e10995aedf0b4b7f5bb7897 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:31:29 +0200 Subject: [PATCH] feat(app): understand date words and DM channels in search filters (#1852) --- .../search/utils/SearchQueryParser.ts | 360 ++++++++++++++++-- 1 file changed, 323 insertions(+), 37 deletions(-) diff --git a/fluxer_app/src/features/search/utils/SearchQueryParser.ts b/fluxer_app/src/features/search/utils/SearchQueryParser.ts index 69b56ae78..5fc83b6b9 100644 --- a/fluxer_app/src/features/search/utils/SearchQueryParser.ts +++ b/fluxer_app/src/features/search/utils/SearchQueryParser.ts @@ -27,6 +27,23 @@ export interface ParsedToken { exclude: boolean; } +export const SearchChipRole = Object.freeze({ + KEY: 'key', + VALUE: 'value', + UNAPPLIED_VALUE: 'unapplied-value', +} as const); + +export type SearchChipRole = (typeof SearchChipRole)[keyof typeof SearchChipRole]; + +export interface SearchChip { + role: SearchChipRole; + key: string; + exclude: boolean; + start: number; + end: number; + mutable: boolean; +} + const QUOTE_CHARS = new Set(['"', '\u201c', '\u201d', '\u201f', '\u2033', '\u00ab', '\u00bb']); const isQuoteChar = (ch: string | undefined): boolean => ch !== undefined && QUOTE_CHARS.has(ch); const KNOWN_KEYS = new Set([ @@ -123,12 +140,38 @@ const SEARCH_SCOPES: ReadonlySet = new Set([ ]); const isSearchScope = (value: string): value is SearchScope => SEARCH_SCOPES.has(value as SearchScope); +interface FilterKeyMatch { + key: string; + exclude: boolean; + valueStart: number; +} + +function matchFilterKeyAt(query: string, index: number): FilterKeyMatch | null { + const n = query.length; + const exclude = query[index] === '-'; + const keyStart = exclude ? index + 1 : index; + let cursor = keyStart; + while (cursor < n && query[cursor] !== ':' && query[cursor] !== ' ') cursor++; + if (cursor >= n || query[cursor] !== ':' || cursor === keyStart) return null; + const key = query.slice(keyStart, cursor).toLowerCase(); + if (!KNOWN_KEYS.has(key)) return null; + return {key, exclude, valueStart: cursor + 1}; +} + +interface QuerySpan { + start: number; + end: number; +} + export function tokenize(query: string): { tokens: Array; content: string; exactPhrases: Array; + chips: Array; } { const tokens: Array = []; + const chips: Array = []; + const consumed: Array = []; const n = query['length']; let i = 0; while (i < n) { @@ -136,21 +179,21 @@ export function tokenize(query: string): { 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] !== ':') { + const match = matchFilterKeyAt(query, i); + if (match === null) { i++; continue; } - const key = query['slice'](keyStart, j); - if (!KNOWN_KEYS.has(key)) { - i++; + const {key, exclude: isExclude, valueStart} = match; + let j = valueStart; + while (j < n && query[j] === ' ') j++; + const absorbedSpaces = j > valueStart; + if (j >= n || (absorbedSpaces && matchFilterKeyAt(query, j) !== null)) { + chips.push({role: SearchChipRole.KEY, key, exclude: isExclude, start: i, end: valueStart, mutable: false}); + consumed.push({start: i, end: valueStart}); + i = valueStart; continue; } - j++; - if (j >= n) break; let end = j; let inQuotes = false; let escaped = false; @@ -178,13 +221,34 @@ export function tokenize(query: string): { const raw = query['slice'](i, end); const quoted = isQuoteChar(value[0]); tokens.push({key, value, start: i, end, raw, quoted, exclude: isExclude}); + chips.push({role: SearchChipRole.KEY, key, exclude: isExclude, start: i, end: valueStart, mutable: false}); + if (isSearchFilterValueChippable(key, value)) { + chips.push({ + role: SearchChipRole.VALUE, + key, + exclude: isExclude, + start: valueStart, + end, + mutable: isSearchFilterValueMutable(key), + }); + } else if (value.trim().length > 0) { + chips.push({ + role: SearchChipRole.UNAPPLIED_VALUE, + key, + exclude: isExclude, + start: valueStart, + end, + mutable: true, + }); + } + consumed.push({start: i, end}); 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; + for (const span of consumed) { + if (span.start > pos) remaining += query['slice'](pos, span.start); + pos = span.end; } if (pos < n) remaining += query['slice'](pos); const exactPhrases: Array = []; @@ -226,7 +290,7 @@ export function tokenize(query: string): { ri++; } } - return {tokens, content: normalizeSpaces(content), exactPhrases}; + return {tokens, content: normalizeSpaces(content), exactPhrases, chips}; } const splitCSV = (input: string): Array => { @@ -291,13 +355,33 @@ const tryResolveUser = (tag: string, hints?: SearchHints): string | null => { const user = Users.getUserByTag(trimmedTag); return user?.id ?? null; }; +export function resolveSearchChannelDisplayName(channel: { + name?: string | null; + isDM: () => boolean; + getRecipientId: () => string | undefined; +}): string { + if (channel.isDM()) { + const recipientId = channel.getRecipientId(); + if (recipientId == null) return ''; + return Users.getUser(recipientId)?.tag ?? ''; + } + return channel.name?.trim() ?? ''; +} + const tryResolveChannel = (name: string, guildId?: string | null, hints?: SearchHints): string | null => { if (hints?.channelsByName?.[name]) return hints.channelsByName[name]; - if (!guildId) return null; + const target = name.toLowerCase(); + if (!guildId) { + const privateChannels = Channels.getPrivateChannels(); + const exact = privateChannels.find((c) => resolveSearchChannelDisplayName(c).toLowerCase() === target); + if (exact) return exact.id; + const partial = privateChannels.find((c) => resolveSearchChannelDisplayName(c).toLowerCase().includes(target)); + return partial?.id ?? null; + } const channels = Channels.getGuildChannels(guildId); - const matches = channels.filter((c) => (c.name || '').toLowerCase() === name.toLowerCase()); + const matches = channels.filter((c) => (c.name || '').toLowerCase() === target); if (matches.length > 0) return matches[0].id; - const partial = channels.find((c) => (c.name || '').toLowerCase().includes(name.toLowerCase())); + const partial = channels.find((c) => (c.name || '').toLowerCase().includes(target)); return partial?.id ?? null; }; @@ -337,6 +421,137 @@ export function parseCompactDateTime(input: string, now: DateTime = DateTime.loc return null; } +export interface SearchDatePeriod { + readonly start: DateTime; + readonly end: DateTime; +} + +const SEARCH_DATE_YEAR_FLOOR = 2015; +const EXPLICIT_CLOCK_TIME_RE = /[T_ ]\d{1,2}:\d{2}|[T_ ]\d{1,2}$|[T_ ]\d{4,6}$|\d{8}T\d{4}/; +const RELATIVE_PERIOD_UNITS: Record = { + week: 'week', + month: 'month', + year: 'year', +}; +const WEEKDAY_NAMES: ReadonlyArray = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', +]; +const MONTH_NAMES: ReadonlyArray = [ + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december', +]; + +const RELATIVE_DATE_WORDS: ReadonlyArray = ['today', 'yesterday', ...Object.keys(RELATIVE_PERIOD_UNITS)]; + +export function getSearchDateWords(now: DateTime = DateTime.local()): Array { + const years: Array = []; + for (let year = now.year; year >= SEARCH_DATE_YEAR_FLOOR; year -= 1) { + years.push(String(year)); + } + return [...RELATIVE_DATE_WORDS, ...years, ...MONTH_NAMES, ...WEEKDAY_NAMES]; +} + +function isSubsequence(needle: string, haystack: string): boolean { + const haystackLength = haystack.length; + const needleLength = needle.length; + if (needleLength > haystackLength) return false; + if (needleLength === haystackLength) return needle === haystack; + let haystackIndex = 0; + outer: for (let needleIndex = 0; needleIndex < needleLength; needleIndex += 1) { + const code = needle.charCodeAt(needleIndex); + while (haystackIndex < haystackLength) { + if (haystack.charCodeAt(haystackIndex++) === code) continue outer; + } + return false; + } + return true; +} + +export function matchSearchDateWords(term: string, limit: number, now: DateTime = DateTime.local()): Array { + const needle = term.toLocaleLowerCase(); + const exact: Array = []; + const prefixed: Array = []; + const scattered: Array = []; + for (const word of getSearchDateWords(now)) { + const candidate = word.toLocaleLowerCase(); + if (candidate === needle) { + exact.push(word); + } else if (candidate.startsWith(needle)) { + prefixed.push(word); + } else if (isSubsequence(needle, candidate)) { + scattered.push(word); + } + } + return [...exact, ...prefixed, ...scattered].slice(0, limit); +} + +function localeWeekStart(now: DateTime): DateTime { + return now.startOf('week', {useLocaleWeeks: true}); +} + +function dayPeriod(start: DateTime): SearchDatePeriod { + const floor = start.startOf('day'); + return {start: floor, end: floor.plus({days: 1})}; +} + +function resolveSearchDateWord(word: string, now: DateTime): SearchDatePeriod | null { + if (word === 'today') return dayPeriod(now); + if (word === 'yesterday') return dayPeriod(now.minus({days: 1})); + const relativeUnit = RELATIVE_PERIOD_UNITS[word]; + if (relativeUnit != null) { + const floor = relativeUnit === 'week' ? localeWeekStart(now) : now.startOf(relativeUnit); + return {start: floor, end: floor.plus({[relativeUnit]: 1})}; + } + const monthIndex = MONTH_NAMES.indexOf(word); + if (monthIndex >= 0) { + const floor = DateTime.fromObject({year: now.year, month: monthIndex + 1, day: 1}, {zone: now.zone}); + return {start: floor, end: floor.plus({months: 1})}; + } + const weekdayIndex = WEEKDAY_NAMES.indexOf(word); + if (weekdayIndex >= 0) { + const weekStart = localeWeekStart(now); + for (let offset = 0; offset < 7; offset += 1) { + const candidate = weekStart.plus({days: offset}); + if (candidate.weekday === weekdayIndex + 1) return dayPeriod(candidate); + } + return null; + } + if (!/^\d{4}$/.test(word)) return null; + const year = Number(word); + if (year < SEARCH_DATE_YEAR_FLOOR || year > now.year) return null; + const floor = DateTime.fromObject({year, month: 1, day: 1}, {zone: now.zone}); + return {start: floor, end: floor.plus({years: 1})}; +} + +export function resolveSearchDatePeriod(input: string, now: DateTime = DateTime.local()): SearchDatePeriod | null { + const word = input.trim().toLowerCase(); + if (word.length === 0) return null; + const wordPeriod = resolveSearchDateWord(word, now); + if (wordPeriod != null) return wordPeriod; + const instant = parseCompactDateTime(input, now); + if (instant == null) return null; + if (word === 'now' || EXPLICIT_CLOCK_TIME_RE.test(input.trim())) { + return {start: instant, end: instant}; + } + return dayPeriod(instant); +} + const parseDuration = ( input: string, ): { @@ -357,14 +572,85 @@ const parseDuration = ( return {millis: n * map[unit]}; }; +const normalizeSearchFilterKey = (key: string): string => { + const stripped = key.startsWith('-') ? key.slice(1) : key; + if (stripped === 'on') return 'during'; + return stripped; +}; + +const NON_MUTABLE_VALUE_KEYS: ReadonlySet = new Set(['has', 'pinned', 'author-type']); + +export function isSearchFilterValueMutable(key: string): boolean { + return !NON_MUTABLE_VALUE_KEYS.has(normalizeSearchFilterKey(key)); +} + +const BOOLEAN_VALUES: ReadonlySet = new Set(['true', 'false', 'yes', 'no', '1', '0']); + +const isEveryCSVItem = (value: string, predicate: (item: string) => boolean): boolean => { + const items = splitCSV(value); + return items.length > 0 && items.every((item) => predicate(item.toLowerCase())); +}; + +function isSearchDateValue(value: string): boolean { + if (!value.includes('..')) return resolveSearchDatePeriod(value) !== null; + const [lower, upper] = value.split('..'); + return resolveSearchDatePeriod(lower) !== null && resolveSearchDatePeriod(upper) !== null; +} + +const SEARCH_FILTER_VALUE_VALIDATORS: Record boolean> = { + has: (value) => isEveryCSVItem(value, (item) => normalizeHasValue(item) !== null), + 'author-type': (value) => isEveryCSVItem(value, isAuthorFilter), + 'embed-type': (value) => isEveryCSVItem(value, isEmbedTypeFilter), + pinned: (value) => BOOLEAN_VALUES.has(normalizeToken(value)), + mature: (value) => normalizeToken(value) === 'true' || normalizeToken(value) === 'false', + sort: (value) => isSortField(normalizeToken(value)), + order: (value) => isSortDirection(normalizeToken(value)), + scope: (value) => splitCSV(value).some((item) => isSearchScope(normalizeToken(item))), + last: (value) => parseDuration(value) !== null, + before: isSearchDateValue, + after: isSearchDateValue, + during: isSearchDateValue, +}; + +export function isSearchFilterValueChippable(key: string, value: string): boolean { + const trimmed = value.trim(); + if (trimmed.length === 0) return false; + const validator = SEARCH_FILTER_VALUE_VALIDATORS[normalizeSearchFilterKey(key)]; + if (validator == null) return true; + return validator(trimmed); +} + +export function resolveSearchChipDeletion(query: string, caret: number, isForward: boolean): SearchChip | null { + for (const chip of tokenize(query).chips) { + if (chip.mutable) continue; + let isWithin: boolean; + if (isForward) { + isWithin = caret >= chip.start && caret < chip.end; + } else { + isWithin = caret > chip.start && caret <= chip.end; + } + if (isWithin) return chip; + } + return null; +} + +function resolveDuringUpperBound(period: SearchDatePeriod): DateTime { + if (period.end > period.start) return period.end; + return period.start.startOf('day').plus({days: 1}); +} + +export function addUniqueSearchParam(values: Array | undefined, value: T): Array { + if (values == null) return [value]; + if (values.includes(value)) return values; + return [...values, value]; +} + 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]; - } + const add = addUniqueSearchParam; for (const tok of tokens) { const key = tok.key === 'on' ? 'during' : tok.key; const normalizedKey = key.startsWith('-') ? key.slice(1) : key; @@ -530,9 +816,8 @@ export function parseQuery(query: string, hints?: SearchHints, ctx?: ParseContex break; } case 'scope': { - const values = splitCSV(tok.value); - for (const value of values) { - const normalized = value.toLowerCase(); + for (const item of splitCSV(tok.value)) { + const normalized = normalizeToken(item); if (isSearchScope(normalized)) { params.scope = normalized; break; @@ -568,24 +853,25 @@ export function parseQuery(query: string, hints?: SearchHints, ctx?: ParseContex 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')); + const lower = resolveSearchDatePeriod(a); + const upper = resolveSearchDatePeriod(b); + if (!lower || !upper) break; + params.minId = toSnowflakeAt(lower.start); + params.maxId = toSnowflakeAt(upper.end); break; } - const dt = parseCompactDateTime(tok.value); - if (!dt) break; + const period = resolveSearchDatePeriod(tok.value); + if (!period) 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); + params.maxId = toSnowflakeAt(period.start); + break; } + if (key === 'after') { + params.minId = toSnowflakeAt(period.end); + break; + } + params.minId = toSnowflakeAt(period.start); + params.maxId = toSnowflakeAt(resolveDuringUpperBound(period)); break; } default: