141 lines
4.8 KiB
TypeScript
141 lines
4.8 KiB
TypeScript
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<string, number> = {
|
||
|
|
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<string, MsgTypeFilter> = {
|
||
|
|
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;
|
||
|
|
}
|