Technische ID als Identität der Notizen; Pfad wird zur Eigenschaft

Bisher war der Dateipfad die Identität einer Notiz: Umbenennen brach
Verweise, und der Pfad tauchte in der Oberfläche auf (Tooltips, Menüpunkte
"Dateiname ändern"/"Ordnerpfad ändern"). Jede Notiz trägt jetzt eine
technische ID im Frontmatter (id: <uuid>), die nie wechselt; der
Systempfad ist nur noch eine Eigenschaft.

- ensure_ids ergänzt fehlende IDs beim Seitenaufbau (wie ensure_node_texts);
  frontmatter() schreibt sie bei jedem Anlegen als erste Zeile.
- Doc, WikiDocEntry, FolderNode und Suchtreffer führen die ID mit;
  resolve_id löst sie auf den aktuellen relpath auf.
- Commands sprechen IDs: archive_read/write/set_title/delete nehmen `id`,
  archive_create_doc/create_folder die ID des Elternknotens plus Namen,
  panel_archive_cmd den Zielknoten als ID.
- Frontend adressiert durchgehend über IDs: Auswahl, Klapp-Zustand,
  Verlauf des Zurück-Knopfs, Wikilink-Sprünge, Suchtreffer-Vormerkung und
  der Zielordner-Baum des Archiv-Dialogs.
- Der Pfad ist aus der Oberfläche verschwunden: die Menüpunkte zum
  Umbenennen von Datei und Ordner samt Commands (archive_rename,
  archive_move, archive_move_folder) sind entfallen — es gibt nur noch den
  Titel, Name und Titel können nicht mehr auseinanderlaufen.

Offen dadurch: Verschieben im Baum fehlt (ToDo: Drag & Drop).

