spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/search/search.mjs8 * Purpose : FTS5 queries with filters (type, tag, folder, dates, size, starred)9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { getDb } from '../db/db.mjs';1415const TYPE_BUCKETS = {16 image: "n.mime LIKE 'image/%'",17 video: "n.mime LIKE 'video/%'",18 audio: "n.mime LIKE 'audio/%'",19 doc: "(n.mime = 'application/pdf' OR n.mime LIKE 'text/%' OR n.mime LIKE '%word%' OR n.mime LIKE '%sheet%' OR n.mime LIKE '%presentation%' OR n.mime LIKE '%opendocument%')",20 archive: "(n.mime LIKE '%zip%' OR n.mime LIKE '%tar%' OR n.mime LIKE '%7z%' OR n.mime LIKE '%rar%' OR n.mime LIKE '%gzip%')",21 folder: "n.type = 'folder'",22};2324/** Escape user input for an FTS5 MATCH — quote each term, keep prefix `*`. */25function ftsQuery(q) {26 const terms = String(q).trim().split(/\s+/).filter(Boolean).slice(0, 12);27 if (terms.length === 0) return null;28 return terms29 .map((t) => {30 const prefix = t.endsWith('*');31 const clean = t.replace(/["']/g, '');32 if (!clean) return null;33 return `"${clean.replace(/\*/g, '')}"${prefix ? '*' : ''}`;34 })35 .filter(Boolean)36 .join(' ');37}3839/**40 * Full-text search over names, tags and extracted content.41 * @param {string} q42 * @param {{type?: string, tag?: string, folderId?: number, starred?: boolean,43 * shared?: boolean, after?: number, before?: number,44 * minSize?: number, maxSize?: number, limit?: number}} filters45 * @returns {Array<object>} node rows + snippet46 */47export function searchNodes(q, filters = {}) {48 const db = getDb();49 const match = ftsQuery(q);50 if (!match) return [];5152 const where = ['n.trashed_at IS NULL'];53 const params = { match, limit: Math.min(filters.limit ?? 100, 500) };5455 if (filters.type && TYPE_BUCKETS[filters.type]) where.push(TYPE_BUCKETS[filters.type]);56 if (filters.starred) where.push('n.starred = 1');57 if (filters.after) { where.push('n.modified >= @after'); params.after = filters.after; }58 if (filters.before) { where.push('n.modified <= @before'); params.before = filters.before; }59 if (filters.minSize) { where.push('n.size >= @minSize'); params.minSize = filters.minSize; }60 if (filters.maxSize) { where.push('n.size <= @maxSize'); params.maxSize = filters.maxSize; }61 if (filters.tag) {62 where.push('EXISTS (SELECT 1 FROM node_tags nt JOIN tags t ON t.id = nt.tag_id WHERE nt.node_id = n.id AND t.name = @tag)');63 params.tag = filters.tag;64 }65 if (filters.shared) {66 where.push('EXISTS (SELECT 1 FROM shares s WHERE s.node_id = n.id AND s.revoked_at IS NULL)');67 }6869 let rows = db70 .prepare(71 `SELECT n.*, snippet(fts, 2, '<mark>', '</mark>', ' … ', 12) AS snippet,72 bm25(fts, 8.0, 4.0, 1.0) AS rank73 FROM fts JOIN nodes n ON n.id = fts.rowid74 WHERE fts MATCH @match AND ${where.join(' AND ')}75 ORDER BY rank LIMIT @limit`,76 )77 .all(params);7879 // Folder-scope filter walks ancestry in JS (cheap at these result sizes).80 if (filters.folderId) {81 const inScope = (id) => {82 let cur = db.prepare('SELECT id, parent_id FROM nodes WHERE id = ?').get(id);83 while (cur) {84 if (cur.id === Number(filters.folderId)) return true;85 cur = cur.parent_id86 ? db.prepare('SELECT id, parent_id FROM nodes WHERE id = ?').get(cur.parent_id)87 : null;88 }89 return false;90 };91 rows = rows.filter((r) => inScope(r.id));92 }93 return rows;94}9596/** Optimize the FTS index (daily maintenance). */97export function vacuumFts() {98 getDb().prepare("INSERT INTO fts(fts) VALUES ('optimize')").run();99}100