/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/search/search.mjs * Purpose : FTS5 queries with filters (type, tag, folder, dates, size, starred) * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { getDb } from '../db/db.mjs'; const TYPE_BUCKETS = { image: "n.mime LIKE 'image/%'", video: "n.mime LIKE 'video/%'", audio: "n.mime LIKE 'audio/%'", 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%')", 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%')", folder: "n.type = 'folder'", }; /** Escape user input for an FTS5 MATCH — quote each term, keep prefix `*`. */ function ftsQuery(q) { const terms = String(q).trim().split(/\s+/).filter(Boolean).slice(0, 12); if (terms.length === 0) return null; return terms .map((t) => { const prefix = t.endsWith('*'); const clean = t.replace(/["']/g, ''); if (!clean) return null; return `"${clean.replace(/\*/g, '')}"${prefix ? '*' : ''}`; }) .filter(Boolean) .join(' '); } /** * Full-text search over names, tags and extracted content. * @param {string} q * @param {{type?: string, tag?: string, folderId?: number, starred?: boolean, * shared?: boolean, after?: number, before?: number, * minSize?: number, maxSize?: number, limit?: number}} filters * @returns {Array} node rows + snippet */ export function searchNodes(q, filters = {}) { const db = getDb(); const match = ftsQuery(q); if (!match) return []; const where = ['n.trashed_at IS NULL']; const params = { match, limit: Math.min(filters.limit ?? 100, 500) }; if (filters.type && TYPE_BUCKETS[filters.type]) where.push(TYPE_BUCKETS[filters.type]); if (filters.starred) where.push('n.starred = 1'); if (filters.after) { where.push('n.modified >= @after'); params.after = filters.after; } if (filters.before) { where.push('n.modified <= @before'); params.before = filters.before; } if (filters.minSize) { where.push('n.size >= @minSize'); params.minSize = filters.minSize; } if (filters.maxSize) { where.push('n.size <= @maxSize'); params.maxSize = filters.maxSize; } if (filters.tag) { 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)'); params.tag = filters.tag; } if (filters.shared) { where.push('EXISTS (SELECT 1 FROM shares s WHERE s.node_id = n.id AND s.revoked_at IS NULL)'); } let rows = db .prepare( `SELECT n.*, snippet(fts, 2, '', '', ' … ', 12) AS snippet, bm25(fts, 8.0, 4.0, 1.0) AS rank FROM fts JOIN nodes n ON n.id = fts.rowid WHERE fts MATCH @match AND ${where.join(' AND ')} ORDER BY rank LIMIT @limit`, ) .all(params); // Folder-scope filter walks ancestry in JS (cheap at these result sizes). if (filters.folderId) { const inScope = (id) => { let cur = db.prepare('SELECT id, parent_id FROM nodes WHERE id = ?').get(id); while (cur) { if (cur.id === Number(filters.folderId)) return true; cur = cur.parent_id ? db.prepare('SELECT id, parent_id FROM nodes WHERE id = ?').get(cur.parent_id) : null; } return false; }; rows = rows.filter((r) => inScope(r.id)); } return rows; } /** Optimize the FTS index (daily maintenance). */ export function vacuumFts() { getDb().prepare("INSERT INTO fts(fts) VALUES ('optimize')").run(); }