Archiv-Dialog nebenbei überarbeitet: modales Popup im Design der
Archiv-Ansicht mit Abbrechen und zuverlässigem Escape, Zielordner über
einen einklappbaren Baum statt Pfadeingabe, zusätzlicher Knopf "Auf Platte
legen" (panel_save_as); Archivieren öffnet keinen Dateimanager mehr.
This commit is contained in:
marcus hinz
2026-07-26 17:53:19 +02:00
parent 9de58d43e8
commit 2251a1d45f
26 changed files with 984 additions and 403 deletions
+2 -3
View File
@@ -66,13 +66,12 @@ fn main() {
"search_run",
"panel_load",
"wiki_open",
"archive_move",
"archive_rename",
"archive_read",
"archive_write",
"archive_set_title",
"archive_folders",
"panel_save_as",
"archive_delete",
"archive_move_folder",
"archive_create_folder",
"archive_create_doc",
"open_panel_window",
+2 -3
View File
@@ -24,12 +24,11 @@
"allow-archive-create-doc",
"allow-archive-create-folder",
"allow-archive-delete",
"allow-archive-move",
"allow-archive-move-folder",
"allow-archive-rename",
"allow-archive-read",
"allow-archive-write",
"allow-archive-set-title",
"allow-archive-folders",
"allow-panel-save-as",
"allow-buffer-read",
"allow-commands-delete",
"allow-enabled-modules",
+2 -3
View File
@@ -23,12 +23,11 @@
"allow-archive-create-doc",
"allow-archive-create-folder",
"allow-archive-delete",
"allow-archive-move",
"allow-archive-move-folder",
"allow-archive-rename",
"allow-archive-read",
"allow-archive-write",
"allow-archive-set-title",
"allow-archive-folders",
"allow-panel-save-as",
"allow-buffer-read",
"allow-commands-delete",
"allow-enabled-modules",
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-folders"
description = "Enables the archive_folders command without any pre-configured scope."
commands.allow = ["archive_folders"]
[[permission]]
identifier = "deny-archive-folders"
description = "Denies the archive_folders command without any pre-configured scope."
commands.deny = ["archive_folders"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-move"
description = "Enables the archive_move command without any pre-configured scope."
commands.allow = ["archive_move"]
[[permission]]
identifier = "deny-archive-move"
description = "Denies the archive_move command without any pre-configured scope."
commands.deny = ["archive_move"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-move-folder"
description = "Enables the archive_move_folder command without any pre-configured scope."
commands.allow = ["archive_move_folder"]
[[permission]]
identifier = "deny-archive-move-folder"
description = "Denies the archive_move_folder command without any pre-configured scope."
commands.deny = ["archive_move_folder"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-rename"
description = "Enables the archive_rename command without any pre-configured scope."
commands.allow = ["archive_rename"]
[[permission]]
identifier = "deny-archive-rename"
description = "Denies the archive_rename command without any pre-configured scope."
commands.deny = ["archive_rename"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-panel-save-as"
description = "Enables the panel_save_as command without any pre-configured scope."
commands.allow = ["panel_save_as"]
[[permission]]
identifier = "deny-panel-save-as"
description = "Denies the panel_save_as command without any pre-configured scope."
commands.deny = ["panel_save_as"]
+2 -3
View File
@@ -311,13 +311,12 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
terminal::search_run,
terminal::panel_load,
terminal::wiki_open,
terminal::archive_move,
terminal::archive_rename,
terminal::archive_read,
terminal::archive_write,
terminal::archive_set_title,
terminal::archive_folders,
terminal::panel_save_as,
terminal::archive_delete,
terminal::archive_move_folder,
terminal::archive_create_folder,
terminal::archive_create_doc,
terminal::open_panel_window
+18 -1
View File
@@ -436,8 +436,25 @@ pub(crate) fn panel_archive_cmd(
description: Option<String>,
tags: Option<Vec<String>>,
) -> Result<String, String> {
// Der Zielordner kommt als Knoten-ID; erst hier wird daraus ein Pfad.
let folder = match folder.filter(|f| !f.trim().is_empty()) {
Some(id) => {
let home = require_archive_home(&project)?;
let rel = crate::domain::archive_index::resolve_id(&home, &id)?;
let stem = std::path::Path::new(&rel)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
Some(match rel.rsplit_once('/') {
Some((head, _)) => format!("{head}/{stem}"),
None => stem,
})
}
None => None,
};
let meta = ArchiveMeta {
folder: folder.filter(|f| !f.trim().is_empty()),
folder,
description: description.filter(|d| !d.trim().is_empty()),
tags: tags.unwrap_or_default(),
};
+5 -2
View File
@@ -282,7 +282,8 @@ pub(crate) fn frontmatter(
meta: &ArchiveMeta,
) -> String {
let mut fm = format!(
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
"---\nid: {}\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
uuid::Uuid::new_v4(),
title.replace('"', "'"),
);
if let Some(d) = &meta.description {
@@ -542,7 +543,9 @@ mod tests {
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"));
// Erste Zeile ist die technische ID, dann der Titel.
assert!(fm.starts_with("---\nid: "));
assert!(fm.contains("\ntitle: \"Titel\"\n"));
assert!(!fm.contains("description:"));
assert!(!fm.contains("tags:"));
+57 -2
View File
@@ -12,7 +12,10 @@ use crate::domain::archive::{parse_frontmatter, parse_tag_list, slugify, strip_s
#[derive(serde::Serialize, Clone)]
pub(crate) struct Doc {
/// Pfad relativ zum Archiv-Home.
/// Technische ID aus dem Frontmatter — bleibt über Umbenennen und
/// Verschieben hinweg gleich; alle Verweise laufen darüber.
pub(crate) id: String,
/// Pfad relativ zum Archiv-Home (Eigenschaft, keine Identität).
pub(crate) relpath: String,
/// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel.
pub(crate) name: String,
@@ -113,6 +116,7 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
.display()
.to_string();
let doc = Doc {
id: fm.get("id").cloned().unwrap_or_default(),
relpath,
title: fm.get("title").unwrap_or(&name).clone(),
description: fm.get("description").cloned(),
@@ -164,6 +168,8 @@ pub(crate) struct WikiFolder {
#[derive(serde::Serialize)]
pub(crate) struct WikiDocEntry {
/// Technische ID — Adressat aller Aktionen.
pub(crate) id: String,
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
/// Zeilen-Aktionen (umbenennen, löschen).
pub(crate) relpath: String,
@@ -230,9 +236,48 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
})
}
/// Ordner-Knoten für den Zielordner-Baum des Archiv-Dialogs: Pfad plus
/// Anzeige-Titel aus dem Knotentext (`<name>.md`/`.html` daneben) — dieselbe
/// logische Sicht wie die Archiv-Ansicht.
#[derive(serde::Serialize)]
pub(crate) struct FolderNode {
/// Technische ID des Knotentexts — Adressat der Auswahl.
pub(crate) id: String,
/// Pfad relativ zum Archiv-Home (nur Eigenschaft).
pub(crate) path: String,
/// Titel des Knotentexts, sonst der Ordnername.
pub(crate) title: String,
}
pub(crate) fn folder_nodes(home: &Path) -> Result<Vec<FolderNode>, String> {
let mut paths = Vec::new();
folder_paths(home, home, &mut paths)?;
paths.sort();
let mut out = Vec::new();
for path in paths {
let name = Path::new(&path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
// Knotentext daneben: erst .md, dann .html.
let fm = ["md", "html"]
.iter()
.map(|ext| home.join(&path).with_extension(ext))
.find(|p| p.is_file())
.and_then(|p| fs::read_to_string(p).ok())
.map(|text| parse_frontmatter(&text))
.unwrap_or_default();
let title = fm.get("title").cloned().unwrap_or_else(|| name.clone());
let id = fm.get("id").cloned().unwrap_or_default();
out.push(FolderNode { id, path, title });
}
Ok(out)
}
/// 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> {
pub(crate) 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()))?;
@@ -264,6 +309,7 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
.map(|c| c[..10].to_string())
});
WikiDocEntry {
id: doc.id.clone(),
relpath: doc.relpath.clone(),
name: doc.name.clone(),
title: doc.title.clone(),
@@ -275,6 +321,15 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
}
}
/// Löst eine technische ID auf den aktuellen relpath auf.
pub(crate) fn resolve_id(home: &Path, id: &str) -> Result<String, String> {
scan_archive(home)?
.into_iter()
.find(|d| d.id == id)
.map(|d| d.relpath)
.ok_or_else(|| format!("keine Notiz mit ID {id}"))
}
/// 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> {
+47 -44
View File
@@ -49,24 +49,6 @@ fn fresh_target(target: &Path, home: &Path) -> Result<(), String> {
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.
@@ -194,6 +176,49 @@ pub(crate) fn move_folder(home: &Path, folder: &str, to: &str) -> Result<(), Str
Ok(())
}
/// Zweite Invariante des Notizmodells: JEDES Dokument trägt eine technische
/// ID im Frontmatter (`id:`). Sie entsteht einmal und bleibt — Titel,
/// Dateiname und Ordner dürfen sich danach beliebig ändern, Verweise laufen
/// über die ID. Dateien ohne ID (von Hand angelegt, Altbestand) bekommen hier
/// eine.
pub(crate) fn ensure_ids(home: &Path) -> Result<(), String> {
for path in md_files(home)? {
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
if crate::domain::archive::parse_frontmatter(&text).contains_key("id") {
continue;
}
let line = format!("id: {}", uuid::Uuid::new_v4());
let out = match text.strip_prefix("---\n") {
// Frontmatter vorhanden: ID als erste Zeile einfügen.
Some(rest) => format!("---\n{line}\n{rest}"),
// Ohne Frontmatter: einen Block davor setzen, Rumpf bleibt.
None => format!("---\n{line}\n---\n\n{text}"),
};
fs::write(&path, out).map_err(|e| format!("{}: {e}", path.display()))?;
}
Ok(())
}
/// Alle Markdown-Dateien unterhalb von `dir`, rekursiv; versteckte Einträge
/// bleiben außen vor.
fn md_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut out = Vec::new();
for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let path = entry.path();
if path.is_dir() {
out.extend(md_files(&path)?);
} else if name.ends_with(".md") {
out.push(path);
}
}
Ok(out)
}
/// Invariante des Notizmodells: JEDER Knoten besitzt eine Textdatei — der
/// Ordnername ist nur technische Verwaltung, Titel und Inhalt stehen im
/// gleichnamigen Dokument daneben (Wurzel: `index.md`). Läuft vor jedem
@@ -260,35 +285,13 @@ mod tests {
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, "../2026-07-19_1000-adr-logging.md").is_err());
assert!(delete_doc(&home, "/etc/passwd.md").is_err());
assert!(delete_doc(&home, "konzepte").is_err()); // kein .md
assert!(move_folder(&home, "konzepte", "../raus").is_err());
}
@@ -316,7 +319,7 @@ mod tests {
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("\ntitle: \"Deploy Nötiz!\"\n"));
assert!(text.contains("created: "));
// In der Wurzel, gleicher Name kollidiert laut, leerer Name bricht ab.
@@ -383,7 +386,7 @@ mod tests {
assert!(home.join("leer.md").is_file());
assert!(home.join("leer/unter.md").is_file());
let text = fs::read_to_string(home.join("leer.md")).unwrap();
assert!(text.starts_with("---\ntitle: \"leer\"\n"));
assert!(text.contains("\ntitle: \"leer\"\n"));
// Gestempelte Zwillinge zählen als Knotentext — kein Duplikat: konzepte/
// hat keins, bekommt eines; ein zweiter Lauf legt nichts Neues an.
assert!(home.join("konzepte.md").is_file());
+13 -5
View File
@@ -12,7 +12,9 @@ use crate::domain::archive_index::scan_with_bodies;
#[derive(serde::Serialize)]
pub(crate) struct Hit {
/// Pfad relativ zum Archiv-Home.
/// Technische ID der Notiz — Adressat des Treffer-Sprungs.
pub(crate) id: String,
/// Pfad relativ zum Archiv-Home (Anzeige).
pub(crate) relpath: String,
pub(crate) title: String,
/// Textausschnitt um die Fundstelle, Treffer in `**…**`.
@@ -45,13 +47,18 @@ pub(crate) fn search(
let conn = build_index(home)?;
let mut stmt = conn
.prepare(
"SELECT relpath, title, snippet(docs, 5, '**', '**', ' … ', 12) \
"SELECT id, relpath, title, snippet(docs, 6, '**', '**', ' … ', 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)? })
Ok(Hit {
id: row.get(0)?,
relpath: row.get(1)?,
title: row.get(2)?,
snippet: row.get(3)?,
})
})
.map_err(|e| format!("Suchausdruck „{query}“: {e}"))?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
@@ -103,16 +110,17 @@ 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)",
"CREATE VIRTUAL TABLE docs USING fts5(id UNINDEXED, 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)")
.prepare("INSERT INTO docs (id, relpath, name, title, description, tags, body) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)")
.map_err(|e| e.to_string())?;
for (doc, body) in &docs {
insert
.execute(rusqlite::params![
doc.id,
doc.relpath,
doc.name,
doc.title,
+86 -32
View File
@@ -527,6 +527,7 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> {
Some(tag) => {
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
crate::domain::archive_ops::ensure_node_texts(&home, &display)?;
crate::domain::archive_ops::ensure_ids(&home)?;
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(
&home,
(!tag.is_empty()).then_some(tag),
@@ -549,8 +550,9 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> {
/// Liest den Body eines Archiv-Dokuments (ohne Frontmatter) — Inhalt der
/// Notiz-Ansicht im Archiv-Tab.
#[tauri::command]
pub fn archive_read(project: String, relpath: String) -> Result<String, String> {
pub fn archive_read(project: String, id: String) -> Result<String, String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(crate::domain::archive::strip_frontmatter(&text).to_string())
@@ -559,19 +561,60 @@ pub fn archive_read(project: String, relpath: String) -> Result<String, String>
/// Schreibt den Body einer Archiv-Notiz zurück (Bearbeiten im
/// Archiv-Fenster); die Frontmatter der Datei bleibt.
#[tauri::command]
pub fn archive_write(project: String, relpath: String, text: String) -> Result<(), String> {
pub fn archive_write(project: String, id: String, text: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
crate::domain::archive_ops::write_body(&path, &text)
}
/// Ordner-Knoten des Archivs (Pfad + Titel, sortiert) — Zielordner-Baum des
/// Archiv-Dialogs.
#[tauri::command]
pub fn archive_folders(
project: String,
) -> Result<Vec<crate::domain::archive_index::FolderNode>, String> {
let home = crate::domain::archive::require_archive_home(&project)?;
crate::domain::archive_index::folder_nodes(&home)
}
/// Legt den Entwurf als Datei an einem frei gewählten Pfad ab (Speichern-
/// Dialog) — ohne Archiv, ohne Frontmatter: der Panel-Inhalt, wie er ist.
#[tauri::command]
pub fn panel_save_as(project: String, path: String) -> Result<(), String> {
let text = std::fs::read_to_string(panel_file(&project)).map_err(|e| e.to_string())?;
std::fs::write(&path, text).map_err(|e| format!("{path}: {e}"))
}
/// Setzt den Anzeige-Titel einer Notiz (Klick auf den Titel im Archiv);
/// danach die frische Übersicht, damit Baum und Karten den neuen Titel zeigen.
#[tauri::command]
pub fn archive_set_title(project: String, relpath: String, title: String) -> Result<(), String> {
pub fn archive_set_title(project: String, id: String, title: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
crate::domain::archive_ops::set_title(&path, &title)?;
// Der technische Name folgt dem Titel — beide dürfen nicht auseinander
// laufen. Bei einem Knotentext wandert der gleichnamige Ordner mit; die
// Archiv-Wurzel (index.md) behält ihren Namen, sie ist die Konvention.
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
let name = crate::domain::archive::strip_stamp(&stem).to_string();
let slug = crate::domain::archive::slugify(&title);
if name != slug && name != "index" {
let dir = path.with_file_name(&name);
if dir.is_dir() {
let old_rel = dir.strip_prefix(&home).unwrap().display().to_string();
let new_rel = match old_rel.rsplit_once('/') {
Some((parent, _)) => format!("{parent}/{slug}"),
None => slug.clone(),
};
crate::domain::archive_ops::move_folder(&home, &old_rel, &new_rel)?;
relink_folder(&project, &home.join(&old_rel), &home.join(&new_rel))?;
} else {
let new_rel = crate::domain::archive_ops::rename_doc(&home, &relpath, &title)?;
relink(&project, &path, Some(&home.join(new_rel)))?;
}
}
wiki_refresh_page(&project, &home)
}
@@ -580,57 +623,66 @@ pub fn archive_set_title(project: String, relpath: String, title: String) -> Res
fn wiki_refresh_page(project: &str, home: &std::path::Path) -> Result<(), String> {
let display = crate::domain::project::display_name_in(&Paths::real(), project)?;
crate::domain::archive_ops::ensure_node_texts(home, &display)?;
crate::domain::archive_ops::ensure_ids(home)?;
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(home, None)?)
.map_err(|e| e.to_string())?;
std::fs::write(wiki_file(project), json).map_err(|e| e.to_string())
}
/// Verschiebt ein Archiv-Dokument in einen anderen Ordner (leer = Wurzel).
#[tauri::command]
pub fn archive_move(project: String, relpath: String, folder: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let old = home.join(&relpath);
let new_rel = crate::domain::archive_ops::move_doc(&home, &relpath, &folder)?;
relink(&project, &old, Some(&home.join(new_rel)))?;
wiki_refresh_page(&project, &home)
}
/// Benennt ein Archiv-Dokument um (Slug hinter erhaltenem Zeitstempel).
#[tauri::command]
pub fn archive_rename(project: String, relpath: String, name: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let old = home.join(&relpath);
let new_rel = crate::domain::archive_ops::rename_doc(&home, &relpath, &name)?;
relink(&project, &old, Some(&home.join(new_rel)))?;
wiki_refresh_page(&project, &home)
}
/// Löscht ein Archiv-Dokument; danach zeigt das Wiki die Übersicht.
#[tauri::command]
pub fn archive_delete(project: String, relpath: String) -> Result<(), String> {
pub fn archive_delete(project: String, id: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
crate::domain::archive_ops::delete_doc(&home, &relpath)?;
relink(&project, &home.join(&relpath), None)?;
wiki_refresh_page(&project, &home)
}
/// Verschiebt/benennt einen Archiv-Ordner um; danach Übersicht.
#[tauri::command]
pub fn archive_move_folder(project: String, folder: String, to: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
crate::domain::archive_ops::move_folder(&home, &folder, &to)?;
relink_folder(&project, &home.join(&folder), &home.join(&to))?;
wiki_refresh_page(&project, &home)
}
/// Legt einen Ordner im Archiv an (Plus im Baum); danach Übersicht.
#[tauri::command]
pub fn archive_create_folder(project: String, folder: String) -> Result<(), String> {
pub fn archive_create_folder(project: String, parent: String, name: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let folder = join_under(&home, &parent, &name)?;
crate::domain::archive_ops::create_folder(&home, &folder)?;
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
crate::domain::archive_ops::ensure_node_texts(&home, &display)?;
crate::domain::archive_ops::ensure_ids(&home)?;
wiki_refresh_page(&project, &home)
}
/// Pfad eines neuen Kindes unterhalb des Knotens `parent` (ID; leer =
/// Wurzel) — der Name wird als Slug angehängt.
fn join_under(
home: &std::path::Path,
parent: &str,
name: &str,
) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Name fehlt".into());
}
let slug = crate::domain::archive::slugify(name);
if parent.is_empty() {
return Ok(slug);
}
// Der Zielordner ist der Ordner neben dem Knotentext des Elternteils.
let rel = crate::domain::archive_index::resolve_id(home, parent)?;
let stem = std::path::Path::new(&rel)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let dir = match rel.rsplit_once('/') {
Some((head, _)) => format!("{head}/{stem}"),
None => stem,
};
Ok(format!("{dir}/{slug}"))
}
/// Legt ein leeres Dokument an (Plus im Listenkopf) und öffnet es im
/// Dokument-Tab: leerer Dokument-Puffer plus Quell-Verknüpfung — das
/// Getippte landet über `panel_set` in der Archiv-Datei. Die Übersicht
@@ -638,9 +690,11 @@ pub fn archive_create_folder(project: String, folder: String) -> Result<(), Stri
/// frisch (zwei konkurrierende Puffer-Events würden sonst um den aktiven Tab
/// rennen).
#[tauri::command]
pub fn archive_create_doc(project: String, folder: String, name: String) -> Result<(), String> {
pub fn archive_create_doc(project: String, parent: String, name: String) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?;
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
let rel_new = join_under(&home, &parent, &name)?;
let folder = rel_new.rsplit_once('/').map(|(h, _)| h.to_string()).unwrap_or_default();
let rel = crate::domain::archive_ops::create_doc(&home, &folder, &name, &display)?;
let path = home.join(rel);
std::fs::write(panel_file(&project), "").map_err(|e| e.to_string())?;