Files
ai-control/src-tauri/src/domain/archive_index.rs
T
marcus hinz 994d5070f0 Archiv-Wiki im Panel: vier Tabs, FTS5-Suche, strukturierbares Archiv
- 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
2026-07-19 15:25:40 +02:00

377 lines
12 KiB
Rust

//! Archiv-Index: reproduzierbare Sicht über den Archiv-Baum eines Projekts —
//! pro Markdown-Dokument Name, Frontmatter-Metadaten und Wikilinks; Backlinks
//! fallen beim Scan als Nebenprodukt ab. Der Index ist abgeleitete Information
//! und wird bei Bedarf frisch aus dem Baum gebaut (nichts davon wird gesynct).
//! Das Dateiformat (Frontmatter, Zeitstempel-Stem) definiert archive.rs.
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::domain::archive::{
parse_frontmatter, parse_tag_list, slugify, strip_frontmatter, strip_stamp,
};
#[derive(serde::Serialize, Clone)]
pub(crate) struct Doc {
/// Pfad relativ zum Archiv-Home.
pub(crate) relpath: String,
/// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel.
pub(crate) name: String,
/// Frontmatter-Titel, sonst der Name.
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) tags: Vec<String>,
/// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel).
pub(crate) links: Vec<String>,
/// Namen der Dokumente, die per Wikilink hierher zeigen.
pub(crate) backlinks: Vec<String>,
}
/// Scannt den Archiv-Baum rekursiv über alle Markdown-Dateien; versteckte
/// Einträge (Punkt-Präfix) bleiben außen vor. Reihenfolge: relpath sortiert;
/// Backlinks sind nach dem Scan gefüllt.
pub(crate) fn scan_archive(home: &Path) -> Result<Vec<Doc>, String> {
Ok(scan_with_bodies(home)?.into_iter().map(|(doc, _)| doc).collect())
}
/// Wie `scan_archive`, liefert zu jedem Dokument den bereits gelesenen
/// Volltext mit — die Suche indexiert damit ohne zweite Lesung pro Datei.
pub(crate) fn scan_with_bodies(home: &Path) -> Result<Vec<(Doc, String)>, String> {
let mut docs = Vec::new();
walk(home, home, &mut docs)?;
docs.sort_by(|a, b| a.0.relpath.cmp(&b.0.relpath));
fill_backlinks(&mut docs);
Ok(docs)
}
/// Backlinks in einem Durchlauf: Slug → Doc-Index einmal aufbauen, jeden Link
/// genau einmal auflösen und zu Backlinks invertieren.
fn fill_backlinks(docs: &mut [(Doc, String)]) {
let mut lookup: HashMap<String, usize> = HashMap::new();
for (i, (doc, _)) in docs.iter().enumerate() {
let stem = Path::new(&doc.relpath).file_stem().unwrap_or_default().to_string_lossy();
for key in [slugify(&doc.name), slugify(&doc.title), slugify(&stem)] {
lookup.entry(key).or_insert(i);
}
}
let mut back: Vec<Vec<String>> = vec![Vec::new(); docs.len()];
for (i, (doc, _)) in docs.iter().enumerate() {
let mut targets: std::collections::BTreeSet<usize> = docs[i]
.0
.links
.iter()
.filter_map(|l| lookup.get(&slugify(l)).copied())
.collect();
targets.remove(&i);
for t in targets {
back[t].push(doc.name.clone());
}
}
for ((doc, _), b) in docs.iter_mut().zip(back) {
doc.backlinks = b;
}
}
fn walk(home: &Path, dir: &Path, docs: &mut Vec<(Doc, String)>) -> Result<(), String> {
let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
let path = entry.path();
let file_name = entry.file_name();
let fname = file_name.to_string_lossy();
if fname.starts_with('.') {
continue;
}
if path.is_dir() {
walk(home, &path, docs)?;
} else if fname.ends_with(".md") {
docs.push(read_doc(home, &path)?);
}
}
Ok(())
}
fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
let name = strip_stamp(&stem).to_string();
let fm = parse_frontmatter(&text);
let relpath = path
.strip_prefix(home)
.map_err(|e| format!("{}: {e}", path.display()))?
.display()
.to_string();
let doc = Doc {
relpath,
title: fm.get("title").unwrap_or(&name).clone(),
description: fm.get("description").cloned(),
tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(),
links: wikilinks(&text),
backlinks: Vec::new(),
name,
};
Ok((doc, text))
}
/// Slug-Vergleich eines Wikilink-Ziels gegen Name, Titel und Datei-Stem.
fn matches(doc: &Doc, want: &str) -> bool {
slugify(&doc.name) == want
|| slugify(&doc.title) == want
|| Path::new(&doc.relpath)
.file_stem()
.is_some_and(|s| slugify(&s.to_string_lossy()) == want)
}
/// Übersichts- bzw. Schlagwort-Seite als strukturierte Wiki-Daten: Dokumente
/// nach Ordnern gruppiert (neueste zuerst), Schlagwort-Leiste mit Zählern.
/// Das Panel rendert daraus die Wiki-Ansicht; `kind` unterscheidet im
/// Wiki-Puffer Seite und Dokument.
#[derive(serde::Serialize)]
pub(crate) struct WikiPage {
pub(crate) kind: &'static str,
pub(crate) home: String,
pub(crate) tag: Option<String>,
pub(crate) total: usize,
pub(crate) tags: Vec<TagCount>,
pub(crate) folders: Vec<WikiFolder>,
}
#[derive(serde::Serialize)]
pub(crate) struct TagCount {
pub(crate) name: String,
pub(crate) count: usize,
}
#[derive(serde::Serialize)]
pub(crate) struct WikiFolder {
/// Ordner relativ zum Archiv-Home; leer für die Wurzel.
pub(crate) name: String,
pub(crate) docs: Vec<WikiDocEntry>,
}
#[derive(serde::Serialize)]
pub(crate) struct WikiDocEntry {
pub(crate) name: String,
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) tags: Vec<String>,
/// Archivierungsdatum aus dem Zeitstempel-Stem (`YYYY-MM-DD`).
pub(crate) date: Option<String>,
}
pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, String> {
let docs = scan_archive(home)?;
let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for doc in &docs {
for t in &doc.tags {
*counts.entry(t).or_default() += 1;
}
}
let selected: Vec<&Doc> = match tag {
Some(t) => docs.iter().filter(|d| d.tags.iter().any(|x| x == t)).collect(),
None => docs.iter().collect(),
};
let mut folders: std::collections::BTreeMap<String, Vec<&Doc>> =
std::collections::BTreeMap::new();
for doc in &selected {
let folder = Path::new(&doc.relpath)
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
folders.entry(folder).or_default().push(doc);
}
Ok(WikiPage {
kind: "page",
home: home.display().to_string(),
tag: tag.map(str::to_string),
total: selected.len(),
tags: counts
.into_iter()
.map(|(name, count)| TagCount { name: name.to_string(), count })
.collect(),
folders: folders
.into_iter()
.map(|(name, mut list)| {
// Zeitstempel-Stems sortieren chronologisch — absteigend = neueste oben.
list.sort_by(|a, b| b.relpath.cmp(&a.relpath));
WikiFolder { name, docs: list.into_iter().map(doc_entry).collect() }
})
.collect(),
})
}
fn doc_entry(doc: &Doc) -> WikiDocEntry {
let stem = Path::new(&doc.relpath)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let date = (stem != doc.name).then(|| stem[..10].to_string());
WikiDocEntry {
name: doc.name.clone(),
title: doc.title.clone(),
description: doc.description.clone(),
tags: doc.tags.clone(),
date,
}
}
/// Ein Archiv-Dokument als Wiki-Ansicht: Markdown-Rumpf ohne Frontmatter plus
/// Metadaten und Backlinks aus dem Scan.
#[derive(serde::Serialize)]
pub(crate) struct WikiDocPage {
pub(crate) kind: &'static str,
pub(crate) home: String,
pub(crate) relpath: String,
pub(crate) name: String,
pub(crate) title: String,
pub(crate) tags: Vec<String>,
pub(crate) backlinks: Vec<String>,
pub(crate) markdown: String,
}
pub(crate) fn wiki_doc(home: &Path, target: &str) -> Result<WikiDocPage, String> {
let pairs = scan_with_bodies(home)?;
let want = slugify(target);
let (doc, body) = pairs
.iter()
.find(|(d, _)| matches(d, &want))
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))?;
Ok(WikiDocPage {
kind: "doc",
home: home.display().to_string(),
relpath: doc.relpath.clone(),
name: doc.name.clone(),
title: doc.title.clone(),
tags: doc.tags.clone(),
backlinks: doc.backlinks.clone(),
markdown: strip_frontmatter(body).to_string(),
})
}
/// Alle `[[ziel]]`-Vorkommen im Text, in Dokumentreihenfolge; bei
/// `[[ziel|label]]` zählt nur das Ziel.
fn wikilinks(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = text;
while let Some(start) = rest.find("[[") {
rest = &rest[start + 2..];
let Some(end) = rest.find("]]") else {
break;
};
let inner = &rest[..end];
let target = inner.split('|').next().unwrap_or(inner).trim();
if !target.is_empty() {
out.push(target.to_string());
}
rest = &rest[end + 2..];
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::testutil::tmp_paths;
use std::path::PathBuf;
fn write(home: &Path, rel: &str, content: &str) {
let path = home.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
fn archiv() -> PathBuf {
let home = tmp_paths().home.join("archiv");
fs::create_dir_all(&home).unwrap();
write(
&home,
"2026-07-19_1000-adr-logging.md",
"---\ntitle: \"ADR Logging\"\ndescription: \"Logging vereinheitlichen\"\ntags: [\"adr\", \"infra\"]\n---\n\nSiehe [[notiz-deploy|die Deploy-Notiz]].\n",
);
write(
&home,
"konzepte/2026-07-19_1005-notiz-deploy.md",
"---\ntitle: \"Notiz Deploy\"\n---\n\nText ohne Links.\n",
);
write(&home, ".versteckt/ignoriert.md", "unsichtbar");
home
}
#[test]
fn scan_liest_baum_frontmatter_und_links() {
let home = archiv();
let docs = scan_archive(&home).unwrap();
assert_eq!(docs.len(), 2);
let adr = &docs[0];
assert_eq!(adr.name, "adr-logging");
assert_eq!(adr.title, "ADR Logging");
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
assert_eq!(adr.tags, vec!["adr", "infra"]);
assert_eq!(adr.links, vec!["notiz-deploy"]);
assert_eq!(docs[1].relpath, "konzepte/2026-07-19_1005-notiz-deploy.md");
}
#[test]
fn backlinks_aus_wikilinks() {
let home = archiv();
let docs = scan_archive(&home).unwrap();
let deploy = docs.iter().find(|d| d.name == "notiz-deploy").unwrap();
assert_eq!(deploy.backlinks, vec!["adr-logging"]);
let adr = docs.iter().find(|d| d.name == "adr-logging").unwrap();
assert!(adr.backlinks.is_empty());
}
#[test]
fn startseite_gruppiert_und_zaehlt() {
let home = archiv();
let page = archive_page(&home, None).unwrap();
assert_eq!(page.kind, "page");
assert_eq!(page.tag, None);
assert_eq!(page.total, 2);
let tags: Vec<(&str, usize)> =
page.tags.iter().map(|t| (t.name.as_str(), t.count)).collect();
assert_eq!(tags, vec![("adr", 1), ("infra", 1)]);
assert_eq!(page.folders.len(), 2);
assert_eq!(page.folders[0].name, "");
let adr = &page.folders[0].docs[0];
assert_eq!(adr.name, "adr-logging");
assert_eq!(adr.title, "ADR Logging");
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
assert_eq!(adr.date.as_deref(), Some("2026-07-19"));
assert_eq!(page.folders[1].name, "konzepte");
assert_eq!(page.folders[1].docs[0].name, "notiz-deploy");
}
#[test]
fn tag_seite_filtert() {
let home = archiv();
let page = archive_page(&home, Some("adr")).unwrap();
assert_eq!(page.tag.as_deref(), Some("adr"));
assert_eq!(page.total, 1);
assert_eq!(page.folders.len(), 1);
assert_eq!(page.folders[0].docs[0].name, "adr-logging");
// Die Schlagwort-Leiste bleibt vollständig — sie ist die Navigation.
assert_eq!(page.tags.len(), 2);
}
#[test]
fn wiki_doc_mit_rumpf_und_backlinks() {
let home = archiv();
// Auflösung über Titel; Name und Stem gehen über dieselben Slug-Vergleiche.
let doc = wiki_doc(&home, "Notiz Deploy").unwrap();
assert_eq!(doc.kind, "doc");
assert_eq!(doc.name, "notiz-deploy");
assert_eq!(
wiki_doc(&home, "2026-07-19_1000-adr-logging").unwrap().name,
"adr-logging"
);
assert_eq!(doc.title, "Notiz Deploy");
assert_eq!(doc.relpath, "konzepte/2026-07-19_1005-notiz-deploy.md");
assert_eq!(doc.backlinks, vec!["adr-logging"]);
assert_eq!(doc.markdown, "Text ohne Links.\n");
assert!(wiki_doc(&home, "fehlt").is_err());
}
}