Archiv-Navigator: Baum nur Ordner + flache Inhaltsliste (Zeilen: öffnen/umbenennen/löschen), Ordner/Dokument anlegen (Dateiname ohne Stempel, Datum im Frontmatter), Write-back geöffneter Archiv-Dokumente über Quell-Verknüpfung; Ablösen übergibt aktiven Tab
This commit is contained in:
@@ -275,7 +275,12 @@ fn check_folder(folder: &str) -> Result<&std::path::Path, String> {
|
||||
/// 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 {
|
||||
pub(crate) 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('"', "'"),
|
||||
@@ -398,6 +403,16 @@ pub(crate) fn slugify(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktuelle UTC-Zeit als ISO-String — Frontmatter-`created` neu angelegter
|
||||
/// Dokumente.
|
||||
pub(crate) fn utc_now_iso() -> Result<String, String> {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_secs();
|
||||
Ok(utc_stamp(secs).1)
|
||||
}
|
||||
|
||||
/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`).
|
||||
fn utc_stamp(secs: u64) -> (String, String) {
|
||||
let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60);
|
||||
|
||||
@@ -8,9 +8,7 @@ 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,
|
||||
};
|
||||
use crate::domain::archive::{parse_frontmatter, parse_tag_list, slugify, strip_stamp};
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub(crate) struct Doc {
|
||||
@@ -22,6 +20,9 @@ pub(crate) struct Doc {
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) tags: Vec<String>,
|
||||
/// Frontmatter-`created` (ISO) — Datumsquelle für Dokumente ohne
|
||||
/// Zeitstempel im Dateinamen.
|
||||
pub(crate) created: Option<String>,
|
||||
/// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel).
|
||||
pub(crate) links: Vec<String>,
|
||||
/// Namen der Dokumente, die per Wikilink hierher zeigen.
|
||||
@@ -107,6 +108,7 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
|
||||
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(),
|
||||
created: fm.get("created").cloned(),
|
||||
links: wikilinks(&text),
|
||||
backlinks: Vec::new(),
|
||||
name,
|
||||
@@ -134,9 +136,6 @@ pub(crate) struct WikiPage {
|
||||
pub(crate) tag: Option<String>,
|
||||
pub(crate) total: usize,
|
||||
pub(crate) tags: Vec<TagCount>,
|
||||
/// Die neuesten Dokumente über alle Ordner; leer, wenn es nur einen
|
||||
/// Ordner gibt (dann wäre die Sektion eine Dublette der Liste).
|
||||
pub(crate) recent: Vec<WikiDocEntry>,
|
||||
pub(crate) folders: Vec<WikiFolder>,
|
||||
}
|
||||
|
||||
@@ -155,6 +154,9 @@ pub(crate) struct WikiFolder {
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiDocEntry {
|
||||
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
|
||||
/// Zeilen-Aktionen (umbenennen, löschen).
|
||||
pub(crate) relpath: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
@@ -179,6 +181,16 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
};
|
||||
let mut folders: std::collections::BTreeMap<String, Vec<&Doc>> =
|
||||
std::collections::BTreeMap::new();
|
||||
// Die Übersicht führt alle Ordner, auch dokumentlose — der Navigator zeigt
|
||||
// frisch angelegte Ordner sonst nicht. Schlagwort-Seiten bleiben auf die
|
||||
// Treffer-Ordner beschränkt.
|
||||
if tag.is_none() {
|
||||
let mut dirs = Vec::new();
|
||||
folder_paths(home, home, &mut dirs)?;
|
||||
for d in dirs {
|
||||
folders.entry(d).or_default();
|
||||
}
|
||||
}
|
||||
for doc in &selected {
|
||||
let folder = Path::new(&doc.relpath)
|
||||
.parent()
|
||||
@@ -186,17 +198,6 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
.unwrap_or_default();
|
||||
folders.entry(folder).or_default().push(doc);
|
||||
}
|
||||
let recent = if folders.len() > 1 {
|
||||
// Neueste zuerst über alle Ordner — der Zeitstempel-Stem sortiert
|
||||
// chronologisch, verglichen wird der Dateiname.
|
||||
let mut all = selected.clone();
|
||||
all.sort_by(|a, b| {
|
||||
Path::new(&b.relpath).file_name().cmp(&Path::new(&a.relpath).file_name())
|
||||
});
|
||||
all.into_iter().take(5).map(doc_entry).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(WikiPage {
|
||||
kind: "page",
|
||||
home: home.display().to_string(),
|
||||
@@ -206,7 +207,6 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
.into_iter()
|
||||
.map(|(name, count)| TagCount { name: name.to_string(), count })
|
||||
.collect(),
|
||||
recent,
|
||||
folders: folders
|
||||
.into_iter()
|
||||
.map(|(name, mut list)| {
|
||||
@@ -218,14 +218,41 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
})
|
||||
}
|
||||
|
||||
/// Alle Ordner-Relpaths unterhalb von `dir`, rekursiv; versteckte Einträge
|
||||
/// (Punkt-Präfix) bleiben außen vor — wie beim Dokument-Scan.
|
||||
fn folder_paths(home: &Path, dir: &Path, out: &mut Vec<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();
|
||||
if entry.file_name().to_string_lossy().starts_with('.') || !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
out.push(path.strip_prefix(home).unwrap().display().to_string());
|
||||
folder_paths(home, &path, out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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());
|
||||
// Zeitstempel im Dateinamen, sonst der Frontmatter-`created`-Tag —
|
||||
// im Navigator angelegte Dokumente tragen das Datum nur dort.
|
||||
let date = (stem != doc.name)
|
||||
.then(|| stem[..10].to_string())
|
||||
.or_else(|| {
|
||||
doc
|
||||
.created
|
||||
.as_ref()
|
||||
.filter(|c| c.len() >= 10)
|
||||
.map(|c| c[..10].to_string())
|
||||
});
|
||||
WikiDocEntry {
|
||||
relpath: doc.relpath.clone(),
|
||||
name: doc.name.clone(),
|
||||
title: doc.title.clone(),
|
||||
description: doc.description.clone(),
|
||||
@@ -235,37 +262,16 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
/// Löst ein Wikilink-Ziel (Name, Titel oder Datei-Stem) gegen das Archiv auf
|
||||
/// und liefert den relpath des Dokuments.
|
||||
pub(crate) fn resolve_doc(home: &Path, target: &str) -> Result<String, String> {
|
||||
let docs = scan_archive(home)?;
|
||||
let want = slugify(target);
|
||||
let (doc, body) = pairs
|
||||
docs
|
||||
.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(),
|
||||
})
|
||||
.find(|d| matches(d, &want))
|
||||
.map(|d| d.relpath.clone())
|
||||
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))
|
||||
}
|
||||
|
||||
/// Alle `[[ziel]]`-Vorkommen im Text, in Dokumentreihenfolge; bei
|
||||
@@ -358,14 +364,38 @@ mod tests {
|
||||
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!(adr.relpath, "2026-07-19_1000-adr-logging.md");
|
||||
assert_eq!(page.folders[1].name, "konzepte");
|
||||
assert_eq!(page.folders[1].docs[0].name, "notiz-deploy");
|
||||
// Zwei Ordner → Zuletzt-Sektion, neuestes Dokument zuerst, mit
|
||||
// Backlink-Zähler aus dem Scan.
|
||||
let recent: Vec<&str> = page.recent.iter().map(|d| d.name.as_str()).collect();
|
||||
assert_eq!(recent, vec!["notiz-deploy", "adr-logging"]);
|
||||
assert_eq!(page.recent[0].backlinks, 1);
|
||||
assert_eq!(page.recent[1].backlinks, 0);
|
||||
assert_eq!(page.folders[1].docs[0].backlinks, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leere_ordner_und_created_datum() {
|
||||
let home = archiv();
|
||||
fs::create_dir_all(home.join("leer/unter")).unwrap();
|
||||
write(
|
||||
&home,
|
||||
"notiz-plain.md",
|
||||
"---\ntitle: \"Plain\"\ncreated: 2026-07-23T10:00:00Z\n---\n\nText.\n",
|
||||
);
|
||||
let page = archive_page(&home, None).unwrap();
|
||||
let names: Vec<&str> = page.folders.iter().map(|f| f.name.as_str()).collect();
|
||||
// Dokumentlose Ordner erscheinen (Navigator), versteckte weiterhin nicht.
|
||||
assert!(names.contains(&"leer"));
|
||||
assert!(names.contains(&"leer/unter"));
|
||||
assert!(!names.iter().any(|n| n.starts_with(".versteckt")));
|
||||
// Ohne Zeitstempel im Namen kommt das Datum aus dem Frontmatter-created.
|
||||
let plain = page
|
||||
.folders
|
||||
.iter()
|
||||
.find(|f| f.name.is_empty())
|
||||
.unwrap()
|
||||
.docs
|
||||
.iter()
|
||||
.find(|d| d.name == "notiz-plain")
|
||||
.unwrap();
|
||||
assert_eq!(plain.date.as_deref(), Some("2026-07-23"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -381,20 +411,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_doc_mit_rumpf_und_backlinks() {
|
||||
fn resolve_ueber_name_titel_und_stem() {
|
||||
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"
|
||||
resolve_doc(&home, "Notiz Deploy").unwrap(),
|
||||
"konzepte/2026-07-19_1005-notiz-deploy.md"
|
||||
);
|
||||
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());
|
||||
assert_eq!(
|
||||
resolve_doc(&home, "2026-07-19_1000-adr-logging").unwrap(),
|
||||
"2026-07-19_1000-adr-logging.md"
|
||||
);
|
||||
assert!(resolve_doc(&home, "fehlt").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Dokument- und Ordner-Operationen im Archiv-Baum (Baum-Navigation im
|
||||
//! Panel): verschieben, umbenennen, löschen. Alle Pfade sind relativ zum
|
||||
//! Archiv-Home und strikt darauf begrenzt — die relpaths kommen aus dem
|
||||
//! Webview, ein Traversal-Bestandteil bricht laut ab.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::domain::archive::{
|
||||
frontmatter, slugify, strip_frontmatter, strip_stamp, utc_now_iso, ArchiveMeta,
|
||||
};
|
||||
|
||||
/// Relativer Pfad ohne Traversal: nur normale Komponenten, nichts Absolutes.
|
||||
fn checked_rel(rel: &str) -> Result<(), String> {
|
||||
if rel.is_empty() {
|
||||
return Err("leerer Pfad".into());
|
||||
}
|
||||
for c in Path::new(rel).components() {
|
||||
match c {
|
||||
Component::Normal(_) => {}
|
||||
_ => return Err(format!("unzulässiger Pfad: {rel}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Vorhandenes Archiv-Dokument zum relpath.
|
||||
fn doc_path(home: &Path, relpath: &str) -> Result<PathBuf, String> {
|
||||
checked_rel(relpath)?;
|
||||
if !relpath.ends_with(".md") {
|
||||
return Err(format!("kein Markdown-Dokument: {relpath}"));
|
||||
}
|
||||
let path = home.join(relpath);
|
||||
if !path.is_file() {
|
||||
return Err(format!("Dokument nicht gefunden: {relpath}"));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Ziel für rename: darf noch nicht existieren — stilles Überschreiben wäre
|
||||
/// Datenverlust.
|
||||
fn fresh_target(target: &Path, home: &Path) -> Result<(), String> {
|
||||
if target.exists() {
|
||||
return Err(format!(
|
||||
"Ziel existiert bereits: {}",
|
||||
target.strip_prefix(home).unwrap_or(target).display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verschiebt ein Dokument in einen anderen Ordner (leer = Wurzel); der
|
||||
/// Zielordner entsteht bei Bedarf. Liefert den neuen relpath.
|
||||
pub(crate) fn move_doc(home: &Path, relpath: &str, folder: &str) -> Result<String, String> {
|
||||
let src = doc_path(home, relpath)?;
|
||||
if !folder.is_empty() {
|
||||
checked_rel(folder)?;
|
||||
}
|
||||
let file_name = src.file_name().unwrap();
|
||||
let dir = home.join(folder);
|
||||
let target = dir.join(file_name);
|
||||
if target == src {
|
||||
return Ok(relpath.to_string());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
/// Benennt ein Dokument um: neuer Name als Slug hinter dem (erhaltenen)
|
||||
/// Zeitstempel. Liefert den neuen relpath.
|
||||
pub(crate) fn rename_doc(home: &Path, relpath: &str, name: &str) -> Result<String, String> {
|
||||
let src = doc_path(home, relpath)?;
|
||||
let stem = src.file_stem().unwrap().to_string_lossy().to_string();
|
||||
let stamp = &stem[..stem.len() - strip_stamp(&stem).len()];
|
||||
let target = src.with_file_name(format!("{stamp}{}.md", slugify(name)));
|
||||
if target == src {
|
||||
return Ok(relpath.to_string());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn delete_doc(home: &Path, relpath: &str) -> Result<(), String> {
|
||||
let path = doc_path(home, relpath)?;
|
||||
fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Legt einen (auch leeren) Ordner unterhalb des Archiv-Home an.
|
||||
pub(crate) fn create_folder(home: &Path, folder: &str) -> Result<(), String> {
|
||||
checked_rel(folder)?;
|
||||
let dir = home.join(folder);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
}
|
||||
|
||||
/// Legt ein leeres Dokument an: `<slug>.md` im Ordner (leer = Wurzel), das
|
||||
/// Datum steht in der Frontmatter (`created`), nicht im Dateinamen. Liefert
|
||||
/// den relpath.
|
||||
pub(crate) fn create_doc(
|
||||
home: &Path,
|
||||
folder: &str,
|
||||
name: &str,
|
||||
project_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Dokumentname fehlt".into());
|
||||
}
|
||||
if !folder.is_empty() {
|
||||
checked_rel(folder)?;
|
||||
}
|
||||
let dir = home.join(folder);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let target = dir.join(format!("{}.md", slugify(name)));
|
||||
let fm = frontmatter(name, project_name, &utc_now_iso()?, &ArchiveMeta::default());
|
||||
let mut file = match fs::OpenOptions::new().write(true).create_new(true).open(&target) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(format!(
|
||||
"Ziel existiert bereits: {}",
|
||||
target.strip_prefix(home).unwrap_or(&target).display()
|
||||
))
|
||||
}
|
||||
Err(e) => return Err(format!("{}: {e}", target.display())),
|
||||
};
|
||||
std::io::Write::write_all(&mut file, fm.as_bytes())
|
||||
.map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
/// Schreibt den Panel-Body zurück in ein Archiv-Dokument (Quell-Verknüpfung
|
||||
/// des Dokument-Tabs): der Frontmatter-Block der Datei bleibt, der Rumpf wird
|
||||
/// ersetzt.
|
||||
pub(crate) fn write_body(path: &Path, text: &str) -> Result<(), String> {
|
||||
let doc = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let head = doc.len() - strip_frontmatter(&doc).len();
|
||||
let out = format!("{}{}\n", &doc[..head], text.trim_end());
|
||||
fs::write(path, out).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Verschiebt bzw. benennt einen ganzen Ordner um (fs::rename des
|
||||
/// Verzeichnisses); Eltern des Ziels entstehen bei Bedarf.
|
||||
pub(crate) fn move_folder(home: &Path, folder: &str, to: &str) -> Result<(), String> {
|
||||
checked_rel(folder)?;
|
||||
checked_rel(to)?;
|
||||
let src = home.join(folder);
|
||||
if !src.is_dir() {
|
||||
return Err(format!("Ordner nicht gefunden: {folder}"));
|
||||
}
|
||||
let target = home.join(to);
|
||||
if target == src {
|
||||
return Ok(());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::testutil::tmp_paths;
|
||||
|
||||
fn archiv() -> 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"), "a").unwrap();
|
||||
fs::write(home.join("konzepte/2026-07-19_1005-notiz-deploy.md"), "b").unwrap();
|
||||
home
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verschieben_legt_ordner_an_und_liefert_relpath() {
|
||||
let home = archiv();
|
||||
let rel = move_doc(&home, "2026-07-19_1000-adr-logging.md", "adr/2026").unwrap();
|
||||
assert_eq!(rel, "adr/2026/2026-07-19_1000-adr-logging.md");
|
||||
assert!(home.join(&rel).is_file());
|
||||
|
||||
// In die Wurzel zurück: leerer Zielordner.
|
||||
let rel = move_doc(&home, &rel, "").unwrap();
|
||||
assert_eq!(rel, "2026-07-19_1000-adr-logging.md");
|
||||
|
||||
// Gleiches Ziel ist ein No-Op, kein Fehler.
|
||||
assert_eq!(move_doc(&home, &rel, "").unwrap(), rel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verschieben_ueberschreibt_nicht() {
|
||||
let home = archiv();
|
||||
fs::write(home.join("konzepte/2026-07-19_1000-adr-logging.md"), "alt").unwrap();
|
||||
let err = move_doc(&home, "2026-07-19_1000-adr-logging.md", "konzepte").unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_und_fremde_pfade_brechen_ab() {
|
||||
let home = archiv();
|
||||
assert!(move_doc(&home, "../2026-07-19_1000-adr-logging.md", "x").is_err());
|
||||
assert!(move_doc(&home, "2026-07-19_1000-adr-logging.md", "../raus").is_err());
|
||||
assert!(move_doc(&home, "/etc/passwd.md", "x").is_err());
|
||||
assert!(delete_doc(&home, "konzepte").is_err()); // kein .md
|
||||
assert!(move_folder(&home, "konzepte", "../raus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn umbenennen_erhaelt_stempel_und_sluggt() {
|
||||
let home = archiv();
|
||||
let rel =
|
||||
rename_doc(&home, "konzepte/2026-07-19_1005-notiz-deploy.md", "Deploy Nötiz!").unwrap();
|
||||
assert_eq!(rel, "konzepte/2026-07-19_1005-deploy-noetiz.md");
|
||||
assert!(home.join(&rel).is_file());
|
||||
|
||||
// Ohne Stempel bleibt der Name pur.
|
||||
fs::write(home.join("plain.md"), "c").unwrap();
|
||||
assert_eq!(rename_doc(&home, "plain.md", "Neu").unwrap(), "neu.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anlegen_ordner_und_dokument() {
|
||||
let home = archiv();
|
||||
create_folder(&home, "notizen/2026").unwrap();
|
||||
assert!(home.join("notizen/2026").is_dir());
|
||||
assert!(create_folder(&home, "../raus").is_err());
|
||||
|
||||
let rel = create_doc(&home, "notizen/2026", "Deploy Nötiz!", "proj").unwrap();
|
||||
assert_eq!(rel, "notizen/2026/deploy-noetiz.md");
|
||||
let text = fs::read_to_string(home.join(&rel)).unwrap();
|
||||
assert!(text.starts_with("---\ntitle: \"Deploy Nötiz!\"\n"));
|
||||
assert!(text.contains("created: "));
|
||||
|
||||
// In der Wurzel, gleicher Name kollidiert laut, leerer Name bricht ab.
|
||||
assert_eq!(create_doc(&home, "", "Plain", "proj").unwrap(), "plain.md");
|
||||
let err = create_doc(&home, "notizen/2026", "Deploy Nötiz!", "proj").unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
assert!(create_doc(&home, "", " ", "proj").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_body_ersetzt_rumpf_und_erhaelt_frontmatter() {
|
||||
let home = archiv();
|
||||
let path = home.join("mit-fm.md");
|
||||
fs::write(&path, "---\ntitle: \"T\"\n---\n\nalter Text\n").unwrap();
|
||||
write_body(&path, "neuer Text").unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).unwrap(),
|
||||
"---\ntitle: \"T\"\n---\n\nneuer Text\n"
|
||||
);
|
||||
|
||||
// Ohne Frontmatter wird die Datei komplett ersetzt.
|
||||
let plain = home.join("ohne-fm.md");
|
||||
fs::write(&plain, "alt").unwrap();
|
||||
write_body(&plain, "neu").unwrap();
|
||||
assert_eq!(fs::read_to_string(&plain).unwrap(), "neu\n");
|
||||
|
||||
assert!(write_body(&home.join("fehlt.md"), "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loeschen_und_ordner_verschieben() {
|
||||
let home = archiv();
|
||||
delete_doc(&home, "2026-07-19_1000-adr-logging.md").unwrap();
|
||||
assert!(!home.join("2026-07-19_1000-adr-logging.md").exists());
|
||||
|
||||
move_folder(&home, "konzepte", "notizen/2026").unwrap();
|
||||
assert!(home.join("notizen/2026/2026-07-19_1005-notiz-deploy.md").is_file());
|
||||
assert!(!home.join("konzepte").exists());
|
||||
|
||||
let err = move_folder(&home, "fehlt", "x").unwrap_err();
|
||||
assert!(err.contains("nicht gefunden"));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub(crate) mod archive;
|
||||
pub(crate) mod archive_index;
|
||||
pub(crate) mod archive_ops;
|
||||
pub(crate) mod archive_search;
|
||||
pub(crate) mod credentials;
|
||||
pub(crate) mod modules;
|
||||
|
||||
@@ -71,6 +71,18 @@ pub(crate) fn panel_file(project: &str) -> PathBuf {
|
||||
Paths::real().panels_dir().join(format!("{}.md", checked(project)))
|
||||
}
|
||||
|
||||
/// Quell-Verknüpfung des Panel-Inhalts: absoluter Pfad des Archiv-Dokuments,
|
||||
/// aus dem der Dokument-Tab gerade geladen ist. Solange sie existiert,
|
||||
/// schreibt jeder Editor-Commit den Body dorthin zurück; ein frischer Entwurf
|
||||
/// (write_panel, Session-Start) entfernt sie. Liegt neben der Panel-Datei als
|
||||
/// `<panel>.source`, damit der MCP-Server sie aus AI_CONTROL_PANEL ableiten
|
||||
/// kann.
|
||||
pub(crate) fn panel_source_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{}.md.source", checked(project)))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
Reference in New Issue
Block a user