mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(app): understand date words and DM channels in search filters (#1852)
This commit is contained in:
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:
|
||||||
|
|||||||
Reference in new issue
Block a user