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
This commit is contained in:
+180
-15
@@ -11,18 +11,23 @@ use crate::domain::project::{
|
||||
};
|
||||
use crate::domain::registry::project_dir;
|
||||
|
||||
/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir),
|
||||
/// Konfiguriertes Archiv-Home des Projekts (ai-control.json: archiveHome),
|
||||
/// Home-expandiert; None, wenn nicht gesetzt.
|
||||
pub(crate) fn project_archive_dir(project: &str) -> Option<PathBuf> {
|
||||
pub(crate) fn project_archive_home(project: &str) -> Option<PathBuf> {
|
||||
let paths = Paths::real();
|
||||
let dir = read_project_config_in(&paths, project).ok()?.archive_dir?;
|
||||
let dir = read_project_config_in(&paths, project).ok()?.archive_home?;
|
||||
Some(expand_home(&paths, &dir))
|
||||
}
|
||||
|
||||
/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in
|
||||
/// Archiv-Home als Result — die gemeinsame Vorbedingung aller Archiv-Tools.
|
||||
pub(crate) fn require_archive_home(project: &str) -> Result<PathBuf, String> {
|
||||
project_archive_home(project).ok_or_else(|| "kein Archiv-Ordner gesetzt".into())
|
||||
}
|
||||
|
||||
/// Setzt das Archiv-Home: ai-control.json (~-relativ) und ein Eintrag in
|
||||
/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit
|
||||
/// claude das Archiv später lesen/scannen darf.
|
||||
pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> {
|
||||
pub(crate) fn set_project_archive_home(project: &str, dir: &str) -> Result<(), String> {
|
||||
let paths = Paths::real();
|
||||
let expanded = expand_home(&paths, dir);
|
||||
if !expanded.is_absolute() {
|
||||
@@ -39,7 +44,7 @@ pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), St
|
||||
fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
|
||||
let contracted = contract_home(&paths, &expanded);
|
||||
let mut cfg = read_project_config_in(&paths, project)?;
|
||||
cfg.archive_dir = Some(contracted.clone());
|
||||
cfg.archive_home = Some(contracted.clone());
|
||||
write_project_config_in(&paths, project, &cfg)?;
|
||||
add_archive_permission(&paths, project, &contracted)
|
||||
}
|
||||
@@ -82,17 +87,33 @@ fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(),
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
}
|
||||
|
||||
/// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ArchiveMeta {
|
||||
/// Unterordner relativ zum Archiv-Home (wird angelegt).
|
||||
pub(crate) folder: Option<String>,
|
||||
/// Einzeiler fürs Frontmatter.
|
||||
pub(crate) description: Option<String>,
|
||||
/// Schlagwörter fürs Frontmatter.
|
||||
pub(crate) tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit
|
||||
/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich
|
||||
/// Frontmatter im Archiv-Home. `dir_override` setzt das Home zugleich
|
||||
/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad.
|
||||
pub(crate) fn archive_panel_content(
|
||||
project: &str,
|
||||
dir_override: Option<&str>,
|
||||
meta: &ArchiveMeta,
|
||||
) -> Result<PathBuf, String> {
|
||||
if let Some(d) = dir_override {
|
||||
set_project_archive_dir(project, d)?;
|
||||
set_project_archive_home(project, d)?;
|
||||
}
|
||||
let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?;
|
||||
let home = require_archive_home(project)?;
|
||||
let dir = match &meta.folder {
|
||||
Some(f) => home.join(check_folder(f)?),
|
||||
None => home,
|
||||
};
|
||||
let text = fs::read_to_string(panel_file(project)).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
return Err("Panel ist leer — nichts zu archivieren".into());
|
||||
@@ -106,15 +127,110 @@ pub(crate) fn archive_panel_content(
|
||||
let (stamp, iso) = utc_stamp(secs);
|
||||
let title = first_line(&text);
|
||||
let path = dir.join(format!("{stamp}-{}.md", slugify(&title)));
|
||||
let doc = format!(
|
||||
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n",
|
||||
title.replace('"', "'"),
|
||||
text.trim_end(),
|
||||
);
|
||||
let doc = format!("{}{}\n", frontmatter(&title, project, &iso, meta), text.trim_end());
|
||||
fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Unterordner-Pfad: relativ, nur normale Komponenten (kein `..`, kein Root).
|
||||
fn check_folder(folder: &str) -> Result<&std::path::Path, String> {
|
||||
let p = std::path::Path::new(folder);
|
||||
let normal = p
|
||||
.components()
|
||||
.all(|c| matches!(c, std::path::Component::Normal(_)));
|
||||
if p.components().next().is_some() && normal {
|
||||
Ok(p)
|
||||
} else {
|
||||
Err(format!("Unterordner muss ein relativer Pfad ohne '..' sein: {folder}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// YAML-Frontmatter des Archiv-Dokuments inklusive optionaler
|
||||
/// description/tags aus den Metadaten. Gegenstück: `parse_frontmatter` unten —
|
||||
/// Schreiber und Leser des Formats leben bewusst im selben Modul.
|
||||
fn frontmatter(title: &str, project: &str, iso: &str, meta: &ArchiveMeta) -> String {
|
||||
let mut fm = format!(
|
||||
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
|
||||
title.replace('"', "'"),
|
||||
);
|
||||
if let Some(d) = &meta.description {
|
||||
fm.push_str(&format!("description: \"{}\"\n", d.replace('"', "'")));
|
||||
}
|
||||
if !meta.tags.is_empty() {
|
||||
let quoted: Vec<String> =
|
||||
meta.tags.iter().map(|t| format!("\"{}\"", t.replace('"', "'"))).collect();
|
||||
fm.push_str(&format!("tags: [{}]\n", quoted.join(", ")));
|
||||
}
|
||||
fm.push_str("---\n\n");
|
||||
fm
|
||||
}
|
||||
|
||||
/// Minimaler Frontmatter-Parser für die selbst geschriebenen Dokumente:
|
||||
/// `key: value`-Zeilen zwischen den beiden `---`-Markern, Anführungszeichen
|
||||
/// um Werte werden entfernt.
|
||||
pub(crate) fn parse_frontmatter(text: &str) -> std::collections::HashMap<String, String> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let Some(rest) = text.strip_prefix("---\n") else {
|
||||
return map;
|
||||
};
|
||||
let Some(end) = rest.find("\n---") else {
|
||||
return map;
|
||||
};
|
||||
for line in rest[..end].lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
map.insert(key.trim().to_string(), unquote(value.trim()).to_string());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Dokument-Rumpf ohne den Frontmatter-Block; führende Leerzeilen entfernt.
|
||||
pub(crate) fn strip_frontmatter(text: &str) -> &str {
|
||||
let Some(rest) = text.strip_prefix("---\n") else {
|
||||
return text;
|
||||
};
|
||||
match rest.find("\n---\n") {
|
||||
Some(end) => rest[end + 5..].trim_start_matches('\n'),
|
||||
None => text,
|
||||
}
|
||||
}
|
||||
|
||||
fn unquote(s: &str) -> &str {
|
||||
s.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.unwrap_or(s)
|
||||
}
|
||||
|
||||
/// Inline-Liste `["a", "b"]` bzw. `[a, b]` in Einzel-Tags zerlegen.
|
||||
pub(crate) fn parse_tag_list(raw: &str) -> Vec<String> {
|
||||
raw
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.split(',')
|
||||
.map(|t| unquote(t.trim()).to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Datei-Stem ohne führenden `YYYY-MM-DD_HHMM-`-Zeitstempel — das Gegenstück
|
||||
/// zum Dateinamen aus `utc_stamp` + `slugify`.
|
||||
pub(crate) fn strip_stamp(stem: &str) -> &str {
|
||||
let bytes = stem.as_bytes();
|
||||
let stamped = bytes.len() > 16
|
||||
&& bytes[..16].iter().enumerate().all(|(i, b)| match i {
|
||||
4 | 7 => *b == b'-',
|
||||
10 => *b == b'_',
|
||||
15 => *b == b'-',
|
||||
_ => b.is_ascii_digit(),
|
||||
});
|
||||
if stamped {
|
||||
&stem[16..]
|
||||
} else {
|
||||
stem
|
||||
}
|
||||
}
|
||||
|
||||
/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile.
|
||||
fn first_line(text: &str) -> String {
|
||||
let mut fallback: Option<&str> = None;
|
||||
@@ -133,7 +249,7 @@ fn first_line(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen).
|
||||
fn slugify(s: &str) -> String {
|
||||
pub(crate) fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
@@ -200,6 +316,55 @@ mod tests {
|
||||
assert_eq!(slugify("###"), "entwurf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_und_strip_roundtrip() {
|
||||
let (stamp, _) = utc_stamp(20_645u64 * 86400);
|
||||
assert_eq!(strip_stamp(&format!("{stamp}-adr-logging")), "adr-logging");
|
||||
assert_eq!(strip_stamp("adr-logging"), "adr-logging");
|
||||
assert_eq!(strip_stamp("2026-07-19-adr"), "2026-07-19-adr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_und_parser_roundtrip() {
|
||||
let meta = ArchiveMeta {
|
||||
folder: None,
|
||||
description: Some("Kurz".into()),
|
||||
tags: vec!["adr".into(), "infra".into()],
|
||||
};
|
||||
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &meta);
|
||||
let map = parse_frontmatter(&fm);
|
||||
assert_eq!(map.get("title").map(String::as_str), Some("Titel"));
|
||||
assert_eq!(map.get("description").map(String::as_str), Some("Kurz"));
|
||||
assert_eq!(parse_tag_list(&map["tags"]), vec!["adr", "infra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_folder_relativ_ohne_punktpunkt() {
|
||||
assert!(check_folder("konzepte/panel").is_ok());
|
||||
assert!(check_folder("../raus").is_err());
|
||||
assert!(check_folder("a/../b").is_err());
|
||||
assert!(check_folder("/absolut").is_err());
|
||||
assert!(check_folder("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_mit_und_ohne_meta() {
|
||||
let leer = ArchiveMeta::default();
|
||||
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &leer);
|
||||
assert!(fm.starts_with("---\ntitle: \"Titel\"\n"));
|
||||
assert!(!fm.contains("description:"));
|
||||
assert!(!fm.contains("tags:"));
|
||||
|
||||
let voll = ArchiveMeta {
|
||||
folder: None,
|
||||
description: Some("Kurz \"zitiert\"".into()),
|
||||
tags: vec!["archiv".into(), "wiki".into()],
|
||||
};
|
||||
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &voll);
|
||||
assert!(fm.contains("description: \"Kurz 'zitiert'\"\n"));
|
||||
assert!(fm.contains("tags: [\"archiv\", \"wiki\"]\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_line_ueberschrift_oder_erste_zeile() {
|
||||
assert_eq!(first_line("\n\n# Titel\n\nText"), "Titel");
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! laufen ausschließlich über crate::platform.
|
||||
|
||||
pub(crate) mod archive;
|
||||
pub(crate) mod archive_index;
|
||||
pub(crate) mod archive_search;
|
||||
pub(crate) mod credentials;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod pool;
|
||||
|
||||
@@ -56,6 +56,33 @@ pub(crate) fn panel_file(project: &str) -> PathBuf {
|
||||
Paths::real().panels_dir().join(format!("{project}.md"))
|
||||
}
|
||||
|
||||
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
|
||||
/// Session-Start geleert). Der Pfad landet als AI_CONTROL_COMMANDS in der
|
||||
/// PTY-Umgebung.
|
||||
pub(crate) fn commands_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{project}.commands.jsonl"))
|
||||
}
|
||||
|
||||
/// Suchtreffer-Datei eines Projekts (JSON, letzter search_archive-Aufruf —
|
||||
/// flüchtig, wird beim Session-Start geleert). Der Pfad landet als
|
||||
/// AI_CONTROL_SEARCH in der PTY-Umgebung.
|
||||
pub(crate) fn search_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{project}.search.json"))
|
||||
}
|
||||
|
||||
/// Wiki-Puffer eines Projekts (JSON, jeweils letzte Wiki-Seite bzw. letztes
|
||||
/// geöffnetes Dokument — flüchtig, wird beim Session-Start geleert). Der Pfad
|
||||
/// landet als AI_CONTROL_WIKI in der PTY-Umgebung.
|
||||
pub(crate) fn wiki_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{project}.wiki.json"))
|
||||
}
|
||||
|
||||
/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert.
|
||||
pub(crate) fn expand_home(paths: &Paths, p: &str) -> PathBuf {
|
||||
if p == "~" {
|
||||
|
||||
@@ -151,7 +151,7 @@ pub(crate) fn init_pool_config(
|
||||
let mut settings = serde_json::json!({
|
||||
"promptSuggestionEnabled": false,
|
||||
"awaySummaryEnabled": false,
|
||||
"permissions": { "allow": [PANEL_WRITE_PERMISSION] },
|
||||
"permissions": { "allow": PANEL_PERMISSIONS },
|
||||
});
|
||||
let base = settings.as_object_mut().unwrap();
|
||||
if let Some(obj) = extra.as_object() {
|
||||
@@ -180,9 +180,12 @@ const PANEL_SKILL: &str = include_str!("../../resources/panel-skill.md");
|
||||
/// („text panel"). Bestimmt den Tool-Namespace `mcp__<key>__<tool>`.
|
||||
const PANEL_MCP_SERVER: &str = "text-panel";
|
||||
|
||||
/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
|
||||
/// läuft. Namensschema: `mcp__<server>__<tool>`.
|
||||
const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel";
|
||||
/// Freigaben für die Panel-MCP-Tools — damit die Aufrufe ohne Rückfrage
|
||||
/// laufen. Namensschema: `mcp__<server>__<tool>`.
|
||||
const PANEL_PERMISSIONS: [&str; 2] = [
|
||||
"mcp__text-panel__write_panel",
|
||||
"mcp__text-panel__write_commands",
|
||||
];
|
||||
|
||||
/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine
|
||||
/// evtl. ältere, tee-basierte Fassung).
|
||||
@@ -223,13 +226,14 @@ fn ensure_panel_permission(pool_dir: &std::path::Path) {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let before = allow.len();
|
||||
let before = allow.clone();
|
||||
allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
|
||||
let has = allow.iter().any(|e| e.as_str() == Some(PANEL_WRITE_PERMISSION));
|
||||
if !has {
|
||||
allow.push(serde_json::json!(PANEL_WRITE_PERMISSION));
|
||||
for perm in PANEL_PERMISSIONS {
|
||||
if !allow.iter().any(|e| e.as_str() == Some(perm)) {
|
||||
allow.push(serde_json::json!(perm));
|
||||
}
|
||||
}
|
||||
if has && allow.len() == before {
|
||||
if *allow == before {
|
||||
return; // nichts geändert
|
||||
}
|
||||
if let Ok(out) = serde_json::to_string_pretty(&v) {
|
||||
|
||||
@@ -29,13 +29,16 @@ pub(crate) struct ProjectConfig {
|
||||
pub(crate) pool: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
|
||||
pub(crate) terminal: TerminalConfig,
|
||||
/// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert).
|
||||
/// Archiv-Home des Projekts: Zielordner fürs Archivieren von Panel-Entwürfen
|
||||
/// (~-relativ gespeichert). Liest den früheren Key `archiveDir` weiterhin ein,
|
||||
/// geschrieben wird nur noch `archiveHome`.
|
||||
#[serde(
|
||||
default,
|
||||
rename = "archiveDir",
|
||||
rename = "archiveHome",
|
||||
alias = "archiveDir",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub(crate) archive_dir: Option<String>,
|
||||
pub(crate) archive_home: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
@@ -223,7 +226,7 @@ pub(crate) fn create_project_full_in(
|
||||
|
||||
reg.insert(name.to_string(), dir.clone());
|
||||
save_registry(paths, ®)?;
|
||||
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None };
|
||||
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_home: None };
|
||||
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
|
||||
write_project_config_in(paths, name, &cfg)?;
|
||||
}
|
||||
@@ -431,7 +434,7 @@ pub(crate) fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result
|
||||
pub(crate) fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
|
||||
let mut cfg = read_project_config_in(paths, project)?;
|
||||
cfg.pool = None;
|
||||
if cfg.terminal.is_empty() && cfg.archive_dir.is_none() {
|
||||
if cfg.terminal.is_empty() && cfg.archive_home.is_none() {
|
||||
let cfg_path = project_config_path(paths, project)?;
|
||||
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user