From 2251a1d45f594526f2b19fe2bf6c433a44d288d3 Mon Sep 17 00:00:00 2001 From: marcus hinz Date: Sun, 26 Jul 2026 17:53:19 +0200 Subject: [PATCH] =?UTF-8?q?Technische=20ID=20als=20Identit=C3=A4t=20der=20?= =?UTF-8?q?Notizen;=20Pfad=20wird=20zur=20Eigenschaft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ), 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. --- src-tauri/build.rs | 5 +- src-tauri/capabilities/panel.json | 5 +- src-tauri/capabilities/terminal.json | 5 +- .../autogenerated/archive_folders.toml | 11 + .../autogenerated/archive_move.toml | 11 - .../autogenerated/archive_move_folder.toml | 11 - .../autogenerated/archive_rename.toml | 11 - .../autogenerated/panel_save_as.toml | 11 + src-tauri/src/app.rs | 5 +- src-tauri/src/commands.rs | 19 +- src-tauri/src/domain/archive.rs | 7 +- src-tauri/src/domain/archive_index.rs | 59 +++- src-tauri/src/domain/archive_ops.rs | 91 +++--- src-tauri/src/domain/archive_search.rs | 18 +- src-tauri/src/terminal.rs | 118 +++++--- src/archive-form.test.ts | 115 +++++++- src/archive-form.ts | 264 ++++++++++++++++-- src/messages.ts | 30 +- src/modules/archive.ts | 19 +- src/panel-tiles.css | 152 +++++++--- src/panel.ts | 85 ++++-- src/search-view.test.ts | 3 +- src/search-view.ts | 5 +- src/terminal.ts | 89 ++++-- src/wiki-view.test.ts | 66 ++--- src/wiki-view.ts | 172 ++++++------ 26 files changed, 984 insertions(+), 403 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/archive_folders.toml delete mode 100644 src-tauri/permissions/autogenerated/archive_move.toml delete mode 100644 src-tauri/permissions/autogenerated/archive_move_folder.toml delete mode 100644 src-tauri/permissions/autogenerated/archive_rename.toml create mode 100644 src-tauri/permissions/autogenerated/panel_save_as.toml diff --git a/src-tauri/build.rs b/src-tauri/build.rs index cb49bb1..fb3bf9b 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -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", diff --git a/src-tauri/capabilities/panel.json b/src-tauri/capabilities/panel.json index a206c39..e9e9a2c 100644 --- a/src-tauri/capabilities/panel.json +++ b/src-tauri/capabilities/panel.json @@ -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", diff --git a/src-tauri/capabilities/terminal.json b/src-tauri/capabilities/terminal.json index b98c0e2..becb7d1 100644 --- a/src-tauri/capabilities/terminal.json +++ b/src-tauri/capabilities/terminal.json @@ -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", diff --git a/src-tauri/permissions/autogenerated/archive_folders.toml b/src-tauri/permissions/autogenerated/archive_folders.toml new file mode 100644 index 0000000..a6a85bb --- /dev/null +++ b/src-tauri/permissions/autogenerated/archive_folders.toml @@ -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"] diff --git a/src-tauri/permissions/autogenerated/archive_move.toml b/src-tauri/permissions/autogenerated/archive_move.toml deleted file mode 100644 index 6c0ba01..0000000 --- a/src-tauri/permissions/autogenerated/archive_move.toml +++ /dev/null @@ -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"] diff --git a/src-tauri/permissions/autogenerated/archive_move_folder.toml b/src-tauri/permissions/autogenerated/archive_move_folder.toml deleted file mode 100644 index 33fbfbb..0000000 --- a/src-tauri/permissions/autogenerated/archive_move_folder.toml +++ /dev/null @@ -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"] diff --git a/src-tauri/permissions/autogenerated/archive_rename.toml b/src-tauri/permissions/autogenerated/archive_rename.toml deleted file mode 100644 index 99ae3c5..0000000 --- a/src-tauri/permissions/autogenerated/archive_rename.toml +++ /dev/null @@ -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"] diff --git a/src-tauri/permissions/autogenerated/panel_save_as.toml b/src-tauri/permissions/autogenerated/panel_save_as.toml new file mode 100644 index 0000000..2a445d5 --- /dev/null +++ b/src-tauri/permissions/autogenerated/panel_save_as.toml @@ -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"] diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 32df977..83aa244 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -311,13 +311,12 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke) -> 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 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 18fd852..fca7c17 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -436,8 +436,25 @@ pub(crate) fn panel_archive_cmd( description: Option, tags: Option>, ) -> Result { + // 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(), }; diff --git a/src-tauri/src/domain/archive.rs b/src-tauri/src/domain/archive.rs index daa1a30..1c5f716 100644 --- a/src-tauri/src/domain/archive.rs +++ b/src-tauri/src/domain/archive.rs @@ -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:")); diff --git a/src-tauri/src/domain/archive_index.rs b/src-tauri/src/domain/archive_index.rs index 44fc037..af14661 100644 --- a/src-tauri/src/domain/archive_index.rs +++ b/src-tauri/src/domain/archive_index.rs @@ -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.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, 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) -> Result<(), String> { +pub(crate) fn folder_paths(home: &Path, dir: &Path, out: &mut Vec) -> 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 { + 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 { diff --git a/src-tauri/src/domain/archive_ops.rs b/src-tauri/src/domain/archive_ops.rs index 3bf3e60..2874a57 100644 --- a/src-tauri/src/domain/archive_ops.rs +++ b/src-tauri/src/domain/archive_ops.rs @@ -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 { - 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, 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()); diff --git a/src-tauri/src/domain/archive_search.rs b/src-tauri/src/domain/archive_search.rs index 261dcad..4a3af19 100644 --- a/src-tauri/src/domain/archive_search.rs +++ b/src-tauri/src/domain/archive_search.rs @@ -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::, _>>().map_err(|e| e.to_string()) @@ -103,16 +110,17 @@ fn build_index(home: &Path) -> Result { 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, diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 5d2eead..1fff9b7 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -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 { +pub fn archive_read(project: String, id: String) -> Result { 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 /// 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, 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 { + 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())?; diff --git a/src/archive-form.test.ts b/src/archive-form.test.ts index 940ab31..8e2b28e 100644 --- a/src/archive-form.test.ts +++ b/src/archive-form.test.ts @@ -5,10 +5,20 @@ import { initArchiveForm } from "./archive-form"; function setup() { document.body.innerHTML = ``; const onSubmit = vi.fn(); - const form = initArchiveForm(document.getElementById("btn")!, onSubmit); + const onSave = vi.fn(); + const folders = vi.fn(() => + Promise.resolve([ + { id: "id-konzepte", path: "konzepte", title: "Konzepte" }, + { id: "id-panel", path: "konzepte/panel", title: "Panel" }, + ]), + ); + const form = initArchiveForm(document.getElementById("btn")!, onSubmit, { + folders, + onSave, + }); const root = document.querySelector(".archive-form")!; const inputs = [...root.querySelectorAll("input")]; - return { form, onSubmit, root, inputs }; + return { form, onSubmit, onSave, folders, root, inputs }; } describe("initArchiveForm", () => { @@ -25,15 +35,18 @@ describe("initArchiveForm", () => { expect(root.hidden).toBe(true); }); - it("liefert getrimmte Meta; Leeres wird undefined, Tags gesplittet", () => { + it("liefert getrimmte Meta; Leeres wird undefined, Tags gesplittet", async () => { const { form, onSubmit, root, inputs } = setup(); form.toggle(); - inputs[0].value = " konzepte/panel "; - inputs[1].value = ""; - inputs[2].value = " adr, infra ,, "; + await new Promise((r) => setTimeout(r)); + root + .querySelector('.archive-browse-row[data-path="id-konzepte"]')! + .click(); + inputs[0].value = ""; + inputs[1].value = " adr, infra ,, "; root.querySelector(".archive-form-submit")!.click(); expect(onSubmit).toHaveBeenCalledWith({ - folder: "konzepte/panel", + folder: "id-konzepte", description: undefined, tags: ["adr", "infra"], }); @@ -45,13 +58,12 @@ describe("initArchiveForm", () => { it("startet nach dem Archivieren wieder leer", () => { const { form, onSubmit, root, inputs } = setup(); form.toggle(); - inputs[0].value = "konzepte/panel"; - inputs[1].value = "Beschreibung A"; - inputs[2].value = "panel, wiki"; + inputs[0].value = "Beschreibung A"; + inputs[1].value = "panel, wiki"; root.querySelector(".archive-form-submit")!.click(); form.toggle(); - expect(inputs.map((i) => i.value)).toEqual(["", "", ""]); + expect(inputs.map((i) => i.value)).toEqual(["", ""]); root.querySelector(".archive-form-submit")!.click(); expect(onSubmit).toHaveBeenLastCalledWith({ folder: undefined, @@ -60,6 +72,87 @@ describe("initArchiveForm", () => { }); }); + it("Baum startet eingeklappt, Pfeil klappt auf, Klick wählt aus", async () => { + const { form, folders, root } = setup(); + form.toggle(); + await new Promise((r) => setTimeout(r)); + expect(folders).toHaveBeenCalled(); + + // Eingeklappt: Wurzel und erste Ebene sichtbar, Unterebene verborgen. + const visible = () => + [...root.querySelectorAll(".archive-browse-row")].filter( + (r) => !r.closest(".archive-kids[hidden]"), + ); + expect(visible().map((r) => r.dataset.path)).toEqual(["", "id-konzepte"]); + + // Logische Sicht: Titel des Knotentexts, Pfad an der Zeile. + const konzepte = visible()[1]; + expect(konzepte.querySelector(".wiki-tree-name")!.textContent).toBe("Konzepte"); + expect(konzepte.title).toBe("konzepte"); + + // Pfeil klappt auf, ohne die Auswahl zu ändern. + konzepte.querySelector(".archive-arrow")!.click(); + expect(visible().map((r) => r.dataset.path)).toEqual(["", "id-konzepte", "id-panel"]); + // Aufklappen ändert die Auswahl nicht. + expect(visible()[0].className).toContain("active"); + + // Klick auf die Zeile wählt aus. + visible()[2].click(); + expect(visible()[2].className).toContain("active"); + expect(visible()[0].className).not.toContain("active"); + }); + + it("Ladefehler des Baums steht im Kasten", async () => { + document.body.innerHTML = ``; + const form = initArchiveForm(document.getElementById("btn")!, vi.fn(), { + folders: () => Promise.reject(new Error("kein Archiv-Ordner gesetzt")), + }); + form.toggle(); + await new Promise((r) => setTimeout(r)); + expect( + document.querySelector(".archive-browse-error")!.textContent, + ).toContain("kein Archiv-Ordner gesetzt"); + }); + + it("Auf Platte legen ruft onSave und schließt, ohne zu archivieren", () => { + const { form, onSave, onSubmit, root } = setup(); + form.toggle(); + root.querySelector(".archive-form-save")!.click(); + expect(onSave).toHaveBeenCalledOnce(); + expect(onSubmit).not.toHaveBeenCalled(); + expect(root.hidden).toBe(true); + }); + + it("Abbrechen schließt ohne Abschicken", () => { + const { form, onSubmit, root } = setup(); + form.toggle(); + root.querySelector(".archive-form-cancel")!.click(); + expect(root.hidden).toBe(true); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("Klick auf den Hintergrund schließt, Klick in die Box nicht", () => { + const { form, onSubmit, root } = setup(); + form.toggle(); + root.querySelector(".wiki-form")!.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true }), + ); + expect(root.hidden).toBe(false); + root.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + expect(root.hidden).toBe(true); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + /// Im Terminal-Fenster liegt der Fokus oft in der Shell — Escape muss auch + /// dann greifen, wenn die Taste nicht im Dialog ankommt. + it("Escape schließt auch von außerhalb des Dialogs", () => { + const { form, onSubmit, root } = setup(); + form.toggle(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(root.hidden).toBe(true); + expect(onSubmit).not.toHaveBeenCalled(); + }); + it("Enter schickt ab, Escape schließt ohne Abschicken", () => { const { form, onSubmit, root } = setup(); form.toggle(); diff --git a/src/archive-form.ts b/src/archive-form.ts index 19c934d..415615a 100644 --- a/src/archive-form.ts +++ b/src/archive-form.ts @@ -1,7 +1,10 @@ -/// Archiv-Formular: klappt unter dem Archiv-Button auf und fragt Ordner, -/// Beschreibung und Schlagwörter ab (alles optional); Enter oder der Button -/// archivieren, Escape schließt. Gemeinsam für das angedockte Panel und das -/// abgelöste Fenster. +/// Archiv-Dialog: wohin archivieren, mit welcher Beschreibung und welchen +/// Schlagwörtern. Modales Popup im Design der Archiv-Ansicht — abgedunkelter +/// Hintergrund, zentrierte Box. Der Zielordner wird im sichtbaren Baum des +/// Archivs gewählt (Wurzel plus alle Ordner, Klick übernimmt); für einen neuen +/// Ordner steht darunter ein Feld, in dem der gewählte Pfad ergänzt wird. +/// Enter archiviert, Escape oder ein Klick auf den Hintergrund schließt. +/// Gemeinsam für das angedockte Panel und das Archiv-Fenster. import { t } from "./messages"; @@ -11,31 +14,224 @@ export interface ArchiveFormMeta { tags: string[]; } +/// Ordner-Knoten des Archivs für den Zielordner-Baum. +export interface ArchiveFolderNode { + /// Technische ID des Knotens — das archivierte Ziel. + id: string; + /// Pfad (nur für den Tooltip). + path: string; + title: string; +} + +export interface ArchiveFormOptions { + /// Vorhandene Archiv-Ordner (Pfad + Titel) für den Zielordner-Baum. + folders?(): Promise; + /// „Auf Platte legen": Entwurf als Datei an frei gewähltem Pfad ablegen. + onSave?(): void; +} + export function initArchiveForm( - anchor: HTMLElement, + _anchor: HTMLElement, onSubmit: (meta: ArchiveFormMeta) => void, + opts: ArchiveFormOptions = {}, ): { toggle(): void } { - function field(placeholder: string): HTMLInputElement { - const i = document.createElement("input"); - i.type = "text"; - i.placeholder = placeholder; - return i; + function field(labelText: string, placeholder: string): HTMLInputElement { + const input = document.createElement("input"); + input.type = "text"; + input.className = "wiki-tree-input"; + input.placeholder = placeholder; + box.append(labelled(labelText, input)); + return input; } + /// Beschriftete Gruppe. `label` nur für echte Eingabefelder: ein