mirror of
https://github.com/ancsemi/Haven.git
synced 2026-11-04 21:12:50 +00:00
* 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.
137 lines
5.3 KiB
JavaScript
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,
|
|
};
|