994d5070f0
- Archiv-Home pro Projekt (archiveHome in ai-control.json); archive_panel mit Ordner, Beschreibung und Schlagwörtern (Frontmatter + Zeitstempel-Stem) - Archiv-Index (archive_index.rs): Scan mit Frontmatter, Wikilinks, Backlinks; Wiki-Seiten als strukturierte Daten (Übersicht/Tag-Seiten) - FTS5-Suche (archive_search.rs, rusqlite bundled): in-memory-Index pro Anfrage; MCP search_archive und Panel-Suchfeld (search_run) teilen die Engine - Panel: vier Puffer/Ansichten (Dokument, Befehle, Suche, Wiki) mit Tabs im Fenster-Header; gemeinsame Verdrahtung panel-wiring.ts, Kachel-CSS, Wiki-View mit Tag-Chips, Ordner-Sektionen und Backlinks - Archivieren speichert offene Bearbeitung (flush); Tab-Wechsel speichert statt zu blockieren; Fehler sichtbar als Panel-Toast - Abgelöstes Fenster: rahmenlos (Linux), eigene Kopfleiste mit Tabs und Fensterknöpfen, Theme-Kopplung über gemeinsames themes.ts - MCP-Tools show_commands, show_archive; Befehls-History mit Kachel-Löschen
140 lines
4.4 KiB
Rust
140 lines
4.4 KiB
Rust
//! Volltext-Suche übers Panel-Archiv: SQLite-FTS5-Index, bei jeder Anfrage
|
|
//! frisch in-memory aus dem Archiv-Baum gebaut. Bei den Archiv-Größen dieser
|
|
//! App ist der Aufbau Millisekundensache; damit gibt es keinen persistierten
|
|
//! Index, keine Staleness und nichts, was gesynct werden könnte. Die
|
|
//! Tool-Schnittstelle bleibt engine-unabhängig.
|
|
|
|
use std::path::Path;
|
|
|
|
use rusqlite::Connection;
|
|
|
|
use crate::domain::archive_index::scan_with_bodies;
|
|
|
|
#[derive(serde::Serialize)]
|
|
pub(crate) struct Hit {
|
|
/// Pfad relativ zum Archiv-Home.
|
|
pub(crate) relpath: String,
|
|
pub(crate) title: String,
|
|
/// Textausschnitt um die Fundstelle, Treffer in `**…**`.
|
|
pub(crate) snippet: String,
|
|
}
|
|
|
|
/// Durchsucht das Archiv unter `home`. `query` ist FTS5-Syntax (Wörter,
|
|
/// "Phrasen", Präfix*); `tag` engt auf ein Schlagwort ein. Treffer nach
|
|
/// BM25-Rang, höchstens `limit`.
|
|
pub(crate) fn search(
|
|
home: &Path,
|
|
query: &str,
|
|
tag: Option<&str>,
|
|
limit: usize,
|
|
) -> Result<Vec<Hit>, String> {
|
|
let conn = build_index(home)?;
|
|
let expr = match_expr(query, tag)?;
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT relpath, title, snippet(docs, 5, '**', '**', ' … ', 12) \
|
|
FROM docs WHERE docs MATCH ?1 ORDER BY rank LIMIT ?2",
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = stmt
|
|
.query_map(rusqlite::params![expr, limit as i64], |row| {
|
|
Ok(Hit { relpath: row.get(0)?, title: row.get(1)?, snippet: row.get(2)? })
|
|
})
|
|
.map_err(|e| format!("Suchausdruck „{query}“: {e}"))?;
|
|
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// MATCH-Ausdruck aus Query und optionalem Tag-Filter.
|
|
fn match_expr(query: &str, tag: Option<&str>) -> Result<String, String> {
|
|
let q = query.trim();
|
|
let t = tag.map(|t| format!("tags:\"{}\"", t.replace('"', "")));
|
|
match (q.is_empty(), t) {
|
|
(false, Some(t)) => Ok(format!("({q}) AND {t}")),
|
|
(false, None) => Ok(q.to_string()),
|
|
(true, Some(t)) => Ok(t),
|
|
(true, None) => Err("leere Suchanfrage".into()),
|
|
}
|
|
}
|
|
|
|
/// Baut den FTS5-Index in-memory aus dem Archiv-Baum.
|
|
fn build_index(home: &Path) -> Result<Connection, String> {
|
|
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
|
conn
|
|
.execute_batch(
|
|
"CREATE VIRTUAL TABLE docs USING fts5(relpath UNINDEXED, name, title, description, tags, body)",
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
let docs = scan_with_bodies(home)?;
|
|
let mut insert = conn
|
|
.prepare("INSERT INTO docs (relpath, name, title, description, tags, body) VALUES (?1, ?2, ?3, ?4, ?5, ?6)")
|
|
.map_err(|e| e.to_string())?;
|
|
for (doc, body) in &docs {
|
|
insert
|
|
.execute(rusqlite::params![
|
|
doc.relpath,
|
|
doc.name,
|
|
doc.title,
|
|
doc.description.as_deref().unwrap_or(""),
|
|
doc.tags.join(" "),
|
|
body,
|
|
])
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
drop(insert);
|
|
Ok(conn)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::domain::testutil::tmp_paths;
|
|
use std::fs;
|
|
|
|
fn archiv() -> std::path::PathBuf {
|
|
let home = tmp_paths().home.join("archiv");
|
|
fs::create_dir_all(home.join("konzepte")).unwrap();
|
|
fs::write(
|
|
home.join("2026-07-19_1000-adr-logging.md"),
|
|
"---\ntitle: \"ADR Logging\"\ntags: [\"adr\", \"infra\"]\n---\n\nStrukturiertes Logging mit tracing vereinheitlichen.\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
home.join("konzepte/2026-07-19_1005-notiz-deploy.md"),
|
|
"---\ntitle: \"Notiz Deploy\"\ntags: [\"infra\"]\n---\n\nDeploy braucht lsregister auf macOS.\n",
|
|
)
|
|
.unwrap();
|
|
home
|
|
}
|
|
|
|
#[test]
|
|
fn findet_nach_inhaltswort() {
|
|
let home = archiv();
|
|
let hits = search(&home, "tracing", None, 10).unwrap();
|
|
assert_eq!(hits.len(), 1);
|
|
assert_eq!(hits[0].title, "ADR Logging");
|
|
assert!(hits[0].snippet.contains("**tracing**"));
|
|
}
|
|
|
|
#[test]
|
|
fn tag_filter_engt_ein() {
|
|
let home = archiv();
|
|
assert_eq!(search(&home, "", Some("infra"), 10).unwrap().len(), 2);
|
|
assert_eq!(search(&home, "", Some("adr"), 10).unwrap().len(), 1);
|
|
let hits = search(&home, "deploy", Some("adr"), 10).unwrap();
|
|
assert!(hits.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn phrase_und_praefix() {
|
|
let home = archiv();
|
|
assert_eq!(search(&home, "\"Strukturiertes Logging\"", None, 10).unwrap().len(), 1);
|
|
assert_eq!(search(&home, "lsregist*", None, 10).unwrap().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn leere_anfrage_scheitert() {
|
|
let home = archiv();
|
|
assert!(search(&home, " ", None, 10).is_err());
|
|
}
|
|
}
|