// lib/services/vault_search_index.dart // // Phase 6 of the file-based storage plan (docs/plans/2026-06-24-file-based- // storage.md §B/§F): the search index, rebuilt by SCANNING the vault sidecars // (the source of truth) rather than the demoted SQLite cache. // // What it indexes, per notebook/note folder, from its `*.badnote.json` sidecar: // * the title (standalone notebooks) / source filename (file-backed docs), // * every typed text box — EditorStroke(tool: text).textContent across all // pages, // * the handwriting OCR text persisted in the sidecar's `ocrText` field. // // It ALSO indexes a file-backed PDF's document body text, captured once at // import into the sidecar's `pageText` field by [PdfTextIndexer]: the embedded // (printed) text layer for a normal PDF, or background OCR of the rendered pages // for a RASTERIZED / scanned PDF that has no text layer. Matching uses the // existing pure search primitives (normalize / rank / snippet), so CJK substring // search works. import 'dart:io'; import '../editor/search/search_ranking.dart'; import '../editor/search/search_snippet.dart'; import '../storage/badnote_sidecar.dart'; import '../storage/sidecar_store.dart'; import 'vault_service.dart'; /// One indexed notebook: where it lives and the text harvested from its sidecar. class VaultSearchEntry { const VaultSearchEntry({ required this.id, required this.title, required this.openPath, required this.docType, required this.text, required this.isNote, }); /// Stable id (the notebook folder path). final String id; /// Display title (note title / source filename). final String title; /// Path to pass to the editor: the in-vault source file for docs, or the /// synthetic `/notebook` note path for standalone notebooks. final String openPath; /// `pdf` / `pptx` / `ppt` / `docx` / `notebook`. final String docType; /// All searchable text harvested from the sidecar (title + typed text + OCR), /// joined for substring matching. final String text; /// True for standalone (free-ink) notebooks, false for file-backed documents. final bool isNote; } /// A search hit over the vault: the entry plus a display snippet of the match. class VaultSearchHit { const VaultSearchHit({required this.entry, required this.snippet}); final VaultSearchEntry entry; final Snippet snippet; } /// Builds and queries a scan-based full-text index over the vault sidecars. /// /// The index is the list of [VaultSearchEntry]s built by [rebuild]; query is a /// pure substring/rank over their harvested text (CJK-safe). Cheap enough to /// rebuild lazily on demand for a single-user vault; there is no background /// thread and no persisted index file (the SQLite cache is no longer the search /// source of truth). /// /// HONEST SCOPE (what search covers / does NOT): /// * COVERS: note/doc titles, typed text boxes, handwriting OCR text persisted /// into a sidecar's `ocrText` field, AND a PDF's document body text persisted /// into `pageText` at import — the embedded text layer, or background OCR of /// a rasterized/scanned PDF (see [PdfTextIndexer]). /// * CAVEAT: `pageText` is only present once import-time indexing has run and /// written it back to the sidecar. A PDF imported before this feature (or /// whose OCR backend was unavailable) has no `pageText`, so only its /// annotations are searchable until it is re-indexed. class VaultSearchIndex { VaultSearchIndex(this._vault); final VaultService _vault; List _entries = const []; bool _built = false; /// The entries from the most recent [rebuild] (for tests / inspection). List get entries => List.unmodifiable(_entries); /// Scan the vault and (re)build the in-memory index. Safe on an empty/missing /// vault (yields an empty index). Never throws on a single unreadable sidecar. Future rebuild() async { final entries = []; final notebooks = await _vault.scanNotebooks(); for (final nb in notebooks) { final sidecar = await SidecarStore.read( File('${nb.sourceFilePath}$kVaultSidecarSuffix'), ); entries.add( VaultSearchEntry( id: nb.folderPath, title: nb.filename, openPath: nb.sourceFilePath, docType: nb.docType, text: _harvest(title: nb.filename, sidecar: sidecar), isNote: false, ), ); } final notes = await _vault.scanNotes(); for (final note in notes) { final sidecar = await SidecarStore.read( File('${note.notePath}$kVaultSidecarSuffix'), ); entries.add( VaultSearchEntry( id: note.folderPath, title: note.title, openPath: note.notePath, docType: 'notebook', text: _harvest(title: note.title, sidecar: sidecar), isNote: true, ), ); } _entries = entries; _built = true; } /// Search the index for [query], rebuilding it first if it has never been /// built. Returns the best-matching notebooks/notes, most-relevant first. An /// empty/whitespace query yields no hits. Future> search(String query) async { if (query.trim().isEmpty) return const []; if (!_built) await rebuild(); final sources = { for (final e in _entries) e.id: e.text, }; final ranked = rankHits(sources, query); final byId = {for (final e in _entries) e.id: e}; final hits = []; for (final hit in ranked) { final entry = byId[hit.ref]; if (entry == null) continue; hits.add(VaultSearchHit(entry: entry, snippet: hit.snippet)); } return hits; } /// Concatenate every searchable string from one sidecar: the [title], every /// typed text box across all pages, and the persisted handwriting OCR text. static String _harvest({ required String title, required BadnoteSidecar? sidecar, }) { final parts = [title]; if (sidecar != null) { // Typed text boxes: a stroke carries `textContent` regardless of tool // (EditorTool has only pen/highlighter/eraser; text is a content flag). for (final pageStrokes in sidecar.strokes.values) { for (final stroke in pageStrokes) { final text = stroke.textContent; if (text != null && text.trim().isNotEmpty) { parts.add(text.trim()); } } } final ocr = sidecar.ocrText; if (ocr != null && ocr.trim().isNotEmpty) parts.add(ocr.trim()); // Document body text captured at import: the PDF's embedded text layer, // or background OCR of a rasterized/scanned PDF. Covers the underlying // document, not just the user's annotations. final body = sidecar.pageText; if (body != null && body.trim().isNotEmpty) parts.add(body.trim()); } return parts.join('\n'); } }