Files
Haven/src/searchIndex.js
T
Matt def22e3fff [big feat] Search overhaul (#5533)
* Rework message search into a persistent overlay panel

Search results now open as an overlay on the right side over the member
list, the way Discord does it. The panel stays open across channel
switches and only closes when you close it yourself. Public channels
share one panel while each DM keeps its own.

Results paginate at 25 per page. The panel clears and asks you to
refresh when a channel or one of your roles changes, so it never shows
messages you can no longer see. A collapsed sidebar expands
automatically while the panel is open.

* Make message search global with SQLite full text search

Search now spans every channel you can see instead of just the current
one, backed by an FTS5 index that stays in sync with messages through
triggers. Results are scoped to your channel memberships on every query,
so you never see anything you don't have access to. in:#channel narrows
to one channel, from: and has: still work.

The tokenizer is admin configurable through HAVEN_SEARCH_TOKENIZER and
reconciled at boot: unicode61 by default (closest to Discord), with
porter for stemming and trigram for substring matching. Changing it
rebuilds the index once on the next restart. Results come back 25 per
page with a total count, sorted newest first.

* Add a filter picker and cross-channel results to search

Clicking the search box opens a filter popover with from:, in: and has:
pickers. Each lists what you can see (members, channels, media types),
rendered client side with a prefix filter box, and clicking one appends
the filter to the search box. A filter button next to the box toggles
the popover so you can reopen it without closing search.

Channel names are not unique, so in:#code selects a channel by its code
while in:name still matches by name. The picker fills the code form so
duplicate names are never ambiguous.

Search results now show the channel each message came from as
#name (code) above the author, and clicking a result switches to that
channel before jumping to the message so cross-channel results work.

* Add date, pinned, and audio filters plus a sort control to search

The filter popover gains before:, after: and during: date pickers, a
pinned toggle, and audio in the has: list. before/after take a boundary
date and during matches a single day. A sort row lets you order results
by newest, oldest, or most relevant, which the search engine already
supported but the UI never exposed.

All of these run server side against the message index, so they stay
scoped to the channels you can see.

* Guard against stale search results, add a loading state and recents

Each search request carries a sequence token that the server echoes
back, and the client only renders the response matching its latest
request. A slow earlier query can no longer overwrite newer results.
A spinner shows while a request is in flight.

Recent searches are kept in local storage and offered at the top of the
filter popover when the box is empty. They save on Enter, so the list
holds real searches rather than the partials typed on the way there.

* Render search results as real messages and rate limit search

Search results now render through the message formatter, so images,
uploaded video and audio, spoilers, mentions and links look the same as
they do in chat. Audio uses preload=none so nothing loads until play.

Page links are not unfurled automatically. Each link gets a Load button
that renders just that embed through the existing preview scheduler and
cache, so a page of results never fires a burst of preview fetches.

Right clicking a result opens a Copy link and Delete menu. Delete targets
the result's own channel, and the row is removed only once the server
confirms the delete. Results that head a thread show a reply count badge.

The bottom pager gains a page number input so you can jump straight to a
page instead of clicking through the arrows one fetch at a time.

Search also gets its own per account rate limit of 10 requests per 10
seconds. When it trips the client keeps its current results and shows a
toast.
2026-08-27 00:28:47 -04:00

137 lines
5.3 KiB
JavaScript

'use strict';
// ── FTS5 full-text search index for messages (search-overhaul phase 2) ──────
// messages_fts is an external-content FTS5 table shadowing messages.content,
// kept in sync by three triggers. messages is the source of truth; the index
// is disposable, so switching tokenizers just drops and rebuilds it (lossless).
//
// The tokenizer is admin-selected via HAVEN_SEARCH_TOKENIZER and reconciled at
// boot: no-op when the live table already matches, otherwise a clean rebuild.
// ensureSearchIndex() runs synchronously during DB init, before the server
// listens, so search is never served from a half-built index.
const VALID_TOKENIZERS = ['trigram', 'unicode61', 'porter'];
const DEFAULT_TOKENIZER = 'unicode61';
// FTS5 tokenize= clause per option. porter layers stemming over unicode61.
const TOKENIZE_CLAUSE = {
trigram: 'trigram',
unicode61: 'unicode61',
porter: 'porter unicode61',
};
let activeTokenizer = DEFAULT_TOKENIZER;
function getConfiguredTokenizer() {
const raw = (process.env.HAVEN_SEARCH_TOKENIZER || '').trim().toLowerCase();
if (!raw) return DEFAULT_TOKENIZER;
if (VALID_TOKENIZERS.includes(raw)) return raw;
console.warn(`[search] Unknown HAVEN_SEARCH_TOKENIZER "${raw}", falling back to "${DEFAULT_TOKENIZER}". Valid options: ${VALID_TOKENIZERS.join(', ')}.`);
return DEFAULT_TOKENIZER;
}
function getActiveTokenizer() { return activeTokenizer; }
// Minimum query length that can match. Trigram indexes 3-char sequences, so it
// cannot substring-match shorter queries; word tokenizers are fine at 2.
function minQueryChars(tokenizer = activeTokenizer) {
return tokenizer === 'trigram' ? 3 : 2;
}
// Turn free-text into a safe FTS5 MATCH expression. Every term is wrapped in
// double quotes (internal quotes doubled) so user input can never inject FTS
// syntax. Word tokenizers get a trailing * for prefix matching ("app" -> apple);
// trigram already matches substrings so it needs none.
function buildMatchQuery(text, tokenizer = activeTokenizer) {
const terms = String(text || '')
.split(/\s+/)
.filter(Boolean)
.map(term => '"' + term.replace(/"/g, '""') + '"');
if (!terms.length) return null;
if (tokenizer === 'trigram') return terms.join(' ');
return terms.map(term => term + '*').join(' ');
}
// Read the tokenizer the live messages_fts table was built with, straight from
// its stored DDL (authoritative; a settings row could drift). null = no table.
function detectExistingTokenizer(db) {
const row = db.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages_fts'"
).get();
if (!row || !row.sql) return null;
const m = row.sql.match(/tokenize\s*=\s*'([^']*)'/i);
if (!m) return 'unicode61'; // FTS5 default when tokenize= is omitted
const clause = m[1].trim().toLowerCase();
if (clause.startsWith('trigram')) return 'trigram';
if (clause.startsWith('porter')) return 'porter';
return 'unicode61';
}
function dropIndex(db) {
db.exec(`
DROP TRIGGER IF EXISTS messages_ai;
DROP TRIGGER IF EXISTS messages_ad;
DROP TRIGGER IF EXISTS messages_au;
DROP TABLE IF EXISTS messages_fts;
`);
}
function createIndex(db, tokenizer) {
const clause = TOKENIZE_CLAUSE[tokenizer] || TOKENIZE_CLAUSE[DEFAULT_TOKENIZER];
db.exec(`
CREATE VIRTUAL TABLE messages_fts USING fts5(
content,
content='messages',
content_rowid='id',
tokenize='${clause}'
);
CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.id, old.content);
END;
CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.id, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
`);
}
// Reconcile the on-disk index with the configured tokenizer. Safe to call every
// boot: no-op when correct, full drop + rebuild when missing or changed. The
// whole switch runs in one transaction, so an interrupted/restart-loop boot
// rolls back and simply retries next time. It cannot corrupt the DB.
function ensureSearchIndex(db) {
const desired = getConfiguredTokenizer();
activeTokenizer = desired;
const existing = detectExistingTokenizer(db);
console.log(`[search] Current tokenizer: "${existing || 'none'}" | configured: "${desired}".`);
if (existing === desired) {
console.log('[search] Tokenizer unchanged, no migration needed.');
return;
}
console.log(`[search] Detected new tokenizer "${desired}", performing clean migration now....`);
const t0 = Date.now();
const migrate = db.transaction(() => {
if (existing) dropIndex(db);
createIndex(db, desired);
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild');");
});
migrate();
const count = db.prepare('SELECT count(*) AS n FROM messages_fts').get().n;
console.log(`[search] Index rebuilt with "${desired}" (${count} messages) in ${((Date.now() - t0) / 1000).toFixed(1)}s.`);
}
module.exports = {
ensureSearchIndex,
getActiveTokenizer,
minQueryChars,
buildMatchQuery,
VALID_TOKENIZERS,
DEFAULT_TOKENIZER,
};