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",
|
"search_run",
|
||||||
"panel_load",
|
"panel_load",
|
||||||
"wiki_open",
|
"wiki_open",
|
||||||
"archive_move",
|
|
||||||
"archive_rename",
|
|
||||||
"archive_read",
|
"archive_read",
|
||||||
"archive_write",
|
"archive_write",
|
||||||
"archive_set_title",
|
"archive_set_title",
|
||||||
|
"archive_folders",
|
||||||
|
"panel_save_as",
|
||||||
"archive_delete",
|
"archive_delete",
|
||||||
"archive_move_folder",
|
|
||||||
"archive_create_folder",
|
"archive_create_folder",
|
||||||
"archive_create_doc",
|
"archive_create_doc",
|
||||||
"open_panel_window",
|
"open_panel_window",
|
||||||
|
|||||||
@@ -24,12 +24,11 @@
|
|||||||
"allow-archive-create-doc",
|
"allow-archive-create-doc",
|
||||||
"allow-archive-create-folder",
|
"allow-archive-create-folder",
|
||||||
"allow-archive-delete",
|
"allow-archive-delete",
|
||||||
"allow-archive-move",
|
|
||||||
"allow-archive-move-folder",
|
|
||||||
"allow-archive-rename",
|
|
||||||
"allow-archive-read",
|
"allow-archive-read",
|
||||||
"allow-archive-write",
|
"allow-archive-write",
|
||||||
"allow-archive-set-title",
|
"allow-archive-set-title",
|
||||||
|
"allow-archive-folders",
|
||||||
|
"allow-panel-save-as",
|
||||||
"allow-buffer-read",
|
"allow-buffer-read",
|
||||||
"allow-commands-delete",
|
"allow-commands-delete",
|
||||||
"allow-enabled-modules",
|
"allow-enabled-modules",
|
||||||
|
|||||||
@@ -23,12 +23,11 @@
|
|||||||
"allow-archive-create-doc",
|
"allow-archive-create-doc",
|
||||||
"allow-archive-create-folder",
|
"allow-archive-create-folder",
|
||||||
"allow-archive-delete",
|
"allow-archive-delete",
|
||||||
"allow-archive-move",
|
|
||||||
"allow-archive-move-folder",
|
|
||||||
"allow-archive-rename",
|
|
||||||
"allow-archive-read",
|
"allow-archive-read",
|
||||||
"allow-archive-write",
|
"allow-archive-write",
|
||||||
"allow-archive-set-title",
|
"allow-archive-set-title",
|
||||||
|
"allow-archive-folders",
|
||||||
|
"allow-panel-save-as",
|
||||||
"allow-buffer-read",
|
"allow-buffer-read",
|
||||||
"allow-commands-delete",
|
"allow-commands-delete",
|
||||||
"allow-enabled-modules",
|
"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::search_run,
|
||||||
terminal::panel_load,
|
terminal::panel_load,
|
||||||
terminal::wiki_open,
|
terminal::wiki_open,
|
||||||
terminal::archive_move,
|
|
||||||
terminal::archive_rename,
|
|
||||||
terminal::archive_read,
|
terminal::archive_read,
|
||||||
terminal::archive_write,
|
terminal::archive_write,
|
||||||
terminal::archive_set_title,
|
terminal::archive_set_title,
|
||||||
|
terminal::archive_folders,
|
||||||
|
terminal::panel_save_as,
|
||||||
terminal::archive_delete,
|
terminal::archive_delete,
|
||||||
terminal::archive_move_folder,
|
|
||||||
terminal::archive_create_folder,
|
terminal::archive_create_folder,
|
||||||
terminal::archive_create_doc,
|
terminal::archive_create_doc,
|
||||||
terminal::open_panel_window
|
terminal::open_panel_window
|
||||||
|
|||||||
@@ -436,8 +436,25 @@ pub(crate) fn panel_archive_cmd(
|
|||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
tags: Option<Vec<String>>,
|
tags: Option<Vec<String>>,
|
||||||
) -> Result<String, 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 {
|
let meta = ArchiveMeta {
|
||||||
folder: folder.filter(|f| !f.trim().is_empty()),
|
folder,
|
||||||
description: description.filter(|d| !d.trim().is_empty()),
|
description: description.filter(|d| !d.trim().is_empty()),
|
||||||
tags: tags.unwrap_or_default(),
|
tags: tags.unwrap_or_default(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -282,7 +282,8 @@ pub(crate) fn frontmatter(
|
|||||||
meta: &ArchiveMeta,
|
meta: &ArchiveMeta,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut fm = format!(
|
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('"', "'"),
|
title.replace('"', "'"),
|
||||||
);
|
);
|
||||||
if let Some(d) = &meta.description {
|
if let Some(d) = &meta.description {
|
||||||
@@ -542,7 +543,9 @@ mod tests {
|
|||||||
fn frontmatter_mit_und_ohne_meta() {
|
fn frontmatter_mit_und_ohne_meta() {
|
||||||
let leer = ArchiveMeta::default();
|
let leer = ArchiveMeta::default();
|
||||||
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &leer);
|
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("description:"));
|
||||||
assert!(!fm.contains("tags:"));
|
assert!(!fm.contains("tags:"));
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ use crate::domain::archive::{parse_frontmatter, parse_tag_list, slugify, strip_s
|
|||||||
|
|
||||||
#[derive(serde::Serialize, Clone)]
|
#[derive(serde::Serialize, Clone)]
|
||||||
pub(crate) struct Doc {
|
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,
|
pub(crate) relpath: String,
|
||||||
/// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel.
|
/// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel.
|
||||||
pub(crate) name: String,
|
pub(crate) name: String,
|
||||||
@@ -113,6 +116,7 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
|
|||||||
.display()
|
.display()
|
||||||
.to_string();
|
.to_string();
|
||||||
let doc = Doc {
|
let doc = Doc {
|
||||||
|
id: fm.get("id").cloned().unwrap_or_default(),
|
||||||
relpath,
|
relpath,
|
||||||
title: fm.get("title").unwrap_or(&name).clone(),
|
title: fm.get("title").unwrap_or(&name).clone(),
|
||||||
description: fm.get("description").cloned(),
|
description: fm.get("description").cloned(),
|
||||||
@@ -164,6 +168,8 @@ pub(crate) struct WikiFolder {
|
|||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub(crate) struct WikiDocEntry {
|
pub(crate) struct WikiDocEntry {
|
||||||
|
/// Technische ID — Adressat aller Aktionen.
|
||||||
|
pub(crate) id: String,
|
||||||
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
|
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
|
||||||
/// Zeilen-Aktionen (umbenennen, löschen).
|
/// Zeilen-Aktionen (umbenennen, löschen).
|
||||||
pub(crate) relpath: String,
|
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
|
/// Alle Ordner-Relpaths unterhalb von `dir`, rekursiv; versteckte Einträge
|
||||||
/// (Punkt-Präfix) bleiben außen vor — wie beim Dokument-Scan.
|
/// (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()))?;
|
let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
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())
|
.map(|c| c[..10].to_string())
|
||||||
});
|
});
|
||||||
WikiDocEntry {
|
WikiDocEntry {
|
||||||
|
id: doc.id.clone(),
|
||||||
relpath: doc.relpath.clone(),
|
relpath: doc.relpath.clone(),
|
||||||
name: doc.name.clone(),
|
name: doc.name.clone(),
|
||||||
title: doc.title.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
|
/// Löst ein Wikilink-Ziel (Name, Titel oder Datei-Stem) gegen das Archiv auf
|
||||||
/// und liefert den relpath des Dokuments.
|
/// und liefert den relpath des Dokuments.
|
||||||
pub(crate) fn resolve_doc(home: &Path, target: &str) -> Result<String, String> {
|
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(())
|
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)
|
/// Benennt ein Dokument um: neuer Name als Slug hinter dem (erhaltenen)
|
||||||
/// Zeitstempel. Liefert den neuen relpath.
|
/// Zeitstempel. Liefert den neuen relpath.
|
||||||
@@ -194,6 +176,49 @@ pub(crate) fn move_folder(home: &Path, folder: &str, to: &str) -> Result<(), Str
|
|||||||
Ok(())
|
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
|
/// Invariante des Notizmodells: JEDER Knoten besitzt eine Textdatei — der
|
||||||
/// Ordnername ist nur technische Verwaltung, Titel und Inhalt stehen im
|
/// Ordnername ist nur technische Verwaltung, Titel und Inhalt stehen im
|
||||||
/// gleichnamigen Dokument daneben (Wurzel: `index.md`). Läuft vor jedem
|
/// gleichnamigen Dokument daneben (Wurzel: `index.md`). Läuft vor jedem
|
||||||
@@ -260,35 +285,13 @@ mod tests {
|
|||||||
home
|
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]
|
#[test]
|
||||||
fn traversal_und_fremde_pfade_brechen_ab() {
|
fn traversal_und_fremde_pfade_brechen_ab() {
|
||||||
let home = archiv();
|
let home = archiv();
|
||||||
assert!(move_doc(&home, "../2026-07-19_1000-adr-logging.md", "x").is_err());
|
assert!(delete_doc(&home, "../2026-07-19_1000-adr-logging.md").is_err());
|
||||||
assert!(move_doc(&home, "2026-07-19_1000-adr-logging.md", "../raus").is_err());
|
assert!(delete_doc(&home, "/etc/passwd.md").is_err());
|
||||||
assert!(move_doc(&home, "/etc/passwd.md", "x").is_err());
|
|
||||||
assert!(delete_doc(&home, "konzepte").is_err()); // kein .md
|
assert!(delete_doc(&home, "konzepte").is_err()); // kein .md
|
||||||
assert!(move_folder(&home, "konzepte", "../raus").is_err());
|
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();
|
let rel = create_doc(&home, "notizen/2026", "Deploy Nötiz!", "proj").unwrap();
|
||||||
assert_eq!(rel, "notizen/2026/deploy-noetiz.md");
|
assert_eq!(rel, "notizen/2026/deploy-noetiz.md");
|
||||||
let text = fs::read_to_string(home.join(&rel)).unwrap();
|
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: "));
|
assert!(text.contains("created: "));
|
||||||
|
|
||||||
// In der Wurzel, gleicher Name kollidiert laut, leerer Name bricht ab.
|
// 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.md").is_file());
|
||||||
assert!(home.join("leer/unter.md").is_file());
|
assert!(home.join("leer/unter.md").is_file());
|
||||||
let text = fs::read_to_string(home.join("leer.md")).unwrap();
|
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/
|
// Gestempelte Zwillinge zählen als Knotentext — kein Duplikat: konzepte/
|
||||||
// hat keins, bekommt eines; ein zweiter Lauf legt nichts Neues an.
|
// hat keins, bekommt eines; ein zweiter Lauf legt nichts Neues an.
|
||||||
assert!(home.join("konzepte.md").is_file());
|
assert!(home.join("konzepte.md").is_file());
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ use crate::domain::archive_index::scan_with_bodies;
|
|||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub(crate) struct Hit {
|
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) relpath: String,
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
/// Textausschnitt um die Fundstelle, Treffer in `**…**`.
|
/// Textausschnitt um die Fundstelle, Treffer in `**…**`.
|
||||||
@@ -45,13 +47,18 @@ pub(crate) fn search(
|
|||||||
let conn = build_index(home)?;
|
let conn = build_index(home)?;
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.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",
|
FROM docs WHERE docs MATCH ?1 ORDER BY rank LIMIT ?2",
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(rusqlite::params![expr, limit as i64], |row| {
|
.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}"))?;
|
.map_err(|e| format!("Suchausdruck „{query}“: {e}"))?;
|
||||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
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())?;
|
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||||
conn
|
conn
|
||||||
.execute_batch(
|
.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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let docs = scan_with_bodies(home)?;
|
let docs = scan_with_bodies(home)?;
|
||||||
let mut insert = conn
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
for (doc, body) in &docs {
|
for (doc, body) in &docs {
|
||||||
insert
|
insert
|
||||||
.execute(rusqlite::params![
|
.execute(rusqlite::params![
|
||||||
|
doc.id,
|
||||||
doc.relpath,
|
doc.relpath,
|
||||||
doc.name,
|
doc.name,
|
||||||
doc.title,
|
doc.title,
|
||||||
|
|||||||
+86
-32
@@ -527,6 +527,7 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> {
|
|||||||
Some(tag) => {
|
Some(tag) => {
|
||||||
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
|
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_node_texts(&home, &display)?;
|
||||||
|
crate::domain::archive_ops::ensure_ids(&home)?;
|
||||||
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(
|
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(
|
||||||
&home,
|
&home,
|
||||||
(!tag.is_empty()).then_some(tag),
|
(!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
|
/// Liest den Body eines Archiv-Dokuments (ohne Frontmatter) — Inhalt der
|
||||||
/// Notiz-Ansicht im Archiv-Tab.
|
/// Notiz-Ansicht im Archiv-Tab.
|
||||||
#[tauri::command]
|
#[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 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 path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
|
||||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
Ok(crate::domain::archive::strip_frontmatter(&text).to_string())
|
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
|
/// Schreibt den Body einer Archiv-Notiz zurück (Bearbeiten im
|
||||||
/// Archiv-Fenster); die Frontmatter der Datei bleibt.
|
/// Archiv-Fenster); die Frontmatter der Datei bleibt.
|
||||||
#[tauri::command]
|
#[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 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 path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
|
||||||
crate::domain::archive_ops::write_body(&path, &text)
|
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);
|
/// Setzt den Anzeige-Titel einer Notiz (Klick auf den Titel im Archiv);
|
||||||
/// danach die frische Übersicht, damit Baum und Karten den neuen Titel zeigen.
|
/// danach die frische Übersicht, damit Baum und Karten den neuen Titel zeigen.
|
||||||
#[tauri::command]
|
#[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 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 path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
|
||||||
crate::domain::archive_ops::set_title(&path, &title)?;
|
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)
|
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> {
|
fn wiki_refresh_page(project: &str, home: &std::path::Path) -> Result<(), String> {
|
||||||
let display = crate::domain::project::display_name_in(&Paths::real(), project)?;
|
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_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)?)
|
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(home, None)?)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
std::fs::write(wiki_file(project), json).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.
|
/// Löscht ein Archiv-Dokument; danach zeigt das Wiki die Übersicht.
|
||||||
#[tauri::command]
|
#[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 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)?;
|
crate::domain::archive_ops::delete_doc(&home, &relpath)?;
|
||||||
relink(&project, &home.join(&relpath), None)?;
|
relink(&project, &home.join(&relpath), None)?;
|
||||||
wiki_refresh_page(&project, &home)
|
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.
|
/// Legt einen Ordner im Archiv an (Plus im Baum); danach Übersicht.
|
||||||
#[tauri::command]
|
#[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 home = crate::domain::archive::require_archive_home(&project)?;
|
||||||
|
let folder = join_under(&home, &parent, &name)?;
|
||||||
crate::domain::archive_ops::create_folder(&home, &folder)?;
|
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)
|
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
|
/// Legt ein leeres Dokument an (Plus im Listenkopf) und öffnet es im
|
||||||
/// Dokument-Tab: leerer Dokument-Puffer plus Quell-Verknüpfung — das
|
/// Dokument-Tab: leerer Dokument-Puffer plus Quell-Verknüpfung — das
|
||||||
/// Getippte landet über `panel_set` in der Archiv-Datei. Die Übersicht
|
/// 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
|
/// frisch (zwei konkurrierende Puffer-Events würden sonst um den aktiven Tab
|
||||||
/// rennen).
|
/// rennen).
|
||||||
#[tauri::command]
|
#[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 home = crate::domain::archive::require_archive_home(&project)?;
|
||||||
let display = crate::domain::project::display_name_in(&Paths::real(), &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 rel = crate::domain::archive_ops::create_doc(&home, &folder, &name, &display)?;
|
||||||
let path = home.join(rel);
|
let path = home.join(rel);
|
||||||
std::fs::write(panel_file(&project), "").map_err(|e| e.to_string())?;
|
std::fs::write(panel_file(&project), "").map_err(|e| e.to_string())?;
|
||||||
|
|||||||
+104
-11
@@ -5,10 +5,20 @@ import { initArchiveForm } from "./archive-form";
|
|||||||
function setup() {
|
function setup() {
|
||||||
document.body.innerHTML = `<button id="btn">Archiv</button>`;
|
document.body.innerHTML = `<button id="btn">Archiv</button>`;
|
||||||
const onSubmit = vi.fn();
|
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<HTMLElement>(".archive-form")!;
|
const root = document.querySelector<HTMLElement>(".archive-form")!;
|
||||||
const inputs = [...root.querySelectorAll("input")];
|
const inputs = [...root.querySelectorAll("input")];
|
||||||
return { form, onSubmit, root, inputs };
|
return { form, onSubmit, onSave, folders, root, inputs };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("initArchiveForm", () => {
|
describe("initArchiveForm", () => {
|
||||||
@@ -25,15 +35,18 @@ describe("initArchiveForm", () => {
|
|||||||
expect(root.hidden).toBe(true);
|
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();
|
const { form, onSubmit, root, inputs } = setup();
|
||||||
form.toggle();
|
form.toggle();
|
||||||
inputs[0].value = " konzepte/panel ";
|
await new Promise((r) => setTimeout(r));
|
||||||
inputs[1].value = "";
|
root
|
||||||
inputs[2].value = " adr, infra ,, ";
|
.querySelector<HTMLElement>('.archive-browse-row[data-path="id-konzepte"]')!
|
||||||
|
.click();
|
||||||
|
inputs[0].value = "";
|
||||||
|
inputs[1].value = " adr, infra ,, ";
|
||||||
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||||
expect(onSubmit).toHaveBeenCalledWith({
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
folder: "konzepte/panel",
|
folder: "id-konzepte",
|
||||||
description: undefined,
|
description: undefined,
|
||||||
tags: ["adr", "infra"],
|
tags: ["adr", "infra"],
|
||||||
});
|
});
|
||||||
@@ -45,13 +58,12 @@ describe("initArchiveForm", () => {
|
|||||||
it("startet nach dem Archivieren wieder leer", () => {
|
it("startet nach dem Archivieren wieder leer", () => {
|
||||||
const { form, onSubmit, root, inputs } = setup();
|
const { form, onSubmit, root, inputs } = setup();
|
||||||
form.toggle();
|
form.toggle();
|
||||||
inputs[0].value = "konzepte/panel";
|
inputs[0].value = "Beschreibung A";
|
||||||
inputs[1].value = "Beschreibung A";
|
inputs[1].value = "panel, wiki";
|
||||||
inputs[2].value = "panel, wiki";
|
|
||||||
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||||
|
|
||||||
form.toggle();
|
form.toggle();
|
||||||
expect(inputs.map((i) => i.value)).toEqual(["", "", ""]);
|
expect(inputs.map((i) => i.value)).toEqual(["", ""]);
|
||||||
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||||
expect(onSubmit).toHaveBeenLastCalledWith({
|
expect(onSubmit).toHaveBeenLastCalledWith({
|
||||||
folder: undefined,
|
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<HTMLElement>(".archive-browse-row")].filter(
|
||||||
|
(r) => !r.closest<HTMLElement>(".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<HTMLElement>(".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 = `<button id="btn">Archiv</button>`;
|
||||||
|
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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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", () => {
|
it("Enter schickt ab, Escape schließt ohne Abschicken", () => {
|
||||||
const { form, onSubmit, root } = setup();
|
const { form, onSubmit, root } = setup();
|
||||||
form.toggle();
|
form.toggle();
|
||||||
|
|||||||
+242
-22
@@ -1,7 +1,10 @@
|
|||||||
/// Archiv-Formular: klappt unter dem Archiv-Button auf und fragt Ordner,
|
/// Archiv-Dialog: wohin archivieren, mit welcher Beschreibung und welchen
|
||||||
/// Beschreibung und Schlagwörter ab (alles optional); Enter oder der Button
|
/// Schlagwörtern. Modales Popup im Design der Archiv-Ansicht — abgedunkelter
|
||||||
/// archivieren, Escape schließt. Gemeinsam für das angedockte Panel und das
|
/// Hintergrund, zentrierte Box. Der Zielordner wird im sichtbaren Baum des
|
||||||
/// abgelöste Fenster.
|
/// 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";
|
import { t } from "./messages";
|
||||||
|
|
||||||
@@ -11,31 +14,224 @@ export interface ArchiveFormMeta {
|
|||||||
tags: string[];
|
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<ArchiveFolderNode[]>;
|
||||||
|
/// „Auf Platte legen": Entwurf als Datei an frei gewähltem Pfad ablegen.
|
||||||
|
onSave?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
export function initArchiveForm(
|
export function initArchiveForm(
|
||||||
anchor: HTMLElement,
|
_anchor: HTMLElement,
|
||||||
onSubmit: (meta: ArchiveFormMeta) => void,
|
onSubmit: (meta: ArchiveFormMeta) => void,
|
||||||
|
opts: ArchiveFormOptions = {},
|
||||||
): { toggle(): void } {
|
): { toggle(): void } {
|
||||||
function field(placeholder: string): HTMLInputElement {
|
function field(labelText: string, placeholder: string): HTMLInputElement {
|
||||||
const i = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
i.type = "text";
|
input.type = "text";
|
||||||
i.placeholder = placeholder;
|
input.className = "wiki-tree-input";
|
||||||
return i;
|
input.placeholder = placeholder;
|
||||||
|
box.append(labelled(labelText, input));
|
||||||
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Beschriftete Gruppe. `label` nur für echte Eingabefelder: ein <label>
|
||||||
|
/// leitet Klicks auf sein erstes beschriftbares Kind um — Knöpfe im Baum
|
||||||
|
/// würden dadurch alle auf dieselbe Zeile wirken.
|
||||||
|
function labelled(
|
||||||
|
labelText: string,
|
||||||
|
control: HTMLElement,
|
||||||
|
tag: "label" | "div" = "label",
|
||||||
|
): HTMLElement {
|
||||||
|
const group = document.createElement(tag);
|
||||||
|
group.className = "archive-form-label";
|
||||||
|
const caption = document.createElement("span");
|
||||||
|
caption.textContent = labelText;
|
||||||
|
group.append(caption, control);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backdrop trägt die Modal-Optik, die Box das Formular — dieselben Klassen
|
||||||
|
// wie die Dialoge der Archiv-Ansicht.
|
||||||
const form = document.createElement("div");
|
const form = document.createElement("div");
|
||||||
form.className = "archive-form";
|
form.className = "archive-form wiki-modal";
|
||||||
form.hidden = true;
|
form.hidden = true;
|
||||||
const folder = field(t("archiveForm.folder"));
|
const box = document.createElement("div");
|
||||||
const desc = field(t("archiveForm.description"));
|
box.className = "wiki-form archive-form-box";
|
||||||
const tags = field(t("archiveForm.tags"));
|
const caption = document.createElement("div");
|
||||||
|
caption.className = "wiki-form-title";
|
||||||
|
caption.textContent = t("archiveForm.title");
|
||||||
|
box.append(caption);
|
||||||
|
|
||||||
|
// --- Zielordner: durchlaufbarer Baum ------------------------------------
|
||||||
|
// Logische Sicht des Archivs (Knotentitel plus Pfad an der Zeile), startet
|
||||||
|
// eingeklappt. Die gewählte Zeile ist die Anzeige des Ziels — kein zweites
|
||||||
|
// Feld daneben.
|
||||||
|
const browse = document.createElement("div");
|
||||||
|
browse.className = "archive-browse";
|
||||||
|
box.append(labelled(t("archiveForm.folderLabel"), browse, "div"));
|
||||||
|
|
||||||
|
/// Gewählter Zielordner (Pfad relativ zum Archiv-Home, "" = Wurzel).
|
||||||
|
let selected = "";
|
||||||
|
|
||||||
|
/// Aufgeklappte Knoten (Pfade) — überleben das Neuzeichnen des Baums.
|
||||||
|
const openPaths = new Set<string>();
|
||||||
|
|
||||||
|
interface Node {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
children: Node[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flache Pfadliste zu einem Baum; Zwischenebenen ohne eigenen Eintrag
|
||||||
|
/// entstehen mit ihrem Ordnernamen als Titel.
|
||||||
|
function buildNodes(folders: ArchiveFolderNode[]): Node[] {
|
||||||
|
const byPath = new Map<string, Node>();
|
||||||
|
const roots: Node[] = [];
|
||||||
|
const ensure = (path: string, title: string, id = ""): Node => {
|
||||||
|
const found = byPath.get(path);
|
||||||
|
if (found) return found;
|
||||||
|
const node: Node = { id, path, title, children: [] };
|
||||||
|
byPath.set(path, node);
|
||||||
|
const cut = path.lastIndexOf("/");
|
||||||
|
if (cut < 0) roots.push(node);
|
||||||
|
else {
|
||||||
|
const parent = path.slice(0, cut);
|
||||||
|
ensure(parent, parent.split("/").pop()!).children.push(node);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
for (const f of [...folders].sort((a, b) => a.path.localeCompare(b.path))) {
|
||||||
|
ensure(f.path, f.title, f.id);
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Knoten: Zeile (Pfeil klappt, Rest wählt aus) plus Kinderliste.
|
||||||
|
function nodeEl(node: Node, depth: number): HTMLElement {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "archive-node";
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "archive-browse-row";
|
||||||
|
row.dataset.path = node.id;
|
||||||
|
row.style.setProperty("--depth", String(depth));
|
||||||
|
|
||||||
|
const arrow = document.createElement("button");
|
||||||
|
arrow.className = "archive-arrow";
|
||||||
|
arrow.type = "button";
|
||||||
|
if (node.children.length) {
|
||||||
|
arrow.textContent = "▸";
|
||||||
|
arrow.title = t("archiveForm.expand");
|
||||||
|
} else {
|
||||||
|
arrow.classList.add("blank");
|
||||||
|
}
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "wiki-tree-icon";
|
||||||
|
icon.innerHTML = `<svg width="17" height="17" viewBox="0 0 16 16"><circle cx="8" cy="8" r="4.2"/></svg>`;
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "wiki-tree-name";
|
||||||
|
label.textContent = node.title;
|
||||||
|
// Der Pfad würde die Zeile überbreit machen; er steht als Tooltip dran.
|
||||||
|
row.title = node.path;
|
||||||
|
row.append(arrow, icon, label);
|
||||||
|
|
||||||
|
const kids = document.createElement("div");
|
||||||
|
kids.className = "archive-kids";
|
||||||
|
kids.hidden = !openPaths.has(node.path);
|
||||||
|
for (const child of node.children) kids.append(nodeEl(child, depth + 1));
|
||||||
|
|
||||||
|
arrow.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!node.children.length) return;
|
||||||
|
kids.hidden = !kids.hidden;
|
||||||
|
if (kids.hidden) openPaths.delete(node.path);
|
||||||
|
else openPaths.add(node.path);
|
||||||
|
arrow.textContent = kids.hidden ? "▸" : "▾";
|
||||||
|
});
|
||||||
|
arrow.textContent = node.children.length ? (kids.hidden ? "▸" : "▾") : "";
|
||||||
|
row.addEventListener("click", () => select(node.id));
|
||||||
|
|
||||||
|
wrap.append(row, kids);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(path: string) {
|
||||||
|
selected = path;
|
||||||
|
mark();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die gewählte Zeile hervorheben — sie ist die einzige Anzeige des Ziels.
|
||||||
|
function mark() {
|
||||||
|
for (const row of browse.querySelectorAll<HTMLElement>(
|
||||||
|
".archive-browse-row",
|
||||||
|
)) {
|
||||||
|
row.classList.toggle("active", row.dataset.path === selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBrowse(folders: ArchiveFolderNode[]) {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.className = "archive-browse-row";
|
||||||
|
root.dataset.path = "";
|
||||||
|
root.style.setProperty("--depth", "0");
|
||||||
|
const blank = document.createElement("span");
|
||||||
|
blank.className = "archive-arrow blank";
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "wiki-tree-icon";
|
||||||
|
icon.innerHTML = `<svg width="17" height="17" viewBox="0 0 16 16"><circle cx="8" cy="8" r="4.2"/></svg>`;
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "wiki-tree-name";
|
||||||
|
label.textContent = t("wiki.archive");
|
||||||
|
root.append(blank, icon, label);
|
||||||
|
root.addEventListener("click", () => select(""));
|
||||||
|
browse.replaceChildren(root);
|
||||||
|
for (const node of buildNodes(folders)) browse.append(nodeEl(node, 1));
|
||||||
|
mark();
|
||||||
|
}
|
||||||
|
|
||||||
|
const desc = field(
|
||||||
|
t("archiveForm.descriptionLabel"),
|
||||||
|
t("archiveForm.description"),
|
||||||
|
);
|
||||||
|
const tags = field(t("archiveForm.tagsLabel"), t("archiveForm.tags"));
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "wiki-form-row";
|
||||||
const submit = document.createElement("button");
|
const submit = document.createElement("button");
|
||||||
submit.className = "archive-form-submit";
|
submit.className = "archive-form-submit wiki-form-submit";
|
||||||
submit.textContent = t("archiveForm.submit");
|
submit.textContent = t("archiveForm.submit");
|
||||||
form.append(folder, desc, tags, submit);
|
const cancel = document.createElement("button");
|
||||||
|
cancel.className = "archive-form-cancel wiki-form-cancel";
|
||||||
|
cancel.textContent = t("archiveForm.cancel");
|
||||||
|
row.append(submit);
|
||||||
|
if (opts.onSave) {
|
||||||
|
const save = document.createElement("button");
|
||||||
|
save.className = "archive-form-save wiki-form-submit";
|
||||||
|
save.textContent = t("archiveForm.save");
|
||||||
|
save.title = t("archiveForm.saveTitle");
|
||||||
|
save.addEventListener("click", () => {
|
||||||
|
opts.onSave!();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
row.append(save);
|
||||||
|
}
|
||||||
|
row.append(cancel);
|
||||||
|
box.append(row);
|
||||||
|
form.append(box);
|
||||||
document.body.append(form);
|
document.body.append(form);
|
||||||
|
|
||||||
const meta = (): ArchiveFormMeta => ({
|
const meta = (): ArchiveFormMeta => ({
|
||||||
folder: folder.value.trim() || undefined,
|
folder: selected || undefined,
|
||||||
description: desc.value.trim() || undefined,
|
description: desc.value.trim() || undefined,
|
||||||
tags: tags.value
|
tags: tags.value
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -45,27 +241,51 @@ export function initArchiveForm(
|
|||||||
// Leeren, weil das Formular zum Fenster gehört und nicht zum Dokument.
|
// Leeren, weil das Formular zum Fenster gehört und nicht zum Dokument.
|
||||||
const close = () => {
|
const close = () => {
|
||||||
form.hidden = true;
|
form.hidden = true;
|
||||||
folder.value = "";
|
selected = "";
|
||||||
desc.value = "";
|
desc.value = "";
|
||||||
tags.value = "";
|
tags.value = "";
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
// Escape auch dann, wenn der Fokus nicht im Dialog sitzt (Terminal-Fenster
|
||||||
|
// gibt Tasten sonst an die Shell weiter).
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") close();
|
||||||
};
|
};
|
||||||
const open = () => {
|
const open = () => {
|
||||||
const r = anchor.getBoundingClientRect();
|
|
||||||
form.style.top = `${r.bottom + 6}px`;
|
|
||||||
form.style.right = `${Math.max(8, window.innerWidth - r.right - 4)}px`;
|
|
||||||
form.hidden = false;
|
form.hidden = false;
|
||||||
folder.focus();
|
document.addEventListener("keydown", onKey);
|
||||||
|
desc.focus();
|
||||||
|
// Frisch laden: zwischen zwei Dialogen können Ordner entstanden sein.
|
||||||
|
// Scheitert das Laden, steht der Grund im Kasten — ein leerer Kasten
|
||||||
|
// ohne Erklärung war der Fehler der Vorversion.
|
||||||
|
browse.replaceChildren();
|
||||||
|
// .catch statt zweitem then-Argument: so fallen auch Fehler beim Aufbau
|
||||||
|
// des Baums auf, nicht nur beim Laden.
|
||||||
|
opts
|
||||||
|
.folders?.()
|
||||||
|
.then(renderBrowse)
|
||||||
|
.catch((e) => {
|
||||||
|
const err = document.createElement("div");
|
||||||
|
err.className = "archive-browse-error";
|
||||||
|
err.textContent = String(e);
|
||||||
|
browse.replaceChildren(err);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const fire = () => {
|
const fire = () => {
|
||||||
onSubmit(meta());
|
onSubmit(meta());
|
||||||
close();
|
close();
|
||||||
};
|
};
|
||||||
submit.addEventListener("click", fire);
|
submit.addEventListener("click", fire);
|
||||||
|
cancel.addEventListener("click", close);
|
||||||
|
form.addEventListener("mousedown", (e) => {
|
||||||
|
if (e.target === form) close();
|
||||||
|
});
|
||||||
form.addEventListener("keydown", (e) => {
|
form.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fire();
|
fire();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-10
@@ -205,7 +205,7 @@ const de = {
|
|||||||
raw: "Roh",
|
raw: "Roh",
|
||||||
editDraft: "Entwurf bearbeiten (Cmd/Ctrl+Enter speichert, Esc verwirft)",
|
editDraft: "Entwurf bearbeiten (Cmd/Ctrl+Enter speichert, Esc verwirft)",
|
||||||
copy: "In die Zwischenablage kopieren",
|
copy: "In die Zwischenablage kopieren",
|
||||||
archive: "In den Archiv-Ordner speichern",
|
archive: "In den Archiv-Ordner speichern …",
|
||||||
chooseArchiveDir: "Archiv-Ordner wählen",
|
chooseArchiveDir: "Archiv-Ordner wählen",
|
||||||
processEnded: "[Prozess beendet]",
|
processEnded: "[Prozess beendet]",
|
||||||
},
|
},
|
||||||
@@ -220,10 +220,18 @@ const de = {
|
|||||||
session: "Session",
|
session: "Session",
|
||||||
},
|
},
|
||||||
archiveForm: {
|
archiveForm: {
|
||||||
folder: "Ordner — z. B. konzepte/panel",
|
title: "Entwurf archivieren",
|
||||||
|
folderLabel: "Zielordner im Archiv",
|
||||||
|
expand: "Auf-/zuklappen",
|
||||||
|
descriptionLabel: "Beschreibung",
|
||||||
|
tagsLabel: "Schlagwörter",
|
||||||
|
folder: "z. B. konzepte/panel",
|
||||||
description: "Beschreibung",
|
description: "Beschreibung",
|
||||||
tags: "Schlagwörter, kommagetrennt",
|
tags: "Schlagwörter, kommagetrennt",
|
||||||
submit: "Archivieren",
|
submit: "Archivieren",
|
||||||
|
save: "Auf Platte legen …",
|
||||||
|
saveTitle: "Als Datei an frei gewähltem Ort ablegen — ohne Archiv",
|
||||||
|
cancel: "Abbrechen",
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
placeholder: "Archiv durchsuchen — #tag filtert",
|
placeholder: "Archiv durchsuchen — #tag filtert",
|
||||||
@@ -242,8 +250,6 @@ const de = {
|
|||||||
"Archivieren: Archiv-Button im Entwurf oder „archiviere das“ im Chat — mit Ordner, Beschreibung und Schlagwörtern.",
|
"Archivieren: Archiv-Button im Entwurf oder „archiviere das“ im Chat — mit Ordner, Beschreibung und Schlagwörtern.",
|
||||||
noPage: "Keine Wiki-Seite geladen.",
|
noPage: "Keine Wiki-Seite geladen.",
|
||||||
openOverview: "Archiv-Übersicht öffnen",
|
openOverview: "Archiv-Übersicht öffnen",
|
||||||
folderRename: "Ordnerpfad ändern",
|
|
||||||
renameDoc: "Dateiname ändern",
|
|
||||||
titleEdit: "Titel bearbeiten — klicken",
|
titleEdit: "Titel bearbeiten — klicken",
|
||||||
editDoc: "Bearbeiten",
|
editDoc: "Bearbeiten",
|
||||||
save: "Speichern",
|
save: "Speichern",
|
||||||
@@ -257,7 +263,6 @@ const de = {
|
|||||||
newDoc: "Neues Dokument",
|
newDoc: "Neues Dokument",
|
||||||
create: "Anlegen",
|
create: "Anlegen",
|
||||||
cancel: "Abbrechen",
|
cancel: "Abbrechen",
|
||||||
folderPath: "ordner/unterordner",
|
|
||||||
docName: "Name des Dokuments",
|
docName: "Name des Dokuments",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -462,7 +467,7 @@ const en: typeof de = {
|
|||||||
raw: "Raw",
|
raw: "Raw",
|
||||||
editDraft: "Edit draft (Cmd/Ctrl+Enter saves, Esc discards)",
|
editDraft: "Edit draft (Cmd/Ctrl+Enter saves, Esc discards)",
|
||||||
copy: "Copy to clipboard",
|
copy: "Copy to clipboard",
|
||||||
archive: "Save to the archive folder",
|
archive: "Save to the archive folder …",
|
||||||
chooseArchiveDir: "Choose archive folder",
|
chooseArchiveDir: "Choose archive folder",
|
||||||
processEnded: "[process ended]",
|
processEnded: "[process ended]",
|
||||||
},
|
},
|
||||||
@@ -477,10 +482,18 @@ const en: typeof de = {
|
|||||||
session: "Session",
|
session: "Session",
|
||||||
},
|
},
|
||||||
archiveForm: {
|
archiveForm: {
|
||||||
folder: "Folder — e.g. concepts/panel",
|
title: "Archive draft",
|
||||||
|
folderLabel: "Target folder in the archive",
|
||||||
|
expand: "Expand/collapse",
|
||||||
|
descriptionLabel: "Description",
|
||||||
|
tagsLabel: "Tags",
|
||||||
|
folder: "e.g. concepts/panel",
|
||||||
description: "Description",
|
description: "Description",
|
||||||
tags: "Tags, comma-separated",
|
tags: "Tags, comma-separated",
|
||||||
submit: "Archive",
|
submit: "Archive",
|
||||||
|
save: "Save to disk …",
|
||||||
|
saveTitle: "Store as a file at a location you pick — outside the archive",
|
||||||
|
cancel: "Cancel",
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
placeholder: "Search the archive — #tag filters",
|
placeholder: "Search the archive — #tag filters",
|
||||||
@@ -499,8 +512,6 @@ const en: typeof de = {
|
|||||||
"To archive: the archive button in the draft, or “archive this” in the chat — with folder, description and tags.",
|
"To archive: the archive button in the draft, or “archive this” in the chat — with folder, description and tags.",
|
||||||
noPage: "No wiki page loaded.",
|
noPage: "No wiki page loaded.",
|
||||||
openOverview: "Open archive overview",
|
openOverview: "Open archive overview",
|
||||||
folderRename: "Change folder path",
|
|
||||||
renameDoc: "Change file name",
|
|
||||||
titleEdit: "Edit title — click",
|
titleEdit: "Edit title — click",
|
||||||
editDoc: "Edit",
|
editDoc: "Edit",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
@@ -514,7 +525,6 @@ const en: typeof de = {
|
|||||||
newDoc: "New document",
|
newDoc: "New document",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
folderPath: "folder/subfolder",
|
|
||||||
docName: "Document name",
|
docName: "Document name",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+8
-11
@@ -26,10 +26,9 @@ export const wikiTab: PanelTab = {
|
|||||||
);
|
);
|
||||||
return initWikiView(container, {
|
return initWikiView(container, {
|
||||||
autoStart: ctx.standalone,
|
autoStart: ctx.standalone,
|
||||||
readDoc: (relpath) => invoke("archive_read", { project: ctx.project, relpath }),
|
readDoc: (id) => invoke("archive_read", { project: ctx.project, id }),
|
||||||
writeDoc: (relpath, text) =>
|
writeDoc: (id, text) => invoke("archive_write", { project: ctx.project, id, text }),
|
||||||
invoke("archive_write", { project: ctx.project, relpath, text }),
|
setTitle: (id, title) => run("archive_set_title", { id, title }),
|
||||||
setTitle: (relpath, title) => run("archive_set_title", { relpath, title }),
|
|
||||||
openWiki: ctx.openWiki,
|
openWiki: ctx.openWiki,
|
||||||
takePending: () => {
|
takePending: () => {
|
||||||
const p = pendingSelect;
|
const p = pendingSelect;
|
||||||
@@ -37,11 +36,9 @@ export const wikiTab: PanelTab = {
|
|||||||
return p;
|
return p;
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
rename: (relpath, name) => run("archive_rename", { relpath, name }),
|
remove: (id) => run("archive_delete", { id }),
|
||||||
remove: (relpath) => run("archive_delete", { relpath }),
|
createFolder: (parent, name) => run("archive_create_folder", { parent, name }),
|
||||||
moveFolder: (folder, to) => run("archive_move_folder", { folder, to }),
|
createDoc: (parent, name) => run("archive_create_doc", { parent, name }),
|
||||||
createFolder: (folder) => run("archive_create_folder", { folder }),
|
|
||||||
createDoc: (folder, name) => run("archive_create_doc", { folder, name }),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -67,8 +64,8 @@ export const searchTab: PanelTab = {
|
|||||||
init: (container, ctx) =>
|
init: (container, ctx) =>
|
||||||
initSearchView(
|
initSearchView(
|
||||||
container,
|
container,
|
||||||
(_path, relpath) => {
|
(_path, _relpath, id) => {
|
||||||
pendingSelect = relpath;
|
pendingSelect = id;
|
||||||
ctx.openWiki("tag:");
|
ctx.openWiki("tag:");
|
||||||
},
|
},
|
||||||
(raw) => {
|
(raw) => {
|
||||||
|
|||||||
+120
-32
@@ -522,6 +522,9 @@
|
|||||||
}
|
}
|
||||||
/* Anlege-Dialog (Ordner/Dokument): modales Popup mit abgedunkeltem
|
/* Anlege-Dialog (Ordner/Dokument): modales Popup mit abgedunkeltem
|
||||||
Hintergrund. */
|
Hintergrund. */
|
||||||
|
.wiki-modal[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.wiki-modal {
|
.wiki-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -949,55 +952,140 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Archiv-Formular: klappt unter dem Archiv-Button auf. */
|
/* Archiv-Formular: klappt unter dem Archiv-Button auf. */
|
||||||
.archive-form {
|
/* Archiv-Dialog: Optik aus .wiki-modal/.wiki-form, plus Ordner-Baum zum
|
||||||
position: fixed;
|
Anklicken. */
|
||||||
z-index: 20;
|
.archive-form-box {
|
||||||
|
min-width: 540px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
.archive-form-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 260px;
|
color: var(--text);
|
||||||
|
font:
|
||||||
|
600 14px/1.4 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
/* Eingabefelder des Dialogs sichtbar abheben — der dünne Rahmen auf
|
||||||
|
dunklem Grund war als Feld nicht zu erkennen. */
|
||||||
|
.archive-form-box .wiki-tree-input {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.archive-form-box .wiki-tree-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.archive-form-box .wiki-tree-input::placeholder {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
/* Durchlaufbarer Ordnerbaum: eigene Fläche, scrollt bei vielen Ordnern. */
|
||||||
|
.archive-browse {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
max-height: 240px;
|
||||||
|
/* Nur senkrecht scrollen: eine waagerechte Leiste am unteren Rand sähe
|
||||||
|
aus wie ein weiteres Bedienelement. Lange Titel kürzen stattdessen. */
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 6px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
}
|
||||||
.archive-form[hidden] {
|
.archive-browse-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
/* Einrückung nach Tiefe — dieselbe Staffelung wie im Archiv-Baum. */
|
||||||
|
padding-left: calc(8px + var(--depth, 0) * 18px);
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font:
|
||||||
|
400 15px/1.5 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
/* Klapp-Pfeil; ohne Kinder bleibt der Platz frei, damit die Titel bündig
|
||||||
|
stehen. */
|
||||||
|
.archive-arrow {
|
||||||
|
flex: none;
|
||||||
|
width: 16px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.archive-arrow.blank {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.archive-arrow:hover:not(.blank) {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.archive-kids[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.archive-form input {
|
/* Ladefehler des Baums — laut statt leerer Kasten. */
|
||||||
box-sizing: border-box;
|
.archive-browse-error {
|
||||||
width: 100%;
|
color: #f38ba8;
|
||||||
background: var(--tile);
|
text-transform: none;
|
||||||
border: 1px solid var(--line-strong);
|
letter-spacing: 0;
|
||||||
border-radius: 6px;
|
padding: 6px 8px;
|
||||||
padding: 6px 10px;
|
|
||||||
color: var(--text);
|
|
||||||
font:
|
font:
|
||||||
400 12px/1.4 -apple-system,
|
500 13px/1.5 -apple-system,
|
||||||
system-ui,
|
system-ui,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
.archive-form input:focus {
|
/* Jede Zeile dauerhaft sichtbar — nichts erscheint erst beim Überfahren. */
|
||||||
|
.archive-browse-row {
|
||||||
|
background: var(--tile);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.archive-browse-row:hover {
|
||||||
border-color: var(--line-strong);
|
border-color: var(--line-strong);
|
||||||
}
|
}
|
||||||
.archive-form input::placeholder {
|
.archive-browse-row.active {
|
||||||
color: var(--faint);
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.archive-form-submit {
|
.archive-browse-row .wiki-tree-name {
|
||||||
border: none;
|
min-width: 0;
|
||||||
background: var(--line);
|
overflow: hidden;
|
||||||
color: var(--text);
|
text-overflow: ellipsis;
|
||||||
border-radius: 6px;
|
white-space: nowrap;
|
||||||
padding: 6px 10px;
|
|
||||||
font:
|
|
||||||
500 12px/1.4 -apple-system,
|
|
||||||
system-ui,
|
|
||||||
sans-serif;
|
|
||||||
}
|
}
|
||||||
.archive-form-submit:hover {
|
.archive-browse-row .wiki-tree-icon svg {
|
||||||
background: var(--line-strong);
|
fill: none;
|
||||||
|
stroke: var(--muted);
|
||||||
|
stroke-width: 1.3;
|
||||||
|
}
|
||||||
|
.archive-browse-row.active .wiki-tree-icon svg {
|
||||||
|
stroke: var(--accent);
|
||||||
|
}
|
||||||
|
/* Knopfreihe vom Formular abgesetzt. */
|
||||||
|
.archive-form-box .wiki-form-row {
|
||||||
|
border-top: 1px solid var(--line-strong);
|
||||||
|
padding-top: 12px;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */
|
/* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */
|
||||||
|
|||||||
+36
-7
@@ -91,7 +91,8 @@ interface Project {
|
|||||||
terminal: { theme: string | null };
|
terminal: { theme: string | null };
|
||||||
}
|
}
|
||||||
const projects = await invoke<Project[]>("list_projects");
|
const projects = await invoke<Project[]>("list_projects");
|
||||||
const picked = THEMES[projects.find((p) => p.name === project)?.terminal.theme ?? "mocha"];
|
const picked =
|
||||||
|
THEMES[projects.find((p) => p.name === project)?.terminal.theme ?? "mocha"];
|
||||||
applyTheme(picked);
|
applyTheme(picked);
|
||||||
// Fensterhintergrund des Panel-Fensters ist die Kopf-Fläche, nicht das
|
// Fensterhintergrund des Panel-Fensters ist die Kopf-Fläche, nicht das
|
||||||
// Terminal-Dunkel.
|
// Terminal-Dunkel.
|
||||||
@@ -111,7 +112,9 @@ if (initialMode) {
|
|||||||
// Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken
|
// Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken
|
||||||
// wählt notfalls erst das Archiv-Home per Dialog.
|
// wählt notfalls erst das Archiv-Home per Dialog.
|
||||||
const archiveBtn = document.getElementById("panel-archive")!;
|
const archiveBtn = document.getElementById("panel-archive")!;
|
||||||
const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
const archiveForm = initArchiveForm(
|
||||||
|
archiveBtn,
|
||||||
|
async (meta) => {
|
||||||
try {
|
try {
|
||||||
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
|
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
|
||||||
project,
|
project,
|
||||||
@@ -119,12 +122,15 @@ const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
|||||||
let dir: string | undefined;
|
let dir: string | undefined;
|
||||||
if (!configured) {
|
if (!configured) {
|
||||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
const chosen = await open({ directory: true, title: t("panel.chooseArchiveDir") });
|
const chosen = await open({
|
||||||
|
directory: true,
|
||||||
|
title: t("panel.chooseArchiveDir"),
|
||||||
|
});
|
||||||
if (!chosen) return;
|
if (!chosen) return;
|
||||||
dir = chosen as string;
|
dir = chosen as string;
|
||||||
}
|
}
|
||||||
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
|
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
|
||||||
const path = await invoke<string>("panel_archive_cmd", {
|
await invoke<string>("panel_archive_cmd", {
|
||||||
project,
|
project,
|
||||||
dir,
|
dir,
|
||||||
folder: meta.folder ?? null,
|
folder: meta.folder ?? null,
|
||||||
@@ -132,12 +138,33 @@ const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
|||||||
tags: meta.tags,
|
tags: meta.tags,
|
||||||
});
|
});
|
||||||
flash(archiveBtn, "copied", 1400);
|
flash(archiveBtn, "copied", 1400);
|
||||||
invoke("reveal_path_cmd", { path });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
flash(archiveBtn, "error", 1400);
|
flash(archiveBtn, "error", 1400);
|
||||||
panelToast(`Archivieren fehlgeschlagen: ${e}`);
|
panelToast(`Archivieren fehlgeschlagen: ${e}`);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
// Ordner-Vorschläge aus dem Archiv; „Auf Platte legen" schreibt den
|
||||||
|
// Entwurf an einen frei gewählten Pfad (ohne Archiv, ohne Frontmatter).
|
||||||
|
folders: () => invoke<string[]>("archive_folders", { project }),
|
||||||
|
onSave: async () => {
|
||||||
|
try {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const path = await save({
|
||||||
|
title: t("archiveForm.saveTitle"),
|
||||||
|
defaultPath: "entwurf.md",
|
||||||
|
});
|
||||||
|
if (!path) return;
|
||||||
|
await view.flush();
|
||||||
|
await invoke("panel_save_as", { project, path });
|
||||||
|
flash(archiveBtn, "copied", 1400);
|
||||||
|
} catch (e) {
|
||||||
|
flash(archiveBtn, "error", 1400);
|
||||||
|
panelToast(`Speichern fehlgeschlagen: ${e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
archiveBtn.addEventListener("click", () => archiveForm.toggle());
|
archiveBtn.addEventListener("click", () => archiveForm.toggle());
|
||||||
|
|
||||||
// Öffner-Klick im Terminal-Header bei stehendem Fenster: auf den
|
// Öffner-Klick im Terminal-Header bei stehendem Fenster: auf den
|
||||||
@@ -150,4 +177,6 @@ document.getElementById("panel-dock")!.hidden = true;
|
|||||||
|
|
||||||
// „Schließen": Fenster zu, ohne wieder anzudocken (Panel bleibt aus, bis ein
|
// „Schließen": Fenster zu, ohne wieder anzudocken (Panel bleibt aus, bis ein
|
||||||
// neuer Entwurf kommt).
|
// neuer Entwurf kommt).
|
||||||
document.getElementById("panel-close")!.addEventListener("click", () => win.close());
|
document
|
||||||
|
.getElementById("panel-close")!
|
||||||
|
.addEventListener("click", () => win.close());
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const run = JSON.stringify({
|
|||||||
query: "arch",
|
query: "arch",
|
||||||
tag: null,
|
tag: null,
|
||||||
home: "/tmp/archiv",
|
home: "/tmp/archiv",
|
||||||
hits: [{ relpath: "a/2026-01-01_0000-x.md", title: "X", snippet: "ein **arch**iv" }],
|
hits: [{ id: "id-x", relpath: "a/2026-01-01_0000-x.md", title: "X", snippet: "ein **arch**iv" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("initSearchView", () => {
|
describe("initSearchView", () => {
|
||||||
@@ -84,6 +84,7 @@ describe("initSearchView", () => {
|
|||||||
expect(onOpen).toHaveBeenCalledWith(
|
expect(onOpen).toHaveBeenCalledWith(
|
||||||
"/tmp/archiv/a/2026-01-01_0000-x.md",
|
"/tmp/archiv/a/2026-01-01_0000-x.md",
|
||||||
"a/2026-01-01_0000-x.md",
|
"a/2026-01-01_0000-x.md",
|
||||||
|
"id-x",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -8,6 +8,7 @@ import { t } from "./messages";
|
|||||||
import { renderTile } from "./tiles";
|
import { renderTile } from "./tiles";
|
||||||
|
|
||||||
interface Hit {
|
interface Hit {
|
||||||
|
id: string;
|
||||||
relpath: string;
|
relpath: string;
|
||||||
title: string;
|
title: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
@@ -27,7 +28,7 @@ export interface SearchView {
|
|||||||
|
|
||||||
export function initSearchView(
|
export function initSearchView(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
onOpen: (path: string, relpath: string) => void,
|
onOpen: (path: string, relpath: string, id: string) => void,
|
||||||
onSearch: (query: string) => void,
|
onSearch: (query: string) => void,
|
||||||
): SearchView {
|
): SearchView {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -108,7 +109,7 @@ export function initSearchView(
|
|||||||
snippetEl(hit.snippet),
|
snippetEl(hit.snippet),
|
||||||
{ cls: "hit-path", text: hit.relpath },
|
{ cls: "hit-path", text: hit.relpath },
|
||||||
],
|
],
|
||||||
onClick: () => onOpen(`${run.home}/${hit.relpath}`, hit.relpath),
|
onClick: () => onOpen(`${run.home}/${hit.relpath}`, hit.relpath, hit.id),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-7
@@ -177,7 +177,9 @@ function b64ToBytes(b64: string): Uint8Array {
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
await win.listen<string>("pty-output", (e) => term.write(b64ToBytes(e.payload)));
|
await win.listen<string>("pty-output", (e) =>
|
||||||
|
term.write(b64ToBytes(e.payload)),
|
||||||
|
);
|
||||||
await win.listen("pty-exit", () =>
|
await win.listen("pty-exit", () =>
|
||||||
term.write(`\r\n\x1b[90m${t("panel.processEnded")}\x1b[0m\r\n`),
|
term.write(`\r\n\x1b[90m${t("panel.processEnded")}\x1b[0m\r\n`),
|
||||||
);
|
);
|
||||||
@@ -281,7 +283,9 @@ document.getElementById("panel-hide")!.addEventListener("click", () => {
|
|||||||
// Archivieren: Button klappt das Formular (Ordner/Beschreibung/Schlagwörter)
|
// Archivieren: Button klappt das Formular (Ordner/Beschreibung/Schlagwörter)
|
||||||
// auf; Abschicken wählt notfalls erst das Archiv-Home per Dialog.
|
// auf; Abschicken wählt notfalls erst das Archiv-Home per Dialog.
|
||||||
const archiveBtn = document.getElementById("panel-archive")!;
|
const archiveBtn = document.getElementById("panel-archive")!;
|
||||||
const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
const archiveForm = initArchiveForm(
|
||||||
|
archiveBtn,
|
||||||
|
async (meta) => {
|
||||||
try {
|
try {
|
||||||
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
|
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
|
||||||
project,
|
project,
|
||||||
@@ -289,12 +293,15 @@ const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
|||||||
let dir: string | undefined;
|
let dir: string | undefined;
|
||||||
if (!configured) {
|
if (!configured) {
|
||||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
const chosen = await open({ directory: true, title: t("panel.chooseArchiveDir") });
|
const chosen = await open({
|
||||||
|
directory: true,
|
||||||
|
title: t("panel.chooseArchiveDir"),
|
||||||
|
});
|
||||||
if (!chosen) return;
|
if (!chosen) return;
|
||||||
dir = chosen as string;
|
dir = chosen as string;
|
||||||
}
|
}
|
||||||
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
|
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
|
||||||
const path = await invoke<string>("panel_archive_cmd", {
|
await invoke<string>("panel_archive_cmd", {
|
||||||
project,
|
project,
|
||||||
dir,
|
dir,
|
||||||
folder: meta.folder ?? null,
|
folder: meta.folder ?? null,
|
||||||
@@ -302,20 +309,44 @@ const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
|||||||
tags: meta.tags,
|
tags: meta.tags,
|
||||||
});
|
});
|
||||||
flash(archiveBtn, "copied", 1400);
|
flash(archiveBtn, "copied", 1400);
|
||||||
invoke("reveal_path_cmd", { path });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
flash(archiveBtn, "error", 1400);
|
flash(archiveBtn, "error", 1400);
|
||||||
panelToast(`Archivieren fehlgeschlagen: ${e}`);
|
panelToast(`Archivieren fehlgeschlagen: ${e}`);
|
||||||
invoke("term_log", { msg: `archive: ${e}` });
|
invoke("term_log", { msg: `archive: ${e}` });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
// Ordner-Vorschläge aus dem Archiv; „Auf Platte legen" schreibt den
|
||||||
|
// Entwurf an einen frei gewählten Pfad (ohne Archiv, ohne Frontmatter).
|
||||||
|
folders: () => invoke<string[]>("archive_folders", { project }),
|
||||||
|
onSave: async () => {
|
||||||
|
try {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const path = await save({
|
||||||
|
title: t("archiveForm.saveTitle"),
|
||||||
|
defaultPath: "entwurf.md",
|
||||||
|
});
|
||||||
|
if (!path) return;
|
||||||
|
await view.flush();
|
||||||
|
await invoke("panel_save_as", { project, path });
|
||||||
|
flash(archiveBtn, "copied", 1400);
|
||||||
|
} catch (e) {
|
||||||
|
flash(archiveBtn, "error", 1400);
|
||||||
|
panelToast(`Speichern fehlgeschlagen: ${e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
archiveBtn.addEventListener("click", () => archiveForm.toggle());
|
archiveBtn.addEventListener("click", () => archiveForm.toggle());
|
||||||
|
|
||||||
// Splitter: Panelbreite ziehen.
|
// Splitter: Panelbreite ziehen.
|
||||||
splitter.addEventListener("mousedown", (e) => {
|
splitter.addEventListener("mousedown", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const move = (ev: MouseEvent) => {
|
const move = (ev: MouseEvent) => {
|
||||||
const w = Math.max(240, Math.min(window.innerWidth - 200, window.innerWidth - ev.clientX));
|
const w = Math.max(
|
||||||
|
240,
|
||||||
|
Math.min(window.innerWidth - 200, window.innerWidth - ev.clientX),
|
||||||
|
);
|
||||||
panel.style.width = `${w}px`;
|
panel.style.width = `${w}px`;
|
||||||
fit.fit();
|
fit.fit();
|
||||||
};
|
};
|
||||||
|
|||||||
+29
-37
@@ -12,9 +12,7 @@ function setup(pending: string | null = null) {
|
|||||||
const setTitle = vi.fn();
|
const setTitle = vi.fn();
|
||||||
const openWiki = vi.fn();
|
const openWiki = vi.fn();
|
||||||
const actions = {
|
const actions = {
|
||||||
rename: vi.fn(),
|
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
moveFolder: vi.fn(),
|
|
||||||
createFolder: vi.fn(),
|
createFolder: vi.fn(),
|
||||||
createDoc: vi.fn(),
|
createDoc: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -36,6 +34,7 @@ function setup(pending: string | null = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const doc = (relpath: string, name: string, extra: object = {}) => ({
|
const doc = (relpath: string, name: string, extra: object = {}) => ({
|
||||||
|
id: `id-${name}`,
|
||||||
relpath,
|
relpath,
|
||||||
name,
|
name,
|
||||||
title: name.toUpperCase(),
|
title: name.toUpperCase(),
|
||||||
@@ -61,9 +60,17 @@ const page = JSON.stringify({
|
|||||||
backlinks: 2,
|
backlinks: 2,
|
||||||
description: "Beschreibung",
|
description: "Beschreibung",
|
||||||
}),
|
}),
|
||||||
|
// Knotentext des Ordners „konzepte" — jeder Knoten hat einen.
|
||||||
|
doc("konzepte.md", "konzepte", { title: "konzepte" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "konzepte",
|
||||||
|
docs: [
|
||||||
|
doc("konzepte/2026-07-19_1005-neu.md", "neu"),
|
||||||
|
doc("konzepte/panel.md", "panel", { title: "panel" }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ name: "konzepte", docs: [doc("konzepte/2026-07-19_1005-neu.md", "neu")] },
|
|
||||||
{
|
{
|
||||||
name: "konzepte/panel",
|
name: "konzepte/panel",
|
||||||
docs: [doc("konzepte/panel/2026-07-19_1010-alt.md", "alt")],
|
docs: [doc("konzepte/panel/2026-07-19_1010-alt.md", "alt")],
|
||||||
@@ -115,7 +122,7 @@ describe("initWikiView — Baum", () => {
|
|||||||
// Auswahl des Knotens zeigt Inhalt UND Kindliste.
|
// Auswahl des Knotens zeigt Inhalt UND Kindliste.
|
||||||
document.querySelector<HTMLElement>(".wiki-tree summary .wiki-tree-name")!.click();
|
document.querySelector<HTMLElement>(".wiki-tree summary .wiki-tree-name")!.click();
|
||||||
await flush();
|
await flush();
|
||||||
expect(readDoc).toHaveBeenCalledWith("konzepte.md");
|
expect(readDoc).toHaveBeenCalledWith("id-konzepte");
|
||||||
expect(document.querySelector(".wiki-note-body h1")!.textContent).toBe("Inhalt");
|
expect(document.querySelector(".wiki-note-body h1")!.textContent).toBe("Inhalt");
|
||||||
expect(document.querySelector(".wiki-note-children .wiki-doc-title")!.textContent).toBe(
|
expect(document.querySelector(".wiki-note-children .wiki-doc-title")!.textContent).toBe(
|
||||||
"NEU",
|
"NEU",
|
||||||
@@ -129,7 +136,7 @@ describe("initWikiView — Notiz-Ansicht", () => {
|
|||||||
view.set(page);
|
view.set(page);
|
||||||
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click(); // WURZEL-DOC
|
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click(); // WURZEL-DOC
|
||||||
await flush();
|
await flush();
|
||||||
expect(readDoc).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md");
|
expect(readDoc).toHaveBeenCalledWith("id-wurzel-doc");
|
||||||
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("WURZEL-DOC");
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("WURZEL-DOC");
|
||||||
// Meta als Popup am Info-Knopf.
|
// Meta als Popup am Info-Knopf.
|
||||||
const pop = document.querySelector<HTMLElement>(".wiki-note-info-pop")!;
|
const pop = document.querySelector<HTMLElement>(".wiki-note-info-pop")!;
|
||||||
@@ -166,10 +173,7 @@ describe("initWikiView — Notiz-Ansicht", () => {
|
|||||||
expect(document.querySelector(".wiki-edit-preview h1")!.textContent).toBe("Neu");
|
expect(document.querySelector(".wiki-edit-preview h1")!.textContent).toBe("Neu");
|
||||||
document.querySelector<HTMLElement>(".wiki-form-submit")!.click();
|
document.querySelector<HTMLElement>(".wiki-form-submit")!.click();
|
||||||
await flush();
|
await flush();
|
||||||
expect(writeDoc).toHaveBeenCalledWith(
|
expect(writeDoc).toHaveBeenCalledWith("id-wurzel-doc", "# Neu\n\nGeändert.");
|
||||||
"2026-07-19_1000-wurzel-doc.md",
|
|
||||||
"# Neu\n\nGeändert.",
|
|
||||||
);
|
|
||||||
// Nach dem Speichern wieder die Anzeige.
|
// Nach dem Speichern wieder die Anzeige.
|
||||||
expect(document.querySelector(".wiki-note-editor")).toBeNull();
|
expect(document.querySelector(".wiki-note-editor")).toBeNull();
|
||||||
expect(document.querySelector(".wiki-note-body")).not.toBeNull();
|
expect(document.querySelector(".wiki-note-body")).not.toBeNull();
|
||||||
@@ -207,11 +211,11 @@ describe("initWikiView — Notiz-Ansicht", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("vorgemerkter Suchtreffer wählt die Notiz beim set() aus", async () => {
|
it("vorgemerkter Suchtreffer wählt die Notiz beim set() aus", async () => {
|
||||||
const { view, readDoc } = setup("konzepte/2026-07-19_1005-neu.md");
|
const { view, readDoc } = setup("id-neu");
|
||||||
view.set(page);
|
view.set(page);
|
||||||
await flush();
|
await flush();
|
||||||
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("NEU");
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("NEU");
|
||||||
expect(readDoc).toHaveBeenCalledWith("konzepte/2026-07-19_1005-neu.md");
|
expect(readDoc).toHaveBeenCalledWith("id-neu");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,7 +250,7 @@ describe("initWikiView — Aktionen", () => {
|
|||||||
expect(input.value).toBe("WURZEL-DOC");
|
expect(input.value).toBe("WURZEL-DOC");
|
||||||
input.value = "Neuer Titel";
|
input.value = "Neuer Titel";
|
||||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
expect(setTitle).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md", "Neuer Titel");
|
expect(setTitle).toHaveBeenCalledWith("id-wurzel-doc", "Neuer Titel");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Escape verwirft die Titel-Bearbeitung", async () => {
|
it("Escape verwirft die Titel-Bearbeitung", async () => {
|
||||||
@@ -267,7 +271,7 @@ describe("initWikiView — Aktionen", () => {
|
|||||||
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click();
|
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click();
|
||||||
await flush();
|
await flush();
|
||||||
document.querySelector<HTMLElement>(".wiki-note-actions .cmd-del")!.click();
|
document.querySelector<HTMLElement>(".wiki-note-actions .cmd-del")!.click();
|
||||||
expect(actions.remove).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md");
|
expect(actions.remove).toHaveBeenCalledWith("id-wurzel-doc");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Plus im Notiz-Kopf: Formular legt Dokument im gewählten Ordner an", () => {
|
it("Plus im Notiz-Kopf: Formular legt Dokument im gewählten Ordner an", () => {
|
||||||
@@ -280,7 +284,7 @@ describe("initWikiView — Aktionen", () => {
|
|||||||
const input = form.querySelector<HTMLInputElement>("input")!;
|
const input = form.querySelector<HTMLInputElement>("input")!;
|
||||||
input.value = "Deploy Notiz";
|
input.value = "Deploy Notiz";
|
||||||
form.querySelector<HTMLElement>(".wiki-form-submit")!.click();
|
form.querySelector<HTMLElement>(".wiki-form-submit")!.click();
|
||||||
expect(actions.createDoc).toHaveBeenCalledWith("konzepte", "Deploy Notiz");
|
expect(actions.createDoc).toHaveBeenCalledWith("id-konzepte", "Deploy Notiz");
|
||||||
expect(document.querySelector(".wiki-form")).toBeNull();
|
expect(document.querySelector(".wiki-form")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -299,35 +303,23 @@ describe("initWikiView — Aktionen", () => {
|
|||||||
expect(actions.createDoc).not.toHaveBeenCalled();
|
expect(actions.createDoc).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Rechtsklick auf Ordner: Menü legt Ordner darunter an", () => {
|
|
||||||
|
|
||||||
|
it("Rechtsklick auf Knoten: Menü legt darunter an (Eltern-ID)", () => {
|
||||||
const { view, actions } = setup();
|
const { view, actions } = setup();
|
||||||
view.set(page);
|
view.set(mergedPage);
|
||||||
const summary = document.querySelector<HTMLElement>(".wiki-tree summary")!;
|
document
|
||||||
summary.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
.querySelector<HTMLElement>(".wiki-tree summary")!
|
||||||
|
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||||
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
||||||
items.find((i) => i.textContent === "Neuer Ordner")!.click();
|
items.find((i) => i.textContent === "Neuer Ordner")!.click();
|
||||||
const input = document.querySelector<HTMLInputElement>(".wiki-form input")!;
|
const input = document.querySelector<HTMLInputElement>(".wiki-form input")!;
|
||||||
expect(input.value).toBe("konzepte/");
|
input.value = "neu";
|
||||||
input.value = "konzepte/neu";
|
|
||||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
expect(actions.createFolder).toHaveBeenCalledWith("konzepte/neu");
|
expect(actions.createFolder).toHaveBeenCalledWith("id-konzepte", "neu");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Rechtsklick auf Ordner: Dialog ändert den Ordnerpfad", () => {
|
it("Rechtsklick auf Dokument: Menü löscht mit ID", () => {
|
||||||
const { view, actions } = setup();
|
|
||||||
view.set(page);
|
|
||||||
const summary = document.querySelector<HTMLElement>(".wiki-tree summary")!;
|
|
||||||
summary.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
|
||||||
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
|
||||||
items.find((i) => i.textContent === "Ordnerpfad ändern")!.click();
|
|
||||||
const input = document.querySelector<HTMLInputElement>(".wiki-form input")!;
|
|
||||||
expect(input.value).toBe("konzepte");
|
|
||||||
input.value = "notizen/2026";
|
|
||||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
||||||
expect(actions.moveFolder).toHaveBeenCalledWith("konzepte", "notizen/2026");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Rechtsklick auf Dokument: Menü löscht mit relpath", () => {
|
|
||||||
const { view, actions } = setup();
|
const { view, actions } = setup();
|
||||||
view.set(page);
|
view.set(page);
|
||||||
document
|
document
|
||||||
@@ -335,7 +327,7 @@ describe("initWikiView — Aktionen", () => {
|
|||||||
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||||
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
||||||
items.find((i) => i.textContent === "Dokument löschen")!.click();
|
items.find((i) => i.textContent === "Dokument löschen")!.click();
|
||||||
expect(actions.remove).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md");
|
expect(actions.remove).toHaveBeenCalledWith("id-wurzel-doc");
|
||||||
expect(document.querySelector(".wiki-menu")).toBeNull();
|
expect(document.querySelector(".wiki-menu")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+83
-89
@@ -12,6 +12,9 @@ import { linkWikiRefs } from "./panel-view";
|
|||||||
import { deleteAction } from "./tiles";
|
import { deleteAction } from "./tiles";
|
||||||
|
|
||||||
interface DocEntry {
|
interface DocEntry {
|
||||||
|
/// Technische ID — bleibt über Umbenennen und Verschieben gleich; alle
|
||||||
|
/// Aktionen und die Auswahl laufen darüber. Der Pfad ist nur Eigenschaft.
|
||||||
|
id: string;
|
||||||
relpath: string;
|
relpath: string;
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -40,22 +43,21 @@ interface Page {
|
|||||||
/// Dokument-/Ordner-Operationen — laufen als Tauri-Commands, der neue Stand
|
/// Dokument-/Ordner-Operationen — laufen als Tauri-Commands, der neue Stand
|
||||||
/// kommt über den Wiki-Puffer zurück.
|
/// kommt über den Wiki-Puffer zurück.
|
||||||
export interface WikiActions {
|
export interface WikiActions {
|
||||||
rename(relpath: string, name: string): void;
|
remove(id: string): void;
|
||||||
remove(relpath: string): void;
|
/// Neues Kind unterhalb des Knotens (ID; "" = Wurzel).
|
||||||
moveFolder(folder: string, to: string): void;
|
createFolder(parent: string, name: string): void;
|
||||||
createFolder(folder: string): void;
|
createDoc(parent: string, name: string): void;
|
||||||
createDoc(folder: string, name: string): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WikiCallbacks {
|
export interface WikiCallbacks {
|
||||||
/// Leeren Puffer sofort mit der Übersicht füllen (eigenes Fenster).
|
/// Leeren Puffer sofort mit der Übersicht füllen (eigenes Fenster).
|
||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
/// Body eines Archiv-Dokuments (ohne Frontmatter) für die Notiz-Ansicht.
|
/// Body eines Archiv-Dokuments (ohne Frontmatter) für die Notiz-Ansicht.
|
||||||
readDoc(relpath: string): Promise<string>;
|
readDoc(id: string): Promise<string>;
|
||||||
/// Body einer Archiv-Notiz zurückschreiben (Bearbeiten im Archiv).
|
/// Body einer Archiv-Notiz zurückschreiben (Bearbeiten im Archiv).
|
||||||
writeDoc(relpath: string, text: string): Promise<void>;
|
writeDoc(id: string, text: string): Promise<void>;
|
||||||
/// Anzeige-Titel einer Notiz setzen (Klick auf den Titel).
|
/// Anzeige-Titel einer Notiz setzen (Klick auf den Titel).
|
||||||
setTitle(relpath: string, title: string): void;
|
setTitle(id: string, title: string): void;
|
||||||
/// Wiki-Ziel laden (`tag:` = Übersicht in den Puffer, Einstiegs-Chip).
|
/// Wiki-Ziel laden (`tag:` = Übersicht in den Puffer, Einstiegs-Chip).
|
||||||
openWiki(name: string): void;
|
openWiki(name: string): void;
|
||||||
/// Vorgemerkte Auswahl (Suchtreffer-Sprung): einmalig abholen.
|
/// Vorgemerkte Auswahl (Suchtreffer-Sprung): einmalig abholen.
|
||||||
@@ -134,9 +136,9 @@ function buildTree(p: Page): TreeNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiView {
|
export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiView {
|
||||||
/// Zugeklappte Ordner und Auswahl — Auswahl ist ein Ordnerpfad ("" =
|
/// Zugeklappte Knoten und Auswahl — beides über die technische ID der
|
||||||
/// Wurzel) oder ein Dokument-relpath (endet auf .md); beides überlebt
|
/// Notiz ("" = Archiv-Wurzel); übersteht Umbenennen, Verschieben und
|
||||||
/// Puffer-Updates innerhalb der Ansicht.
|
/// Puffer-Updates.
|
||||||
const closed = new Set<string>();
|
const closed = new Set<string>();
|
||||||
let selected = "";
|
let selected = "";
|
||||||
let current: Page | null = null;
|
let current: Page | null = null;
|
||||||
@@ -146,31 +148,41 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
/// Bearbeitungsmodus der aktuellen Notiz (Editor statt Anzeige).
|
/// Bearbeitungsmodus der aktuellen Notiz (Editor statt Anzeige).
|
||||||
let editing = false;
|
let editing = false;
|
||||||
|
|
||||||
const isDoc = (sel: string) => sel.endsWith(".md");
|
/// Ist die ID ein Blatt (Notiz ohne Kinder)?
|
||||||
|
const isLeaf = (id: string) => !!id && !nodeById(id);
|
||||||
|
|
||||||
function findNode(path: string): TreeNode | null {
|
/// Notiz zur ID (Blatt oder Knotentext).
|
||||||
let n = tree;
|
function findDoc(id: string): DocEntry | null {
|
||||||
if (!path) return n;
|
if (!current || !id) return null;
|
||||||
for (const part of path.split("/")) {
|
|
||||||
const next = n.children.get(part);
|
|
||||||
if (!next) return null;
|
|
||||||
n = next;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findDoc(relpath: string): DocEntry | null {
|
|
||||||
if (!current) return null;
|
|
||||||
for (const f of current.folders) {
|
for (const f of current.folders) {
|
||||||
const hit = f.docs.find((d) => d.relpath === relpath);
|
const hit = f.docs.find((d) => d.id === id);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baumknoten zur ID seines Knotentexts ("" = Wurzel).
|
||||||
|
function nodeById(id: string, from: TreeNode = tree): TreeNode | null {
|
||||||
|
if (!id) return tree;
|
||||||
|
if (from.content?.id === id) return from;
|
||||||
|
for (const child of from.children.values()) {
|
||||||
|
const hit = nodeById(id, child);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elternknoten einer Notiz — Ziel beim Anlegen von Kindern.
|
||||||
|
function parentOf(id: string, from: TreeNode = tree): TreeNode | null {
|
||||||
|
if (from.docs.some((d) => d.id === id)) return from;
|
||||||
|
for (const child of from.children.values()) {
|
||||||
|
if (child.content?.id === id) return from;
|
||||||
|
const hit = parentOf(id, child);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ordnerpfad eines Dokument-relpaths.
|
|
||||||
const docFolder = (relpath: string) =>
|
|
||||||
relpath.includes("/") ? relpath.slice(0, relpath.lastIndexOf("/")) : "";
|
|
||||||
|
|
||||||
/// Wikilink lokal auflösen (Name, Titel, Stem — Slug-Vergleich wie im
|
/// Wikilink lokal auflösen (Name, Titel, Stem — Slug-Vergleich wie im
|
||||||
/// Backend); ohne Treffer übernimmt das Backend (Dokument-Tab).
|
/// Backend); ohne Treffer übernimmt das Backend (Dokument-Tab).
|
||||||
@@ -179,7 +191,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
for (const f of current?.folders ?? []) {
|
for (const f of current?.folders ?? []) {
|
||||||
for (const d of f.docs) {
|
for (const d of f.docs) {
|
||||||
if ([d.name, d.title, stem(d.relpath)].some((s) => slugify(s) === want)) {
|
if ([d.name, d.title, stem(d.relpath)].some((s) => slugify(s) === want)) {
|
||||||
select(d.relpath);
|
select(d.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,23 +250,24 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
}
|
}
|
||||||
|
|
||||||
function folderRow(name: string, full: string, node: TreeNode): HTMLElement {
|
function folderRow(name: string, full: string, node: TreeNode): HTMLElement {
|
||||||
|
const key = node.content?.id ?? full;
|
||||||
const det = document.createElement("details");
|
const det = document.createElement("details");
|
||||||
det.className = "wiki-tree-folder";
|
det.className = "wiki-tree-folder";
|
||||||
det.open = !closed.has(full);
|
det.open = !closed.has(key);
|
||||||
det.addEventListener("toggle", () => {
|
det.addEventListener("toggle", () => {
|
||||||
if (det.open) closed.delete(full);
|
if (det.open) closed.delete(key);
|
||||||
else closed.add(full);
|
else closed.add(key);
|
||||||
});
|
});
|
||||||
const sum = document.createElement("summary");
|
const sum = document.createElement("summary");
|
||||||
sum.classList.add(node.content ? "has-content" : "book");
|
sum.classList.add(node.content ? "has-content" : "book");
|
||||||
if (full === selected || node.content?.relpath === selected) {
|
if (node.content?.id === selected) {
|
||||||
sum.classList.add("active");
|
sum.classList.add("active");
|
||||||
}
|
}
|
||||||
// Die ganze Zeile wählt die Notiz aus (wie in Trilium); NUR der Pfeil
|
// Die ganze Zeile wählt die Notiz aus (wie in Trilium); NUR der Pfeil
|
||||||
// klappt — deshalb das summary-Default unterbinden und selbst schalten.
|
// klappt — deshalb das summary-Default unterbinden und selbst schalten.
|
||||||
sum.addEventListener("click", (e) => {
|
sum.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
select(full);
|
select(node.content?.id ?? "");
|
||||||
});
|
});
|
||||||
const arrow = document.createElement("span");
|
const arrow = document.createElement("span");
|
||||||
arrow.className = "wiki-tree-arrow";
|
arrow.className = "wiki-tree-arrow";
|
||||||
@@ -270,16 +283,10 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
sum.addEventListener("contextmenu", (e) => {
|
sum.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const id = node.content?.id ?? "";
|
||||||
openMenu(e.clientX, e.clientY, [
|
openMenu(e.clientX, e.clientY, [
|
||||||
{ label: t("wiki.newDoc"), run: () => newDocForm(full) },
|
{ label: t("wiki.newDoc"), run: () => newDocForm(id) },
|
||||||
{ label: t("wiki.newFolder"), run: () => newFolderForm(full) },
|
{ label: t("wiki.newFolder"), run: () => newFolderForm(id) },
|
||||||
{
|
|
||||||
label: t("wiki.folderRename"),
|
|
||||||
run: () =>
|
|
||||||
openForm(t("wiki.folderRename"), t("wiki.folderPath"), full, (v) =>
|
|
||||||
cb.actions.moveFolder(full, v),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
sum.append(arrow, typeIcon("node"), label);
|
sum.append(arrow, typeIcon("node"), label);
|
||||||
@@ -290,12 +297,12 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
function docRow(doc: DocEntry): HTMLElement {
|
function docRow(doc: DocEntry): HTMLElement {
|
||||||
const row = document.createElement("button");
|
const row = document.createElement("button");
|
||||||
row.className = "wiki-tree-doc";
|
row.className = "wiki-tree-doc";
|
||||||
if (doc.relpath === selected) row.classList.add("active");
|
if (doc.id === selected) row.classList.add("active");
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.className = "wiki-tree-name";
|
label.className = "wiki-tree-name";
|
||||||
label.textContent = doc.title;
|
label.textContent = doc.title;
|
||||||
row.append(typeIcon("doc"), label);
|
row.append(typeIcon("doc"), label);
|
||||||
row.addEventListener("click", () => select(doc.relpath));
|
row.addEventListener("click", () => select(doc.id));
|
||||||
row.addEventListener("contextmenu", (e) => {
|
row.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -303,19 +310,12 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
{
|
{
|
||||||
label: t("wiki.editDoc"),
|
label: t("wiki.editDoc"),
|
||||||
run: () => {
|
run: () => {
|
||||||
select(doc.relpath);
|
select(doc.id);
|
||||||
editing = true;
|
editing = true;
|
||||||
renderMain();
|
renderMain();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{ label: t("wiki.deleteDoc"), run: () => cb.actions.remove(doc.id) },
|
||||||
label: t("wiki.renameDoc"),
|
|
||||||
run: () =>
|
|
||||||
openForm(t("wiki.renameDoc"), t("wiki.docName"), doc.name, (v) =>
|
|
||||||
cb.actions.rename(doc.relpath, v),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ label: t("wiki.deleteDoc"), run: () => cb.actions.remove(doc.relpath) },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
@@ -362,13 +362,15 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
|
|
||||||
// ---------- Anlege-Formular (Ordner/Dokument) ----------
|
// ---------- Anlege-Formular (Ordner/Dokument) ----------
|
||||||
|
|
||||||
function newDocForm(folder: string) {
|
function newDocForm(parent: string) {
|
||||||
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) => cb.actions.createDoc(folder, v));
|
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) =>
|
||||||
|
cb.actions.createDoc(parent, v),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function newFolderForm(folder: string) {
|
function newFolderForm(parent: string) {
|
||||||
openForm(t("wiki.newFolder"), t("wiki.folderPath"), folder ? `${folder}/` : "", (v) =>
|
openForm(t("wiki.newFolder"), t("wiki.docName"), "", (v) =>
|
||||||
cb.actions.createFolder(v),
|
cb.actions.createFolder(parent, v),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,7 +432,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
function goBack() {
|
function goBack() {
|
||||||
while (history.length) {
|
while (history.length) {
|
||||||
const prev = history.pop()!;
|
const prev = history.pop()!;
|
||||||
const exists = isDoc(prev) ? !!findDoc(prev) : !!findNode(prev);
|
const exists = !!findDoc(prev) || !!nodeById(prev);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
select(prev, false);
|
select(prev, false);
|
||||||
return;
|
return;
|
||||||
@@ -461,7 +463,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
// Klick auf den Titel bearbeitet ihn direkt (Frontmatter-Titel der
|
// Klick auf den Titel bearbeitet ihn direkt (Frontmatter-Titel der
|
||||||
// Notiz); Enter übernimmt, Escape verwirft. Der technische Datei-/
|
// Notiz); Enter übernimmt, Escape verwirft. Der technische Datei-/
|
||||||
// Ordnername bleibt davon unberührt — der sitzt im Baum-Kontextmenü.
|
// Ordnername bleibt davon unberührt — der sitzt im Baum-Kontextmenü.
|
||||||
const titleDoc = isDoc(selected) ? findDoc(selected) : findNode(selected)?.content;
|
const titleDoc = findDoc(selected) ?? nodeById(selected)?.content;
|
||||||
if (titleDoc && !editing) {
|
if (titleDoc && !editing) {
|
||||||
h.classList.add("editable");
|
h.classList.add("editable");
|
||||||
h.title = t("wiki.titleEdit");
|
h.title = t("wiki.titleEdit");
|
||||||
@@ -471,7 +473,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
input.value = title;
|
input.value = title;
|
||||||
input.addEventListener("keydown", (e) => {
|
input.addEventListener("keydown", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.key === "Enter") cb.setTitle(titleDoc.relpath, input.value.trim());
|
if (e.key === "Enter") cb.setTitle(titleDoc.id, input.value.trim());
|
||||||
else if (e.key === "Escape") input.replaceWith(h);
|
else if (e.key === "Escape") input.replaceWith(h);
|
||||||
});
|
});
|
||||||
input.addEventListener("blur", () => input.replaceWith(h));
|
input.addEventListener("blur", () => input.replaceWith(h));
|
||||||
@@ -528,9 +530,9 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
function noteBody(doc: DocEntry): HTMLElement {
|
function noteBody(doc: DocEntry): HTMLElement {
|
||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
body.className = "wiki-note-body";
|
body.className = "wiki-note-body";
|
||||||
cb.readDoc(doc.relpath).then(
|
cb.readDoc(doc.id).then(
|
||||||
(text) => {
|
(text) => {
|
||||||
if (selected !== doc.relpath && findNode(selected)?.content !== doc) return;
|
if (selected !== doc.id) return;
|
||||||
body.innerHTML = renderMarkdown(text);
|
body.innerHTML = renderMarkdown(text);
|
||||||
linkWikiRefs(body, followWikiLink);
|
linkWikiRefs(body, followWikiLink);
|
||||||
},
|
},
|
||||||
@@ -563,7 +565,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
preview.innerHTML = renderMarkdown(area.value);
|
preview.innerHTML = renderMarkdown(area.value);
|
||||||
linkWikiRefs(preview, followWikiLink);
|
linkWikiRefs(preview, followWikiLink);
|
||||||
};
|
};
|
||||||
cb.readDoc(doc.relpath).then(
|
cb.readDoc(doc.id).then(
|
||||||
(text) => {
|
(text) => {
|
||||||
area.value = text;
|
area.value = text;
|
||||||
draw();
|
draw();
|
||||||
@@ -576,7 +578,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
);
|
);
|
||||||
area.addEventListener("input", draw);
|
area.addEventListener("input", draw);
|
||||||
const save = () => {
|
const save = () => {
|
||||||
cb.writeDoc(doc.relpath, area.value).then(
|
cb.writeDoc(doc.id, area.value).then(
|
||||||
() => {
|
() => {
|
||||||
editing = false;
|
editing = false;
|
||||||
renderMain();
|
renderMain();
|
||||||
@@ -631,7 +633,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
editing = true;
|
editing = true;
|
||||||
renderMain();
|
renderMain();
|
||||||
});
|
});
|
||||||
return [edit, deleteAction(t("wiki.deleteDoc"), () => cb.actions.remove(doc.relpath))];
|
return [edit, deleteAction(t("wiki.deleteDoc"), () => cb.actions.remove(doc.id))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kindzeile im Dokument-Abschnitt: Titel links, Anlage-/Änderungsdatum
|
/// Kindzeile im Dokument-Abschnitt: Titel links, Anlage-/Änderungsdatum
|
||||||
@@ -664,7 +666,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
const main = container.querySelector<HTMLElement>(".wiki-main")!;
|
const main = container.querySelector<HTMLElement>(".wiki-main")!;
|
||||||
main.textContent = "";
|
main.textContent = "";
|
||||||
|
|
||||||
if (isDoc(selected)) {
|
if (isLeaf(selected)) {
|
||||||
const doc = findDoc(selected);
|
const doc = findDoc(selected);
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@@ -677,9 +679,9 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = findNode(selected);
|
const node = nodeById(selected);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const title = node.content?.title ?? (selected ? selected.split("/").pop()! : t("wiki.archive"));
|
const title = node.content?.title ?? t("wiki.archive");
|
||||||
const children = [...node.children.keys()].length + node.docs.length;
|
const children = [...node.children.keys()].length + node.docs.length;
|
||||||
const meta: string[] = node.content ? metaParts(node.content) : [];
|
const meta: string[] = node.content ? metaParts(node.content) : [];
|
||||||
|
|
||||||
@@ -687,11 +689,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
add.className = "wiki-add";
|
add.className = "wiki-add";
|
||||||
add.title = t("wiki.newDoc");
|
add.title = t("wiki.newDoc");
|
||||||
add.textContent = "+";
|
add.textContent = "+";
|
||||||
add.addEventListener("click", () =>
|
add.addEventListener("click", () => newDocForm(selected));
|
||||||
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) =>
|
|
||||||
cb.actions.createDoc(selected, v),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const actions: HTMLElement[] = node.content
|
const actions: HTMLElement[] = node.content
|
||||||
? [add, ...docActions(node.content)]
|
? [add, ...docActions(node.content)]
|
||||||
: [add];
|
: [add];
|
||||||
@@ -731,13 +729,15 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
});
|
});
|
||||||
list.append(caption);
|
list.append(caption);
|
||||||
for (const [name, child] of [...node.children].sort((a, b) => a[0].localeCompare(b[0]))) {
|
for (const [name, child] of [...node.children].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||||
const full = selected ? `${selected}/${name}` : name;
|
const childId = child.content?.id ?? "";
|
||||||
list.append(
|
list.append(
|
||||||
childRow(child.content?.title ?? name, child.content ?? null, () => select(full)),
|
childRow(child.content?.title ?? name, child.content ?? null, () =>
|
||||||
|
select(childId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const doc of [...node.docs].sort((a, b) => a.title.localeCompare(b.title))) {
|
for (const doc of [...node.docs].sort((a, b) => a.title.localeCompare(b.title))) {
|
||||||
list.append(childRow(doc.title, doc, () => select(doc.relpath)));
|
list.append(childRow(doc.title, doc, () => select(doc.id)));
|
||||||
}
|
}
|
||||||
main.append(list);
|
main.append(list);
|
||||||
}
|
}
|
||||||
@@ -746,15 +746,9 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
if (remember && target !== selected) history.push(selected);
|
if (remember && target !== selected) history.push(selected);
|
||||||
editing = false;
|
editing = false;
|
||||||
selected = target;
|
selected = target;
|
||||||
// Elternordner eines gewählten Dokuments aufklappen, sonst bleibt die
|
// Vorfahren aufklappen, sonst bleibt die Hervorhebung unsichtbar.
|
||||||
// Hervorhebung unsichtbar.
|
for (let p = parentOf(target); p?.content; p = parentOf(p.content.id)) {
|
||||||
if (isDoc(target)) {
|
closed.delete(p.content.id);
|
||||||
const parts = docFolder(target).split("/").filter(Boolean);
|
|
||||||
let path = "";
|
|
||||||
for (const part of parts) {
|
|
||||||
path = path ? `${path}/${part}` : part;
|
|
||||||
closed.delete(path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (current) render();
|
if (current) render();
|
||||||
}
|
}
|
||||||
@@ -802,7 +796,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
}
|
}
|
||||||
// Die gewählte Notiz kann nach Umbenennen/Löschen weg sein — dann
|
// Die gewählte Notiz kann nach Umbenennen/Löschen weg sein — dann
|
||||||
// zurück zur Wurzel.
|
// zurück zur Wurzel.
|
||||||
const exists = isDoc(selected) ? !!findDoc(selected) : !!findNode(selected);
|
const exists = !!findDoc(selected) || !!nodeById(selected);
|
||||||
if (selected && !exists) {
|
if (selected && !exists) {
|
||||||
selected = "";
|
selected = "";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user