Files
BadNote/lib/services/vault_search_index.dart
Akiba So 2b1c6ba7e0
All checks were successful
CI / Windows build (push) Successful in 8m42s
feat: OneNote-style notebooks, text fonts, and page navigation
Add notebook.json containers with multi-member pages, fix PDF text
editing (size/bold/drag/double-tap), index SidecarText in search, and
share keyboard page shortcuts plus a PDF scrubber.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:27:35 +08:00

195 lines
7.0 KiB
Dart

// 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 `<folder>/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<VaultSearchEntry> _entries = const [];
bool _built = false;
/// The entries from the most recent [rebuild] (for tests / inspection).
List<VaultSearchEntry> 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<void> rebuild() async {
final entries = <VaultSearchEntry>[];
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<List<VaultSearchHit>> search(String query) async {
if (query.trim().isEmpty) return const [];
if (!_built) await rebuild();
final sources = <String, String>{
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 = <VaultSearchHit>[];
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 = <String>[title];
if (sidecar != null) {
// Typed text boxes on the pen-first text tool (SidecarText), plus any
// legacy stroke-embedded textContent.
for (final pageTexts in sidecar.texts.values) {
for (final t in pageTexts) {
if (t.text.trim().isNotEmpty) parts.add(t.text.trim());
}
}
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');
}
}