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");
|
||||
|
||||
Reference in New Issue
Block a user