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
+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,