feat(search): in:, before:/after:, has:, is:pinned operators (#106)
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 10m39s
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 10m39s
Typed operators alongside the existing from:, each one setting the filter the
buttons already set (so typing and clicking end in the same state, shown in
the filter bar):
in:general in:"voice lounge" room by name / alias / id (unique partial
name ok); a room outside this page's scope
switches to all rooms
after:2026-09-01 before:7d date range; relative h/d/w/m/y
has:link | image | video | file contains-link / media filters
is:pinned pinned-only filter
- searchOperators.ts: pure parser + room resolver (unit-tested); quoted
values; keys case-insensitive; only at a word start, so URLs and times
aren't mistaken for operators; anything it doesn't understand stays in the
searched text with a notice listing the valid operators.
- Notices: no matching room; operators with no words or from: ("Add a word to
search for, or a from:@user.") — then the text is kept in the box.
- from:bob now resolves to a known @bob:server (this homeserver first); the
old path sent "@bob", which matched nobody unless picked from autocomplete.
Verified in Chromium on a local Synapse: `raid in:"Thread Lab" from:bob`
sets rooms=!…Thread Lab, senders=@bob:localhost and finds bob's message;
dates, an unknown room, operators-only and has:gif give the expected params
and notices.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
06bb896a00
commit
75951f9040
@@ -50,6 +50,7 @@ import { clearAll as clearSearchCache } from '../../utils/searchCache';
|
|||||||
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
|
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
|
||||||
import { SearchResultGroup } from './SearchResultGroup';
|
import { SearchResultGroup } from './SearchResultGroup';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
|
import { ParsedSearch, resolveRoomOperand } from './searchOperators';
|
||||||
import { SearchFilters } from './SearchFilters';
|
import { SearchFilters } from './SearchFilters';
|
||||||
import { VirtualTile } from '../../components/virtualizer';
|
import { VirtualTile } from '../../components/virtualizer';
|
||||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||||
@@ -205,6 +206,8 @@ export function MessageSearch({
|
|||||||
// currently pinned in their room (`m.room.pinned_events`). Server-unaffected.
|
// currently pinned in their room (`m.room.pinned_events`). Server-unaffected.
|
||||||
const [pinnedOnly, setPinnedOnly] = useState(false);
|
const [pinnedOnly, setPinnedOnly] = useState(false);
|
||||||
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
|
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
|
||||||
|
// [Gitea #106] Why a typed operator couldn't be applied (unknown room, …).
|
||||||
|
const [operatorNotice, setOperatorNotice] = useState<string>();
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
|
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
|
||||||
@@ -400,6 +403,94 @@ export function MessageSearch({
|
|||||||
setRecentSearches((prev) => addRecentSearch(prev, term));
|
setRecentSearches((prev) => addRecentSearch(prev, term));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// [Gitea #106] Typed operators → the same filters the buttons set.
|
||||||
|
const handleQuery = (parsed: ParsedSearch, raw: string): boolean => {
|
||||||
|
const notices: string[] = [];
|
||||||
|
const roomChoices = allRooms.map((roomId) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
return {
|
||||||
|
roomId,
|
||||||
|
name: room?.name ?? '',
|
||||||
|
aliases: [room?.getCanonicalAlias(), ...(room?.getAltAliases() ?? [])].filter(
|
||||||
|
(a): a is string => !!a,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const roomIds: string[] = [];
|
||||||
|
parsed.rooms.forEach((value) => {
|
||||||
|
const id = resolveRoomOperand(value, roomChoices);
|
||||||
|
if (id) {
|
||||||
|
if (!roomIds.includes(id)) roomIds.push(id);
|
||||||
|
} else notices.push(`No room matches “in:${value}”.`);
|
||||||
|
});
|
||||||
|
const mergedSenders = [...(searchParamsSenders ?? [])];
|
||||||
|
// from:bob → a known @bob:server (this homeserver first), as autocomplete would.
|
||||||
|
const myServer = mx.getUserId()?.split(':')[1];
|
||||||
|
const resolveUser = (id: string): string => {
|
||||||
|
if (id.includes(':')) return id;
|
||||||
|
const local = id.slice(1).toLowerCase();
|
||||||
|
const found = new Set<string>();
|
||||||
|
mx.getRooms().forEach((room) =>
|
||||||
|
room.getMembers().forEach((m) => {
|
||||||
|
if (m.userId.slice(1).split(':')[0].toLowerCase() === local) found.add(m.userId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const list = [...found];
|
||||||
|
return list.find((u) => u.split(':')[1] === myServer) ?? list[0] ?? id;
|
||||||
|
};
|
||||||
|
parsed.senders.map(resolveUser).forEach((id) => {
|
||||||
|
if (!mergedSenders.includes(id)) mergedSenders.push(id);
|
||||||
|
});
|
||||||
|
if (!parsed.term && mergedSenders.length === 0) {
|
||||||
|
notices.push('Add a word to search for, or a from:@user.');
|
||||||
|
}
|
||||||
|
if (parsed.unknown.length > 0) {
|
||||||
|
notices.push(
|
||||||
|
`Not a search filter: ${parsed.unknown.join(', ')} (searched as text). Try in:, from:, before:, after:, has:link/image/video/file, is:pinned.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setOperatorNotice(notices.length > 0 ? notices.join(' ') : undefined);
|
||||||
|
if (!parsed.term && mergedSenders.length === 0) return false;
|
||||||
|
|
||||||
|
setSearchParams((prevParams) => {
|
||||||
|
const p = new URLSearchParams(prevParams);
|
||||||
|
p.delete('term');
|
||||||
|
p.append('term', parsed.term);
|
||||||
|
if (parsed.senders.length > 0) {
|
||||||
|
p.delete('senders');
|
||||||
|
p.append('senders', encodeSearchParamValueArray(mergedSenders));
|
||||||
|
}
|
||||||
|
if (roomIds.length > 0) {
|
||||||
|
p.delete('rooms');
|
||||||
|
p.append('rooms', encodeSearchParamValueArray(roomIds));
|
||||||
|
// A room outside this page's own scope needs the all-rooms scope.
|
||||||
|
if (allowGlobal && roomIds.some((id) => !rooms.includes(id))) {
|
||||||
|
p.delete('global');
|
||||||
|
p.append('global', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.fromTs !== undefined) {
|
||||||
|
p.delete('fromTs');
|
||||||
|
p.append('fromTs', String(parsed.fromTs));
|
||||||
|
}
|
||||||
|
if (parsed.toTs !== undefined) {
|
||||||
|
p.delete('toTs');
|
||||||
|
p.append('toTs', String(parsed.toTs));
|
||||||
|
}
|
||||||
|
if (parsed.containsUrl) {
|
||||||
|
p.delete('containsUrl');
|
||||||
|
p.append('containsUrl', 'true');
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
if (parsed.msgTypes.length > 0) {
|
||||||
|
setMsgTypeFilters((prev) => [...new Set([...prev, ...parsed.msgTypes])]);
|
||||||
|
}
|
||||||
|
if (parsed.pinnedOnly) setPinnedOnly(true);
|
||||||
|
setRecentSearches((prev) => addRecentSearch(prev, raw));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const handleRecentSearch = (term: string) => {
|
const handleRecentSearch = (term: string) => {
|
||||||
if (searchInputRef.current) {
|
if (searchInputRef.current) {
|
||||||
searchInputRef.current.value = term;
|
searchInputRef.current.value = term;
|
||||||
@@ -548,10 +639,16 @@ export function MessageSearch({
|
|||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onReset={handleSearchClear}
|
onReset={handleSearchClear}
|
||||||
onSenderAdd={handleSenderAdd}
|
onSenderAdd={handleSenderAdd}
|
||||||
|
onQuery={handleQuery}
|
||||||
recentSearches={recentSearches}
|
recentSearches={recentSearches}
|
||||||
onRecentSearch={handleRecentSearch}
|
onRecentSearch={handleRecentSearch}
|
||||||
onClearRecentSearches={handleClearRecentSearches}
|
onClearRecentSearches={handleClearRecentSearches}
|
||||||
/>
|
/>
|
||||||
|
{operatorNotice && (
|
||||||
|
<Text size="T200" role="status" style={{ color: color.Warning.Main }}>
|
||||||
|
{operatorNotice}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<SearchFilters
|
<SearchFilters
|
||||||
defaultRoomsFilterName={defaultRoomsFilterName}
|
defaultRoomsFilterName={defaultRoomsFilterName}
|
||||||
allowGlobal={allowGlobal}
|
allowGlobal={allowGlobal}
|
||||||
@@ -582,7 +679,7 @@ export function MessageSearch({
|
|||||||
<PageHero
|
<PageHero
|
||||||
icon={<Icon size="600" src={Icons.Message} />}
|
icon={<Icon size="600" src={Icons.Message} />}
|
||||||
title="Search Messages"
|
title="Search Messages"
|
||||||
subTitle="Find helpful messages in your community by searching with related keywords, or type from:@user to see all messages from someone."
|
subTitle="Search by keyword. Narrow it with from:@user, in:room, after:2026-09-01 or before:7d, has:link / has:image, and is:pinned."
|
||||||
/>
|
/>
|
||||||
</PageHeroSection>
|
</PageHeroSection>
|
||||||
</PageHeroEmpty>
|
</PageHeroEmpty>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { ParsedSearch, hasOperators, parseSearchQuery } from './searchOperators';
|
||||||
|
|
||||||
// Matches "from:@?anything" anywhere — @ is optional
|
// Matches "from:@?anything" anywhere — @ is optional
|
||||||
const FROM_REGEX = /from:@?([^\s:][^\s]*)/gi;
|
const FROM_REGEX = /from:@?([^\s:][^\s]*)/gi;
|
||||||
@@ -43,6 +44,9 @@ type SearchInputProps = {
|
|||||||
onSearch: (term: string) => void;
|
onSearch: (term: string) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onSenderAdd?: (userId: string) => void;
|
onSenderAdd?: (userId: string) => void;
|
||||||
|
/** [Gitea #106] Receives in:/before:/after:/has:/is: (and from:) as filters. */
|
||||||
|
/** Returns false when the query wasn't run, so the text is kept as typed. */
|
||||||
|
onQuery?: (parsed: ParsedSearch, raw: string) => boolean;
|
||||||
recentSearches?: string[];
|
recentSearches?: string[];
|
||||||
onRecentSearch?: (term: string) => void;
|
onRecentSearch?: (term: string) => void;
|
||||||
onClearRecentSearches?: () => void;
|
onClearRecentSearches?: () => void;
|
||||||
@@ -54,6 +58,7 @@ export function SearchInput({
|
|||||||
onSearch,
|
onSearch,
|
||||||
onReset,
|
onReset,
|
||||||
onSenderAdd,
|
onSenderAdd,
|
||||||
|
onQuery,
|
||||||
recentSearches,
|
recentSearches,
|
||||||
onRecentSearch,
|
onRecentSearch,
|
||||||
onClearRecentSearches,
|
onClearRecentSearches,
|
||||||
@@ -167,6 +172,19 @@ export function SearchInput({
|
|||||||
|
|
||||||
const rawValue = searchInput.value.trim();
|
const rawValue = searchInput.value.trim();
|
||||||
|
|
||||||
|
if (onQuery) {
|
||||||
|
const parsed = parseSearchQuery(rawValue);
|
||||||
|
if (!parsed.term && !hasOperators(parsed)) return;
|
||||||
|
// Recognised operators become filters (chips / controls); drop them
|
||||||
|
// from the visible input like from: always did.
|
||||||
|
const ran = onQuery(parsed, rawValue);
|
||||||
|
if (ran && hasOperators(parsed) && searchInputRef.current) {
|
||||||
|
searchInputRef.current.value = parsed.term;
|
||||||
|
}
|
||||||
|
closeAutocomplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract from:user fragments and convert to sender filters
|
// Extract from:user fragments and convert to sender filters
|
||||||
const fromMatches = [...rawValue.matchAll(FROM_REGEX)];
|
const fromMatches = [...rawValue.matchAll(FROM_REGEX)];
|
||||||
fromMatches.forEach((match) => {
|
fromMatches.forEach((match) => {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseDateOperand, parseSearchQuery, resolveRoomOperand } from './searchOperators';
|
||||||
|
|
||||||
|
const NOW = new Date(2026, 8, 25, 12, 0, 0).getTime();
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
|
||||||
|
describe('parseSearchQuery', () => {
|
||||||
|
it('plain text stays the term', () => {
|
||||||
|
const p = parseSearchQuery('loot rules tonight', NOW);
|
||||||
|
assert.equal(p.term, 'loot rules tonight');
|
||||||
|
assert.deepEqual(p.senders, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulls every operator out of the term', () => {
|
||||||
|
const p = parseSearchQuery(
|
||||||
|
'raid from:bob in:general after:7d before:2026-09-24 has:link has:image is:pinned plan',
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.equal(p.term, 'raid plan');
|
||||||
|
assert.deepEqual(p.senders, ['@bob']);
|
||||||
|
assert.deepEqual(p.rooms, ['general']);
|
||||||
|
assert.equal(p.fromTs, NOW - 7 * DAY);
|
||||||
|
assert.equal(p.toTs, new Date(2026, 8, 24).getTime());
|
||||||
|
assert.equal(p.containsUrl, true);
|
||||||
|
assert.deepEqual(p.msgTypes, ['m.image']);
|
||||||
|
assert.equal(p.pinnedOnly, true);
|
||||||
|
assert.deepEqual(p.unknown, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps @ on from:, accepts quoted in:, and is case-insensitive on keys', () => {
|
||||||
|
const p = parseSearchQuery('FROM:@alice:x.org In:"voice lounge" hello', NOW);
|
||||||
|
assert.deepEqual(p.senders, ['@alice:x.org']);
|
||||||
|
assert.deepEqual(p.rooms, ['voice lounge']);
|
||||||
|
assert.equal(p.term, 'hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves operators it cannot understand in the term', () => {
|
||||||
|
const p = parseSearchQuery('has:gif after:someday is:starred hi', NOW);
|
||||||
|
assert.equal(p.term, 'has:gif after:someday is:starred hi');
|
||||||
|
assert.deepEqual(p.unknown, ['has:gif', 'after:someday', 'is:starred']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat a URL or a mid-word colon as an operator', () => {
|
||||||
|
const p = parseSearchQuery('see https://in:example.org and 10:30 before-lunch', NOW);
|
||||||
|
assert.equal(p.term, 'see https://in:example.org and 10:30 before-lunch');
|
||||||
|
assert.deepEqual(p.rooms, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has: file/video map to the media filters without duplicates', () => {
|
||||||
|
const p = parseSearchQuery('has:file has:files has:video', NOW);
|
||||||
|
assert.deepEqual(p.msgTypes, ['m.file', 'm.video']);
|
||||||
|
assert.equal(p.term, '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseDateOperand', () => {
|
||||||
|
it('relative units', () => {
|
||||||
|
assert.equal(parseDateOperand('3h', NOW), NOW - 3 * 3_600_000);
|
||||||
|
assert.equal(parseDateOperand('2w', NOW), NOW - 14 * DAY);
|
||||||
|
assert.equal(parseDateOperand('1m', NOW), NOW - 30 * DAY);
|
||||||
|
});
|
||||||
|
it('rejects impossible dates and junk', () => {
|
||||||
|
assert.equal(parseDateOperand('2026-02-30', NOW), undefined);
|
||||||
|
assert.equal(parseDateOperand('yesterday', NOW), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveRoomOperand', () => {
|
||||||
|
const rooms = [
|
||||||
|
{ roomId: '!a:x', name: 'General', aliases: ['#general:x.org'] },
|
||||||
|
{ roomId: '!b:x', name: 'Voice Lounge', aliases: [] },
|
||||||
|
{ roomId: '!c:x', name: 'Off Topic', aliases: ['#offtopic:x.org'] },
|
||||||
|
];
|
||||||
|
it('by alias, alias localpart, name, room id', () => {
|
||||||
|
assert.equal(resolveRoomOperand('#general:x.org', rooms), '!a:x');
|
||||||
|
assert.equal(resolveRoomOperand('offtopic', rooms), '!c:x');
|
||||||
|
assert.equal(resolveRoomOperand('voice lounge', rooms), '!b:x');
|
||||||
|
assert.equal(resolveRoomOperand('!b:x', rooms), '!b:x');
|
||||||
|
});
|
||||||
|
it('a unique partial name match, otherwise nothing', () => {
|
||||||
|
assert.equal(resolveRoomOperand('lounge', rooms), '!b:x');
|
||||||
|
assert.equal(resolveRoomOperand('o', rooms), undefined);
|
||||||
|
assert.equal(resolveRoomOperand('nope', rooms), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user