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:
+2
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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:"));
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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())?;
|
||||
|
||||
Reference in New Issue
Block a user