SPB Git

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%
4.0 KB · 100 lines javascript
Raw Blame History
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