feat(app): understand date words and DM channels in search filters (#1852)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:31:29 +02:00
1 parent ac6fcc8c40
commit a98a9fe6a9
1 file changed
+323 -37
@@ -27,6 +27,23 @@ export interface ParsedToken {
exclude: boolean; 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 QUOTE_CHARS = new Set(['"', '\u201c', '\u201d', '\u201f', '\u2033', '\u00ab', '\u00bb']);
const isQuoteChar = (ch: string | undefined): boolean => ch !== undefined && QUOTE_CHARS.has(ch); const isQuoteChar = (ch: string | undefined): boolean => ch !== undefined && QUOTE_CHARS.has(ch);
const KNOWN_KEYS = new Set([ const KNOWN_KEYS = new Set([
@@ -123,12 +140,38 @@ const SEARCH_SCOPES: ReadonlySet<SearchScope> = new Set([
]); ]);
const isSearchScope = (value: string): value is SearchScope => SEARCH_SCOPES.has(value as SearchScope); 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): { export function tokenize(query: string): {
tokens: Array<ParsedToken>; tokens: Array<ParsedToken>;
content: string; content: string;
exactPhrases: Array<string>; exactPhrases: Array<string>;
chips: Array<SearchChip>;
} { } {
const tokens: Array<ParsedToken> = []; const tokens: Array<ParsedToken> = [];
const chips: Array<SearchChip> = [];
const consumed: Array<QuerySpan> = [];
const n = query['length']; const n = query['length'];
let i = 0; let i = 0;
while (i < n) { while (i < n) {
@@ -136,21 +179,21 @@ export function tokenize(query: string): {
i++; i++;
continue; continue;
} }
const isExclude = query[i] === '-'; const match = matchFilterKeyAt(query, i);
const keyStart = isExclude ? i + 1 : i; if (match === null) {
let j = keyStart;
while (j < n && query[j] !== ':' && query[j] !== ' ') j++;
if (j >= n || query[j] !== ':') {
i++; i++;
continue; continue;
} }
const key = query['slice'](keyStart, j); const {key, exclude: isExclude, valueStart} = match;
if (!KNOWN_KEYS.has(key)) { let j = valueStart;
i++; 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; continue;
} }
j++;
if (j >= n) break;
let end = j; let end = j;
let inQuotes = false; let inQuotes = false;
let escaped = false; let escaped = false;
@@ -178,13 +221,34 @@ export function tokenize(query: string): {
const raw = query['slice'](i, end); const raw = query['slice'](i, end);
const quoted = isQuoteChar(value[0]); const quoted = isQuoteChar(value[0]);
tokens.push({key, value, start: i, end, raw, quoted, exclude: isExclude}); 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; i = end;
} }
let remaining = ''; let remaining = '';
let pos = 0; let pos = 0;
for (const tok of tokens) { for (const span of consumed) {
if (tok.start > pos) remaining += query['slice'](pos, tok.start); if (span.start > pos) remaining += query['slice'](pos, span.start);
pos = tok.end; pos = span.end;
} }
if (pos < n) remaining += query['slice'](pos); if (pos < n) remaining += query['slice'](pos);
const exactPhrases: Array<string> = []; const exactPhrases: Array<string> = [];
@@ -226,7 +290,7 @@ export function tokenize(query: string): {
ri++; ri++;
} }
} }
return {tokens, content: normalizeSpaces(content), exactPhrases}; return {tokens, content: normalizeSpaces(content), exactPhrases, chips};
} }
const splitCSV = (input: string): Array<string> => { const splitCSV = (input: string): Array<string> => {
@@ -291,13 +355,33 @@ const tryResolveUser = (tag: string, hints?: SearchHints): string | null => {
const user = Users.getUserByTag(trimmedTag); const user = Users.getUserByTag(trimmedTag);
return user?.id ?? null; 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 => { const tryResolveChannel = (name: string, guildId?: string | null, hints?: SearchHints): string | null => {
if (hints?.channelsByName?.[name]) return hints.channelsByName[name]; 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 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; 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; return partial?.id ?? null;
}; };
@@ -337,6 +421,137 @@ export function parseCompactDateTime(input: string, now: DateTime = DateTime.loc
return null; 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<string, 'week' | 'month' | 'year'> = {
week: 'week',
month: 'month',
year: 'year',
};
const WEEKDAY_NAMES: ReadonlyArray<string> = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
];
const MONTH_NAMES: ReadonlyArray<string> = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
];
const RELATIVE_DATE_WORDS: ReadonlyArray<string> = ['today', 'yesterday', ...Object.keys(RELATIVE_PERIOD_UNITS)];
export function getSearchDateWords(now: DateTime = DateTime.local()): Array<string> {
const years: Array<string> = [];
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<string> {
const needle = term.toLocaleLowerCase();
const exact: Array<string> = [];
const prefixed: Array<string> = [];
const scattered: Array<string> = [];
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 = ( const parseDuration = (
input: string, input: string,
): { ): {
@@ -357,14 +572,85 @@ const parseDuration = (
return {millis: n * map[unit]}; 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<string> = new Set(['has', 'pinned', 'author-type']);
export function isSearchFilterValueMutable(key: string): boolean {
return !NON_MUTABLE_VALUE_KEYS.has(normalizeSearchFilterKey(key));
}
const BOOLEAN_VALUES: ReadonlySet<string> = 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<string, (value: string) => 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<T>(values: Array<T> | undefined, value: T): Array<T> {
if (values == null) return [value];
if (values.includes(value)) return values;
return [...values, value];
}
export function parseQuery(query: string, hints?: SearchHints, ctx?: ParseContext): MessageSearchParams { export function parseQuery(query: string, hints?: SearchHints, ctx?: ParseContext): MessageSearchParams {
const {tokens, content, exactPhrases} = tokenize(query); const {tokens, content, exactPhrases} = tokenize(query);
const params: MessageSearchParams = {}; const params: MessageSearchParams = {};
if (content) params.content = content; if (content) params.content = content;
if (exactPhrases.length > 0) params.exactPhrases = exactPhrases; if (exactPhrases.length > 0) params.exactPhrases = exactPhrases;
function add<T>(arr: Array<T> | undefined, v: T): Array<T> { const add = addUniqueSearchParam;
return arr ? [...arr, v] : [v];
}
for (const tok of tokens) { for (const tok of tokens) {
const key = tok.key === 'on' ? 'during' : tok.key; const key = tok.key === 'on' ? 'during' : tok.key;
const normalizedKey = key.startsWith('-') ? key.slice(1) : key; const normalizedKey = key.startsWith('-') ? key.slice(1) : key;
@@ -530,9 +816,8 @@ export function parseQuery(query: string, hints?: SearchHints, ctx?: ParseContex
break; break;
} }
case 'scope': { case 'scope': {
const values = splitCSV(tok.value); for (const item of splitCSV(tok.value)) {
for (const value of values) { const normalized = normalizeToken(item);
const normalized = value.toLowerCase();
if (isSearchScope(normalized)) { if (isSearchScope(normalized)) {
params.scope = normalized; params.scope = normalized;
break; break;
@@ -568,24 +853,25 @@ export function parseQuery(query: string, hints?: SearchHints, ctx?: ParseContex
case 'during': { case 'during': {
if (tok.value.includes('..')) { if (tok.value.includes('..')) {
const [a, b] = tok.value.split('..'); const [a, b] = tok.value.split('..');
const start = parseCompactDateTime(a); const lower = resolveSearchDatePeriod(a);
const end = parseCompactDateTime(b); const upper = resolveSearchDatePeriod(b);
if (start) params.minId = toSnowflakeAt(start.startOf('day')); if (!lower || !upper) break;
if (end) params.maxId = toSnowflakeAt(end.endOf('day')); params.minId = toSnowflakeAt(lower.start);
params.maxId = toSnowflakeAt(upper.end);
break; break;
} }
const dt = parseCompactDateTime(tok.value); const period = resolveSearchDatePeriod(tok.value);
if (!dt) break; if (!period) break;
if (key === 'before') { if (key === 'before') {
params.maxId = toSnowflakeAt(dt); params.maxId = toSnowflakeAt(period.start);
} else if (key === 'after') { break;
params.minId = toSnowflakeAt(dt);
} else {
const start = dt.startOf('day');
const end = dt.endOf('day');
params.minId = toSnowflakeAt(start);
params.maxId = toSnowflakeAt(end);
} }
if (key === 'after') {
params.minId = toSnowflakeAt(period.end);
break;
}
params.minId = toSnowflakeAt(period.start);
params.maxId = toSnowflakeAt(resolveDuringUpperBound(period));
break; break;
} }
default: default: