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
87 lines
3.4 KiB
TypeScript
87 lines
3.4 KiB
TypeScript
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);
|
|
});
|
|
});
|