Technische ID als Identität der Notizen; Pfad wird zur Eigenschaft
Bisher war der Dateipfad die Identität einer Notiz: Umbenennen brach Verweise, und der Pfad tauchte in der Oberfläche auf (Tooltips, Menüpunkte "Dateiname ändern"/"Ordnerpfad ändern"). Jede Notiz trägt jetzt eine technische ID im Frontmatter (id: <uuid>), die nie wechselt; der Systempfad ist nur noch eine Eigenschaft. - ensure_ids ergänzt fehlende IDs beim Seitenaufbau (wie ensure_node_texts); frontmatter() schreibt sie bei jedem Anlegen als erste Zeile. - Doc, WikiDocEntry, FolderNode und Suchtreffer führen die ID mit; resolve_id löst sie auf den aktuellen relpath auf. - Commands sprechen IDs: archive_read/write/set_title/delete nehmen `id`, archive_create_doc/create_folder die ID des Elternknotens plus Namen, panel_archive_cmd den Zielknoten als ID. - Frontend adressiert durchgehend über IDs: Auswahl, Klapp-Zustand, Verlauf des Zurück-Knopfs, Wikilink-Sprünge, Suchtreffer-Vormerkung und der Zielordner-Baum des Archiv-Dialogs. - Der Pfad ist aus der Oberfläche verschwunden: die Menüpunkte zum Umbenennen von Datei und Ordner samt Commands (archive_rename, archive_move, archive_move_folder) sind entfallen — es gibt nur noch den Titel, Name und Titel können nicht mehr auseinanderlaufen. Offen dadurch: Verschieben im Baum fehlt (ToDo: Drag & Drop). Archiv-Dialog nebenbei überarbeitet: modales Popup im Design der Archiv-Ansicht mit Abbrechen und zuverlässigem Escape, Zielordner über einen einklappbaren Baum statt Pfadeingabe, zusätzlicher Knopf "Auf Platte legen" (panel_save_as); Archivieren öffnet keinen Dateimanager mehr.
This commit is contained in:
+2
-3
@@ -66,13 +66,12 @@ fn main() {
|
||||
"search_run",
|
||||
"panel_load",
|
||||
"wiki_open",
|
||||
"archive_move",
|
||||
"archive_rename",
|
||||
"archive_read",
|
||||
"archive_write",
|
||||
"archive_set_title",
|
||||
"archive_folders",
|
||||
"panel_save_as",
|
||||
"archive_delete",
|
||||
"archive_move_folder",
|
||||
"archive_create_folder",
|
||||
"archive_create_doc",
|
||||
"open_panel_window",
|
||||
|
||||
@@ -24,12 +24,11 @@
|
||||
"allow-archive-create-doc",
|
||||
"allow-archive-create-folder",
|
||||
"allow-archive-delete",
|
||||
"allow-archive-move",
|
||||
"allow-archive-move-folder",
|
||||
"allow-archive-rename",
|
||||
"allow-archive-read",
|
||||
"allow-archive-write",
|
||||
"allow-archive-set-title",
|
||||
"allow-archive-folders",
|
||||
"allow-panel-save-as",
|
||||
"allow-buffer-read",
|
||||
"allow-commands-delete",
|
||||
"allow-enabled-modules",
|
||||
|
||||
@@ -23,12 +23,11 @@
|
||||
"allow-archive-create-doc",
|
||||
"allow-archive-create-folder",
|
||||
"allow-archive-delete",
|
||||
"allow-archive-move",
|
||||
"allow-archive-move-folder",
|
||||
"allow-archive-rename",
|
||||
"allow-archive-read",
|
||||
"allow-archive-write",
|
||||
"allow-archive-set-title",
|
||||
"allow-archive-folders",
|
||||
"allow-panel-save-as",
|
||||
"allow-buffer-read",
|
||||
"allow-commands-delete",
|
||||
"allow-enabled-modules",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-archive-folders"
|
||||
description = "Enables the archive_folders command without any pre-configured scope."
|
||||
commands.allow = ["archive_folders"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-archive-folders"
|
||||
description = "Denies the archive_folders command without any pre-configured scope."
|
||||
commands.deny = ["archive_folders"]
|
||||
@@ -1,11 +0,0 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-archive-move"
|
||||
description = "Enables the archive_move command without any pre-configured scope."
|
||||
commands.allow = ["archive_move"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-archive-move"
|
||||
description = "Denies the archive_move command without any pre-configured scope."
|
||||
commands.deny = ["archive_move"]
|
||||
@@ -1,11 +0,0 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-archive-move-folder"
|
||||
description = "Enables the archive_move_folder command without any pre-configured scope."
|
||||
commands.allow = ["archive_move_folder"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-archive-move-folder"
|
||||
description = "Denies the archive_move_folder command without any pre-configured scope."
|
||||
commands.deny = ["archive_move_folder"]
|
||||
@@ -1,11 +0,0 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-archive-rename"
|
||||
description = "Enables the archive_rename command without any pre-configured scope."
|
||||
commands.allow = ["archive_rename"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-archive-rename"
|
||||
description = "Denies the archive_rename command without any pre-configured scope."
|
||||
commands.deny = ["archive_rename"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-panel-save-as"
|
||||
description = "Enables the panel_save_as command without any pre-configured scope."
|
||||
commands.allow = ["panel_save_as"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-panel-save-as"
|
||||
description = "Denies the panel_save_as command without any pre-configured scope."
|
||||
commands.deny = ["panel_save_as"]
|
||||
@@ -311,13 +311,12 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
||||
terminal::search_run,
|
||||
terminal::panel_load,
|
||||
terminal::wiki_open,
|
||||
terminal::archive_move,
|
||||
terminal::archive_rename,
|
||||
terminal::archive_read,
|
||||
terminal::archive_write,
|
||||
terminal::archive_set_title,
|
||||
terminal::archive_folders,
|
||||
terminal::panel_save_as,
|
||||
terminal::archive_delete,
|
||||
terminal::archive_move_folder,
|
||||
terminal::archive_create_folder,
|
||||
terminal::archive_create_doc,
|
||||
terminal::open_panel_window
|
||||
|
||||
@@ -436,8 +436,25 @@ pub(crate) fn panel_archive_cmd(
|
||||
description: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<String, String> {
|
||||
// Der Zielordner kommt als Knoten-ID; erst hier wird daraus ein Pfad.
|
||||
let folder = match folder.filter(|f| !f.trim().is_empty()) {
|
||||
Some(id) => {
|
||||
let home = require_archive_home(&project)?;
|
||||
let rel = crate::domain::archive_index::resolve_id(&home, &id)?;
|
||||
let stem = std::path::Path::new(&rel)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
Some(match rel.rsplit_once('/') {
|
||||
Some((head, _)) => format!("{head}/{stem}"),
|
||||
None => stem,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let meta = ArchiveMeta {
|
||||
folder: folder.filter(|f| !f.trim().is_empty()),
|
||||
folder,
|
||||
description: description.filter(|d| !d.trim().is_empty()),
|
||||
tags: tags.unwrap_or_default(),
|
||||
};
|
||||
|
||||
@@ -282,7 +282,8 @@ pub(crate) fn frontmatter(
|
||||
meta: &ArchiveMeta,
|
||||
) -> String {
|
||||
let mut fm = format!(
|
||||
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
|
||||
"---\nid: {}\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
|
||||
uuid::Uuid::new_v4(),
|
||||
title.replace('"', "'"),
|
||||
);
|
||||
if let Some(d) = &meta.description {
|
||||
@@ -542,7 +543,9 @@ mod tests {
|
||||
fn frontmatter_mit_und_ohne_meta() {
|
||||
let leer = ArchiveMeta::default();
|
||||
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &leer);
|
||||
assert!(fm.starts_with("---\ntitle: \"Titel\"\n"));
|
||||
// Erste Zeile ist die technische ID, dann der Titel.
|
||||
assert!(fm.starts_with("---\nid: "));
|
||||
assert!(fm.contains("\ntitle: \"Titel\"\n"));
|
||||
assert!(!fm.contains("description:"));
|
||||
assert!(!fm.contains("tags:"));
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ use crate::domain::archive::{parse_frontmatter, parse_tag_list, slugify, strip_s
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub(crate) struct Doc {
|
||||
/// Pfad relativ zum Archiv-Home.
|
||||
/// Technische ID aus dem Frontmatter — bleibt über Umbenennen und
|
||||
/// Verschieben hinweg gleich; alle Verweise laufen darüber.
|
||||
pub(crate) id: String,
|
||||
/// Pfad relativ zum Archiv-Home (Eigenschaft, keine Identität).
|
||||
pub(crate) relpath: String,
|
||||
/// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel.
|
||||
pub(crate) name: String,
|
||||
@@ -113,6 +116,7 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
|
||||
.display()
|
||||
.to_string();
|
||||
let doc = Doc {
|
||||
id: fm.get("id").cloned().unwrap_or_default(),
|
||||
relpath,
|
||||
title: fm.get("title").unwrap_or(&name).clone(),
|
||||
description: fm.get("description").cloned(),
|
||||
@@ -164,6 +168,8 @@ pub(crate) struct WikiFolder {
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiDocEntry {
|
||||
/// Technische ID — Adressat aller Aktionen.
|
||||
pub(crate) id: String,
|
||||
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
|
||||
/// Zeilen-Aktionen (umbenennen, löschen).
|
||||
pub(crate) relpath: String,
|
||||
@@ -230,9 +236,48 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
})
|
||||
}
|
||||
|
||||
/// Ordner-Knoten für den Zielordner-Baum des Archiv-Dialogs: Pfad plus
|
||||
/// Anzeige-Titel aus dem Knotentext (`<name>.md`/`.html` daneben) — dieselbe
|
||||
/// logische Sicht wie die Archiv-Ansicht.
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct FolderNode {
|
||||
/// Technische ID des Knotentexts — Adressat der Auswahl.
|
||||
pub(crate) id: String,
|
||||
/// Pfad relativ zum Archiv-Home (nur Eigenschaft).
|
||||
pub(crate) path: String,
|
||||
/// Titel des Knotentexts, sonst der Ordnername.
|
||||
pub(crate) title: String,
|
||||
}
|
||||
|
||||
pub(crate) fn folder_nodes(home: &Path) -> Result<Vec<FolderNode>, String> {
|
||||
let mut paths = Vec::new();
|
||||
folder_paths(home, home, &mut paths)?;
|
||||
paths.sort();
|
||||
let mut out = Vec::new();
|
||||
for path in paths {
|
||||
let name = Path::new(&path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
// Knotentext daneben: erst .md, dann .html.
|
||||
let fm = ["md", "html"]
|
||||
.iter()
|
||||
.map(|ext| home.join(&path).with_extension(ext))
|
||||
.find(|p| p.is_file())
|
||||
.and_then(|p| fs::read_to_string(p).ok())
|
||||
.map(|text| parse_frontmatter(&text))
|
||||
.unwrap_or_default();
|
||||
let title = fm.get("title").cloned().unwrap_or_else(|| name.clone());
|
||||
let id = fm.get("id").cloned().unwrap_or_default();
|
||||
out.push(FolderNode { id, path, title });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Alle Ordner-Relpaths unterhalb von `dir`, rekursiv; versteckte Einträge
|
||||
/// (Punkt-Präfix) bleiben außen vor — wie beim Dokument-Scan.
|
||||
fn folder_paths(home: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
|
||||
pub(crate) fn folder_paths(home: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
|
||||
let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
@@ -264,6 +309,7 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
|
||||
.map(|c| c[..10].to_string())
|
||||
});
|
||||
WikiDocEntry {
|
||||
id: doc.id.clone(),
|
||||
relpath: doc.relpath.clone(),
|
||||
name: doc.name.clone(),
|
||||
title: doc.title.clone(),
|
||||
@@ -275,6 +321,15 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Löst eine technische ID auf den aktuellen relpath auf.
|
||||
pub(crate) fn resolve_id(home: &Path, id: &str) -> Result<String, String> {
|
||||
scan_archive(home)?
|
||||
.into_iter()
|
||||
.find(|d| d.id == id)
|
||||
.map(|d| d.relpath)
|
||||
.ok_or_else(|| format!("keine Notiz mit ID {id}"))
|
||||
}
|
||||
|
||||
/// Löst ein Wikilink-Ziel (Name, Titel oder Datei-Stem) gegen das Archiv auf
|
||||
/// und liefert den relpath des Dokuments.
|
||||
pub(crate) fn resolve_doc(home: &Path, target: &str) -> Result<String, String> {
|
||||
|
||||
@@ -49,24 +49,6 @@ fn fresh_target(target: &Path, home: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verschiebt ein Dokument in einen anderen Ordner (leer = Wurzel); der
|
||||
/// Zielordner entsteht bei Bedarf. Liefert den neuen relpath.
|
||||
pub(crate) fn move_doc(home: &Path, relpath: &str, folder: &str) -> Result<String, String> {
|
||||
let src = doc_path(home, relpath)?;
|
||||
if !folder.is_empty() {
|
||||
checked_rel(folder)?;
|
||||
}
|
||||
let file_name = src.file_name().unwrap();
|
||||
let dir = home.join(folder);
|
||||
let target = dir.join(file_name);
|
||||
if target == src {
|
||||
return Ok(relpath.to_string());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
/// Benennt ein Dokument um: neuer Name als Slug hinter dem (erhaltenen)
|
||||
/// Zeitstempel. Liefert den neuen relpath.
|
||||
@@ -194,6 +176,49 @@ pub(crate) fn move_folder(home: &Path, folder: &str, to: &str) -> Result<(), Str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Zweite Invariante des Notizmodells: JEDES Dokument trägt eine technische
|
||||
/// ID im Frontmatter (`id:`). Sie entsteht einmal und bleibt — Titel,
|
||||
/// Dateiname und Ordner dürfen sich danach beliebig ändern, Verweise laufen
|
||||
/// über die ID. Dateien ohne ID (von Hand angelegt, Altbestand) bekommen hier
|
||||
/// eine.
|
||||
pub(crate) fn ensure_ids(home: &Path) -> Result<(), String> {
|
||||
for path in md_files(home)? {
|
||||
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if crate::domain::archive::parse_frontmatter(&text).contains_key("id") {
|
||||
continue;
|
||||
}
|
||||
let line = format!("id: {}", uuid::Uuid::new_v4());
|
||||
let out = match text.strip_prefix("---\n") {
|
||||
// Frontmatter vorhanden: ID als erste Zeile einfügen.
|
||||
Some(rest) => format!("---\n{line}\n{rest}"),
|
||||
// Ohne Frontmatter: einen Block davor setzen, Rumpf bleibt.
|
||||
None => format!("---\n{line}\n---\n\n{text}"),
|
||||
};
|
||||
fs::write(&path, out).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Alle Markdown-Dateien unterhalb von `dir`, rekursiv; versteckte Einträge
|
||||
/// bleiben außen vor.
|
||||
fn md_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
let mut out = Vec::new();
|
||||
for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
out.extend(md_files(&path)?);
|
||||
} else if name.ends_with(".md") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Invariante des Notizmodells: JEDER Knoten besitzt eine Textdatei — der
|
||||
/// Ordnername ist nur technische Verwaltung, Titel und Inhalt stehen im
|
||||
/// gleichnamigen Dokument daneben (Wurzel: `index.md`). Läuft vor jedem
|
||||
@@ -260,35 +285,13 @@ mod tests {
|
||||
home
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verschieben_legt_ordner_an_und_liefert_relpath() {
|
||||
let home = archiv();
|
||||
let rel = move_doc(&home, "2026-07-19_1000-adr-logging.md", "adr/2026").unwrap();
|
||||
assert_eq!(rel, "adr/2026/2026-07-19_1000-adr-logging.md");
|
||||
assert!(home.join(&rel).is_file());
|
||||
|
||||
// In die Wurzel zurück: leerer Zielordner.
|
||||
let rel = move_doc(&home, &rel, "").unwrap();
|
||||
assert_eq!(rel, "2026-07-19_1000-adr-logging.md");
|
||||
|
||||
// Gleiches Ziel ist ein No-Op, kein Fehler.
|
||||
assert_eq!(move_doc(&home, &rel, "").unwrap(), rel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verschieben_ueberschreibt_nicht() {
|
||||
let home = archiv();
|
||||
fs::write(home.join("konzepte/2026-07-19_1000-adr-logging.md"), "alt").unwrap();
|
||||
let err = move_doc(&home, "2026-07-19_1000-adr-logging.md", "konzepte").unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_und_fremde_pfade_brechen_ab() {
|
||||
let home = archiv();
|
||||
assert!(move_doc(&home, "../2026-07-19_1000-adr-logging.md", "x").is_err());
|
||||
assert!(move_doc(&home, "2026-07-19_1000-adr-logging.md", "../raus").is_err());
|
||||
assert!(move_doc(&home, "/etc/passwd.md", "x").is_err());
|
||||
assert!(delete_doc(&home, "../2026-07-19_1000-adr-logging.md").is_err());
|
||||
assert!(delete_doc(&home, "/etc/passwd.md").is_err());
|
||||
assert!(delete_doc(&home, "konzepte").is_err()); // kein .md
|
||||
assert!(move_folder(&home, "konzepte", "../raus").is_err());
|
||||
}
|
||||
@@ -316,7 +319,7 @@ mod tests {
|
||||
let rel = create_doc(&home, "notizen/2026", "Deploy Nötiz!", "proj").unwrap();
|
||||
assert_eq!(rel, "notizen/2026/deploy-noetiz.md");
|
||||
let text = fs::read_to_string(home.join(&rel)).unwrap();
|
||||
assert!(text.starts_with("---\ntitle: \"Deploy Nötiz!\"\n"));
|
||||
assert!(text.contains("\ntitle: \"Deploy Nötiz!\"\n"));
|
||||
assert!(text.contains("created: "));
|
||||
|
||||
// In der Wurzel, gleicher Name kollidiert laut, leerer Name bricht ab.
|
||||
@@ -383,7 +386,7 @@ mod tests {
|
||||
assert!(home.join("leer.md").is_file());
|
||||
assert!(home.join("leer/unter.md").is_file());
|
||||
let text = fs::read_to_string(home.join("leer.md")).unwrap();
|
||||
assert!(text.starts_with("---\ntitle: \"leer\"\n"));
|
||||
assert!(text.contains("\ntitle: \"leer\"\n"));
|
||||
// Gestempelte Zwillinge zählen als Knotentext — kein Duplikat: konzepte/
|
||||
// hat keins, bekommt eines; ein zweiter Lauf legt nichts Neues an.
|
||||
assert!(home.join("konzepte.md").is_file());
|
||||
|
||||
@@ -12,7 +12,9 @@ use crate::domain::archive_index::scan_with_bodies;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct Hit {
|
||||
/// Pfad relativ zum Archiv-Home.
|
||||
/// Technische ID der Notiz — Adressat des Treffer-Sprungs.
|
||||
pub(crate) id: String,
|
||||
/// Pfad relativ zum Archiv-Home (Anzeige).
|
||||
pub(crate) relpath: String,
|
||||
pub(crate) title: String,
|
||||
/// Textausschnitt um die Fundstelle, Treffer in `**…**`.
|
||||
@@ -45,13 +47,18 @@ pub(crate) fn search(
|
||||
let conn = build_index(home)?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT relpath, title, snippet(docs, 5, '**', '**', ' … ', 12) \
|
||||
"SELECT id, relpath, title, snippet(docs, 6, '**', '**', ' … ', 12) \
|
||||
FROM docs WHERE docs MATCH ?1 ORDER BY rank LIMIT ?2",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![expr, limit as i64], |row| {
|
||||
Ok(Hit { relpath: row.get(0)?, title: row.get(1)?, snippet: row.get(2)? })
|
||||
Ok(Hit {
|
||||
id: row.get(0)?,
|
||||
relpath: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
snippet: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Suchausdruck „{query}“: {e}"))?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
@@ -103,16 +110,17 @@ fn build_index(home: &Path) -> Result<Connection, String> {
|
||||
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
conn
|
||||
.execute_batch(
|
||||
"CREATE VIRTUAL TABLE docs USING fts5(relpath UNINDEXED, name, title, description, tags, body)",
|
||||
"CREATE VIRTUAL TABLE docs USING fts5(id UNINDEXED, relpath UNINDEXED, name, title, description, tags, body)",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let docs = scan_with_bodies(home)?;
|
||||
let mut insert = conn
|
||||
.prepare("INSERT INTO docs (relpath, name, title, description, tags, body) VALUES (?1, ?2, ?3, ?4, ?5, ?6)")
|
||||
.prepare("INSERT INTO docs (id, relpath, name, title, description, tags, body) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)")
|
||||
.map_err(|e| e.to_string())?;
|
||||
for (doc, body) in &docs {
|
||||
insert
|
||||
.execute(rusqlite::params![
|
||||
doc.id,
|
||||
doc.relpath,
|
||||
doc.name,
|
||||
doc.title,
|
||||
|
||||
+86
-32
@@ -527,6 +527,7 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> {
|
||||
Some(tag) => {
|
||||
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
|
||||
crate::domain::archive_ops::ensure_node_texts(&home, &display)?;
|
||||
crate::domain::archive_ops::ensure_ids(&home)?;
|
||||
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(
|
||||
&home,
|
||||
(!tag.is_empty()).then_some(tag),
|
||||
@@ -549,8 +550,9 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> {
|
||||
/// Liest den Body eines Archiv-Dokuments (ohne Frontmatter) — Inhalt der
|
||||
/// Notiz-Ansicht im Archiv-Tab.
|
||||
#[tauri::command]
|
||||
pub fn archive_read(project: String, relpath: String) -> Result<String, String> {
|
||||
pub fn archive_read(project: String, id: String) -> Result<String, String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
|
||||
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(crate::domain::archive::strip_frontmatter(&text).to_string())
|
||||
@@ -559,19 +561,60 @@ pub fn archive_read(project: String, relpath: String) -> Result<String, String>
|
||||
/// Schreibt den Body einer Archiv-Notiz zurück (Bearbeiten im
|
||||
/// Archiv-Fenster); die Frontmatter der Datei bleibt.
|
||||
#[tauri::command]
|
||||
pub fn archive_write(project: String, relpath: String, text: String) -> Result<(), String> {
|
||||
pub fn archive_write(project: String, id: String, text: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
|
||||
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
|
||||
crate::domain::archive_ops::write_body(&path, &text)
|
||||
}
|
||||
|
||||
/// Ordner-Knoten des Archivs (Pfad + Titel, sortiert) — Zielordner-Baum des
|
||||
/// Archiv-Dialogs.
|
||||
#[tauri::command]
|
||||
pub fn archive_folders(
|
||||
project: String,
|
||||
) -> Result<Vec<crate::domain::archive_index::FolderNode>, String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
crate::domain::archive_index::folder_nodes(&home)
|
||||
}
|
||||
|
||||
/// Legt den Entwurf als Datei an einem frei gewählten Pfad ab (Speichern-
|
||||
/// Dialog) — ohne Archiv, ohne Frontmatter: der Panel-Inhalt, wie er ist.
|
||||
#[tauri::command]
|
||||
pub fn panel_save_as(project: String, path: String) -> Result<(), String> {
|
||||
let text = std::fs::read_to_string(panel_file(&project)).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, text).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// Setzt den Anzeige-Titel einer Notiz (Klick auf den Titel im Archiv);
|
||||
/// danach die frische Übersicht, damit Baum und Karten den neuen Titel zeigen.
|
||||
#[tauri::command]
|
||||
pub fn archive_set_title(project: String, relpath: String, title: String) -> Result<(), String> {
|
||||
pub fn archive_set_title(project: String, id: String, title: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
|
||||
let path = crate::domain::archive_ops::doc_path(&home, &relpath)?;
|
||||
crate::domain::archive_ops::set_title(&path, &title)?;
|
||||
// Der technische Name folgt dem Titel — beide dürfen nicht auseinander
|
||||
// laufen. Bei einem Knotentext wandert der gleichnamige Ordner mit; die
|
||||
// Archiv-Wurzel (index.md) behält ihren Namen, sie ist die Konvention.
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let name = crate::domain::archive::strip_stamp(&stem).to_string();
|
||||
let slug = crate::domain::archive::slugify(&title);
|
||||
if name != slug && name != "index" {
|
||||
let dir = path.with_file_name(&name);
|
||||
if dir.is_dir() {
|
||||
let old_rel = dir.strip_prefix(&home).unwrap().display().to_string();
|
||||
let new_rel = match old_rel.rsplit_once('/') {
|
||||
Some((parent, _)) => format!("{parent}/{slug}"),
|
||||
None => slug.clone(),
|
||||
};
|
||||
crate::domain::archive_ops::move_folder(&home, &old_rel, &new_rel)?;
|
||||
relink_folder(&project, &home.join(&old_rel), &home.join(&new_rel))?;
|
||||
} else {
|
||||
let new_rel = crate::domain::archive_ops::rename_doc(&home, &relpath, &title)?;
|
||||
relink(&project, &path, Some(&home.join(new_rel)))?;
|
||||
}
|
||||
}
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
@@ -580,57 +623,66 @@ pub fn archive_set_title(project: String, relpath: String, title: String) -> Res
|
||||
fn wiki_refresh_page(project: &str, home: &std::path::Path) -> Result<(), String> {
|
||||
let display = crate::domain::project::display_name_in(&Paths::real(), project)?;
|
||||
crate::domain::archive_ops::ensure_node_texts(home, &display)?;
|
||||
crate::domain::archive_ops::ensure_ids(home)?;
|
||||
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(home, None)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(wiki_file(project), json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Verschiebt ein Archiv-Dokument in einen anderen Ordner (leer = Wurzel).
|
||||
#[tauri::command]
|
||||
pub fn archive_move(project: String, relpath: String, folder: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let old = home.join(&relpath);
|
||||
let new_rel = crate::domain::archive_ops::move_doc(&home, &relpath, &folder)?;
|
||||
relink(&project, &old, Some(&home.join(new_rel)))?;
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
/// Benennt ein Archiv-Dokument um (Slug hinter erhaltenem Zeitstempel).
|
||||
#[tauri::command]
|
||||
pub fn archive_rename(project: String, relpath: String, name: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let old = home.join(&relpath);
|
||||
let new_rel = crate::domain::archive_ops::rename_doc(&home, &relpath, &name)?;
|
||||
relink(&project, &old, Some(&home.join(new_rel)))?;
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
/// Löscht ein Archiv-Dokument; danach zeigt das Wiki die Übersicht.
|
||||
#[tauri::command]
|
||||
pub fn archive_delete(project: String, relpath: String) -> Result<(), String> {
|
||||
pub fn archive_delete(project: String, id: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let relpath = crate::domain::archive_index::resolve_id(&home, &id)?;
|
||||
crate::domain::archive_ops::delete_doc(&home, &relpath)?;
|
||||
relink(&project, &home.join(&relpath), None)?;
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
/// Verschiebt/benennt einen Archiv-Ordner um; danach Übersicht.
|
||||
#[tauri::command]
|
||||
pub fn archive_move_folder(project: String, folder: String, to: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
crate::domain::archive_ops::move_folder(&home, &folder, &to)?;
|
||||
relink_folder(&project, &home.join(&folder), &home.join(&to))?;
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
/// Legt einen Ordner im Archiv an (Plus im Baum); danach Übersicht.
|
||||
#[tauri::command]
|
||||
pub fn archive_create_folder(project: String, folder: String) -> Result<(), String> {
|
||||
pub fn archive_create_folder(project: String, parent: String, name: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let folder = join_under(&home, &parent, &name)?;
|
||||
crate::domain::archive_ops::create_folder(&home, &folder)?;
|
||||
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
|
||||
crate::domain::archive_ops::ensure_node_texts(&home, &display)?;
|
||||
crate::domain::archive_ops::ensure_ids(&home)?;
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
/// Pfad eines neuen Kindes unterhalb des Knotens `parent` (ID; leer =
|
||||
/// Wurzel) — der Name wird als Slug angehängt.
|
||||
fn join_under(
|
||||
home: &std::path::Path,
|
||||
parent: &str,
|
||||
name: &str,
|
||||
) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Name fehlt".into());
|
||||
}
|
||||
let slug = crate::domain::archive::slugify(name);
|
||||
if parent.is_empty() {
|
||||
return Ok(slug);
|
||||
}
|
||||
// Der Zielordner ist der Ordner neben dem Knotentext des Elternteils.
|
||||
let rel = crate::domain::archive_index::resolve_id(home, parent)?;
|
||||
let stem = std::path::Path::new(&rel)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let dir = match rel.rsplit_once('/') {
|
||||
Some((head, _)) => format!("{head}/{stem}"),
|
||||
None => stem,
|
||||
};
|
||||
Ok(format!("{dir}/{slug}"))
|
||||
}
|
||||
|
||||
/// Legt ein leeres Dokument an (Plus im Listenkopf) und öffnet es im
|
||||
/// Dokument-Tab: leerer Dokument-Puffer plus Quell-Verknüpfung — das
|
||||
/// Getippte landet über `panel_set` in der Archiv-Datei. Die Übersicht
|
||||
@@ -638,9 +690,11 @@ pub fn archive_create_folder(project: String, folder: String) -> Result<(), Stri
|
||||
/// frisch (zwei konkurrierende Puffer-Events würden sonst um den aktiven Tab
|
||||
/// rennen).
|
||||
#[tauri::command]
|
||||
pub fn archive_create_doc(project: String, folder: String, name: String) -> Result<(), String> {
|
||||
pub fn archive_create_doc(project: String, parent: String, name: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
|
||||
let rel_new = join_under(&home, &parent, &name)?;
|
||||
let folder = rel_new.rsplit_once('/').map(|(h, _)| h.to_string()).unwrap_or_default();
|
||||
let rel = crate::domain::archive_ops::create_doc(&home, &folder, &name, &display)?;
|
||||
let path = home.join(rel);
|
||||
std::fs::write(panel_file(&project), "").map_err(|e| e.to_string())?;
|
||||
|
||||
+104
-11
@@ -5,10 +5,20 @@ import { initArchiveForm } from "./archive-form";
|
||||
function setup() {
|
||||
document.body.innerHTML = `<button id="btn">Archiv</button>`;
|
||||
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 inputs = [...root.querySelectorAll("input")];
|
||||
return { form, onSubmit, root, inputs };
|
||||
return { form, onSubmit, onSave, folders, root, inputs };
|
||||
}
|
||||
|
||||
describe("initArchiveForm", () => {
|
||||
@@ -25,15 +35,18 @@ describe("initArchiveForm", () => {
|
||||
expect(root.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("liefert getrimmte Meta; Leeres wird undefined, Tags gesplittet", () => {
|
||||
it("liefert getrimmte Meta; Leeres wird undefined, Tags gesplittet", async () => {
|
||||
const { form, onSubmit, root, inputs } = setup();
|
||||
form.toggle();
|
||||
inputs[0].value = " konzepte/panel ";
|
||||
inputs[1].value = "";
|
||||
inputs[2].value = " adr, infra ,, ";
|
||||
await new Promise((r) => setTimeout(r));
|
||||
root
|
||||
.querySelector<HTMLElement>('.archive-browse-row[data-path="id-konzepte"]')!
|
||||
.click();
|
||||
inputs[0].value = "";
|
||||
inputs[1].value = " adr, infra ,, ";
|
||||
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
folder: "konzepte/panel",
|
||||
folder: "id-konzepte",
|
||||
description: undefined,
|
||||
tags: ["adr", "infra"],
|
||||
});
|
||||
@@ -45,13 +58,12 @@ describe("initArchiveForm", () => {
|
||||
it("startet nach dem Archivieren wieder leer", () => {
|
||||
const { form, onSubmit, root, inputs } = setup();
|
||||
form.toggle();
|
||||
inputs[0].value = "konzepte/panel";
|
||||
inputs[1].value = "Beschreibung A";
|
||||
inputs[2].value = "panel, wiki";
|
||||
inputs[0].value = "Beschreibung A";
|
||||
inputs[1].value = "panel, wiki";
|
||||
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||
|
||||
form.toggle();
|
||||
expect(inputs.map((i) => i.value)).toEqual(["", "", ""]);
|
||||
expect(inputs.map((i) => i.value)).toEqual(["", ""]);
|
||||
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||
expect(onSubmit).toHaveBeenLastCalledWith({
|
||||
folder: undefined,
|
||||
@@ -60,6 +72,87 @@ describe("initArchiveForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Baum startet eingeklappt, Pfeil klappt auf, Klick wählt aus", async () => {
|
||||
const { form, folders, root } = setup();
|
||||
form.toggle();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
expect(folders).toHaveBeenCalled();
|
||||
|
||||
// Eingeklappt: Wurzel und erste Ebene sichtbar, Unterebene verborgen.
|
||||
const visible = () =>
|
||||
[...root.querySelectorAll<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", () => {
|
||||
const { form, onSubmit, root } = setup();
|
||||
form.toggle();
|
||||
|
||||
+245
-25
@@ -1,7 +1,10 @@
|
||||
/// Archiv-Formular: klappt unter dem Archiv-Button auf und fragt Ordner,
|
||||
/// Beschreibung und Schlagwörter ab (alles optional); Enter oder der Button
|
||||
/// archivieren, Escape schließt. Gemeinsam für das angedockte Panel und das
|
||||
/// abgelöste Fenster.
|
||||
/// Archiv-Dialog: wohin archivieren, mit welcher Beschreibung und welchen
|
||||
/// Schlagwörtern. Modales Popup im Design der Archiv-Ansicht — abgedunkelter
|
||||
/// Hintergrund, zentrierte Box. Der Zielordner wird im sichtbaren Baum des
|
||||
/// Archivs gewählt (Wurzel plus alle Ordner, Klick übernimmt); für einen neuen
|
||||
/// Ordner steht darunter ein Feld, in dem der gewählte Pfad ergänzt wird.
|
||||
/// Enter archiviert, Escape oder ein Klick auf den Hintergrund schließt.
|
||||
/// Gemeinsam für das angedockte Panel und das Archiv-Fenster.
|
||||
|
||||
import { t } from "./messages";
|
||||
|
||||
@@ -11,31 +14,224 @@ export interface ArchiveFormMeta {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export function initArchiveForm(
|
||||
anchor: HTMLElement,
|
||||
onSubmit: (meta: ArchiveFormMeta) => void,
|
||||
): { toggle(): void } {
|
||||
function field(placeholder: string): HTMLInputElement {
|
||||
const i = document.createElement("input");
|
||||
i.type = "text";
|
||||
i.placeholder = placeholder;
|
||||
return i;
|
||||
/// 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(
|
||||
_anchor: HTMLElement,
|
||||
onSubmit: (meta: ArchiveFormMeta) => void,
|
||||
opts: ArchiveFormOptions = {},
|
||||
): { toggle(): void } {
|
||||
function field(labelText: string, placeholder: string): HTMLInputElement {
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "wiki-tree-input";
|
||||
input.placeholder = placeholder;
|
||||
box.append(labelled(labelText, input));
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Beschriftete Gruppe. `label` nur für echte Eingabefelder: ein <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");
|
||||
form.className = "archive-form";
|
||||
form.className = "archive-form wiki-modal";
|
||||
form.hidden = true;
|
||||
const folder = field(t("archiveForm.folder"));
|
||||
const desc = field(t("archiveForm.description"));
|
||||
const tags = field(t("archiveForm.tags"));
|
||||
const box = document.createElement("div");
|
||||
box.className = "wiki-form archive-form-box";
|
||||
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");
|
||||
submit.className = "archive-form-submit";
|
||||
submit.className = "archive-form-submit wiki-form-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);
|
||||
|
||||
const meta = (): ArchiveFormMeta => ({
|
||||
folder: folder.value.trim() || undefined,
|
||||
folder: selected || undefined,
|
||||
description: desc.value.trim() || undefined,
|
||||
tags: tags.value
|
||||
.split(",")
|
||||
@@ -45,27 +241,51 @@ export function initArchiveForm(
|
||||
// Leeren, weil das Formular zum Fenster gehört und nicht zum Dokument.
|
||||
const close = () => {
|
||||
form.hidden = true;
|
||||
folder.value = "";
|
||||
selected = "";
|
||||
desc.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 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;
|
||||
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 = () => {
|
||||
onSubmit(meta());
|
||||
close();
|
||||
};
|
||||
submit.addEventListener("click", fire);
|
||||
cancel.addEventListener("click", close);
|
||||
form.addEventListener("mousedown", (e) => {
|
||||
if (e.target === form) close();
|
||||
});
|
||||
form.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
fire();
|
||||
} else if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
+20
-10
@@ -205,7 +205,7 @@ const de = {
|
||||
raw: "Roh",
|
||||
editDraft: "Entwurf bearbeiten (Cmd/Ctrl+Enter speichert, Esc verwirft)",
|
||||
copy: "In die Zwischenablage kopieren",
|
||||
archive: "In den Archiv-Ordner speichern",
|
||||
archive: "In den Archiv-Ordner speichern …",
|
||||
chooseArchiveDir: "Archiv-Ordner wählen",
|
||||
processEnded: "[Prozess beendet]",
|
||||
},
|
||||
@@ -220,10 +220,18 @@ const de = {
|
||||
session: "Session",
|
||||
},
|
||||
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",
|
||||
tags: "Schlagwörter, kommagetrennt",
|
||||
submit: "Archivieren",
|
||||
save: "Auf Platte legen …",
|
||||
saveTitle: "Als Datei an frei gewähltem Ort ablegen — ohne Archiv",
|
||||
cancel: "Abbrechen",
|
||||
},
|
||||
search: {
|
||||
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.",
|
||||
noPage: "Keine Wiki-Seite geladen.",
|
||||
openOverview: "Archiv-Übersicht öffnen",
|
||||
folderRename: "Ordnerpfad ändern",
|
||||
renameDoc: "Dateiname ändern",
|
||||
titleEdit: "Titel bearbeiten — klicken",
|
||||
editDoc: "Bearbeiten",
|
||||
save: "Speichern",
|
||||
@@ -257,7 +263,6 @@ const de = {
|
||||
newDoc: "Neues Dokument",
|
||||
create: "Anlegen",
|
||||
cancel: "Abbrechen",
|
||||
folderPath: "ordner/unterordner",
|
||||
docName: "Name des Dokuments",
|
||||
},
|
||||
};
|
||||
@@ -462,7 +467,7 @@ const en: typeof de = {
|
||||
raw: "Raw",
|
||||
editDraft: "Edit draft (Cmd/Ctrl+Enter saves, Esc discards)",
|
||||
copy: "Copy to clipboard",
|
||||
archive: "Save to the archive folder",
|
||||
archive: "Save to the archive folder …",
|
||||
chooseArchiveDir: "Choose archive folder",
|
||||
processEnded: "[process ended]",
|
||||
},
|
||||
@@ -477,10 +482,18 @@ const en: typeof de = {
|
||||
session: "Session",
|
||||
},
|
||||
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",
|
||||
tags: "Tags, comma-separated",
|
||||
submit: "Archive",
|
||||
save: "Save to disk …",
|
||||
saveTitle: "Store as a file at a location you pick — outside the archive",
|
||||
cancel: "Cancel",
|
||||
},
|
||||
search: {
|
||||
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.",
|
||||
noPage: "No wiki page loaded.",
|
||||
openOverview: "Open archive overview",
|
||||
folderRename: "Change folder path",
|
||||
renameDoc: "Change file name",
|
||||
titleEdit: "Edit title — click",
|
||||
editDoc: "Edit",
|
||||
save: "Save",
|
||||
@@ -514,7 +525,6 @@ const en: typeof de = {
|
||||
newDoc: "New document",
|
||||
create: "Create",
|
||||
cancel: "Cancel",
|
||||
folderPath: "folder/subfolder",
|
||||
docName: "Document name",
|
||||
},
|
||||
};
|
||||
|
||||
+8
-11
@@ -26,10 +26,9 @@ export const wikiTab: PanelTab = {
|
||||
);
|
||||
return initWikiView(container, {
|
||||
autoStart: ctx.standalone,
|
||||
readDoc: (relpath) => invoke("archive_read", { project: ctx.project, relpath }),
|
||||
writeDoc: (relpath, text) =>
|
||||
invoke("archive_write", { project: ctx.project, relpath, text }),
|
||||
setTitle: (relpath, title) => run("archive_set_title", { relpath, title }),
|
||||
readDoc: (id) => invoke("archive_read", { project: ctx.project, id }),
|
||||
writeDoc: (id, text) => invoke("archive_write", { project: ctx.project, id, text }),
|
||||
setTitle: (id, title) => run("archive_set_title", { id, title }),
|
||||
openWiki: ctx.openWiki,
|
||||
takePending: () => {
|
||||
const p = pendingSelect;
|
||||
@@ -37,11 +36,9 @@ export const wikiTab: PanelTab = {
|
||||
return p;
|
||||
},
|
||||
actions: {
|
||||
rename: (relpath, name) => run("archive_rename", { relpath, name }),
|
||||
remove: (relpath) => run("archive_delete", { relpath }),
|
||||
moveFolder: (folder, to) => run("archive_move_folder", { folder, to }),
|
||||
createFolder: (folder) => run("archive_create_folder", { folder }),
|
||||
createDoc: (folder, name) => run("archive_create_doc", { folder, name }),
|
||||
remove: (id) => run("archive_delete", { id }),
|
||||
createFolder: (parent, name) => run("archive_create_folder", { parent, name }),
|
||||
createDoc: (parent, name) => run("archive_create_doc", { parent, name }),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -67,8 +64,8 @@ export const searchTab: PanelTab = {
|
||||
init: (container, ctx) =>
|
||||
initSearchView(
|
||||
container,
|
||||
(_path, relpath) => {
|
||||
pendingSelect = relpath;
|
||||
(_path, _relpath, id) => {
|
||||
pendingSelect = id;
|
||||
ctx.openWiki("tag:");
|
||||
},
|
||||
(raw) => {
|
||||
|
||||
+120
-32
@@ -522,6 +522,9 @@
|
||||
}
|
||||
/* Anlege-Dialog (Ordner/Dokument): modales Popup mit abgedunkeltem
|
||||
Hintergrund. */
|
||||
.wiki-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.wiki-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -949,55 +952,140 @@
|
||||
}
|
||||
|
||||
/* Archiv-Formular: klappt unter dem Archiv-Button auf. */
|
||||
.archive-form {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
/* Archiv-Dialog: Optik aus .wiki-modal/.wiki-form, plus Ordner-Baum zum
|
||||
Anklicken. */
|
||||
.archive-form-box {
|
||||
min-width: 540px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
.archive-form-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
padding: 6px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.archive-form input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
background: var(--tile);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text);
|
||||
/* Ladefehler des Baums — laut statt leerer Kasten. */
|
||||
.archive-browse-error {
|
||||
color: #f38ba8;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
padding: 6px 8px;
|
||||
font:
|
||||
400 12px/1.4 -apple-system,
|
||||
500 13px/1.5 -apple-system,
|
||||
system-ui,
|
||||
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);
|
||||
}
|
||||
.archive-form input::placeholder {
|
||||
color: var(--faint);
|
||||
.archive-browse-row.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.archive-form-submit {
|
||||
border: none;
|
||||
background: var(--line);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font:
|
||||
500 12px/1.4 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
.archive-browse-row .wiki-tree-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.archive-form-submit:hover {
|
||||
background: var(--line-strong);
|
||||
.archive-browse-row .wiki-tree-icon svg {
|
||||
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. */
|
||||
|
||||
+35
-6
@@ -91,7 +91,8 @@ interface Project {
|
||||
terminal: { theme: string | null };
|
||||
}
|
||||
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);
|
||||
// Fensterhintergrund des Panel-Fensters ist die Kopf-Fläche, nicht das
|
||||
// Terminal-Dunkel.
|
||||
@@ -111,7 +112,9 @@ if (initialMode) {
|
||||
// Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken
|
||||
// wählt notfalls erst das Archiv-Home per Dialog.
|
||||
const archiveBtn = document.getElementById("panel-archive")!;
|
||||
const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
||||
const archiveForm = initArchiveForm(
|
||||
archiveBtn,
|
||||
async (meta) => {
|
||||
try {
|
||||
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
|
||||
project,
|
||||
@@ -119,12 +122,15 @@ const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
||||
let dir: string | undefined;
|
||||
if (!configured) {
|
||||
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;
|
||||
dir = chosen as string;
|
||||
}
|
||||
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,
|
||||
dir,
|
||||
folder: meta.folder ?? null,
|
||||
@@ -132,12 +138,33 @@ const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
||||
tags: meta.tags,
|
||||
});
|
||||
flash(archiveBtn, "copied", 1400);
|
||||
invoke("reveal_path_cmd", { path });
|
||||
} catch (e) {
|
||||
flash(archiveBtn, "error", 1400);
|
||||
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());
|
||||
|
||||
// Ö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
|
||||
// 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",
|
||||
tag: null,
|
||||
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", () => {
|
||||
@@ -84,6 +84,7 @@ describe("initSearchView", () => {
|
||||
expect(onOpen).toHaveBeenCalledWith(
|
||||
"/tmp/archiv/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";
|
||||
|
||||
interface Hit {
|
||||
id: string;
|
||||
relpath: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
@@ -27,7 +28,7 @@ export interface SearchView {
|
||||
|
||||
export function initSearchView(
|
||||
container: HTMLElement,
|
||||
onOpen: (path: string, relpath: string) => void,
|
||||
onOpen: (path: string, relpath: string, id: string) => void,
|
||||
onSearch: (query: string) => void,
|
||||
): SearchView {
|
||||
let count = 0;
|
||||
@@ -108,7 +109,7 @@ export function initSearchView(
|
||||
snippetEl(hit.snippet),
|
||||
{ cls: "hit-path", text: hit.relpath },
|
||||
],
|
||||
onClick: () => onOpen(`${run.home}/${hit.relpath}`, hit.relpath),
|
||||
onClick: () => onOpen(`${run.home}/${hit.relpath}`, hit.relpath, hit.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
+37
-6
@@ -177,7 +177,9 @@ function b64ToBytes(b64: string): Uint8Array {
|
||||
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", () =>
|
||||
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)
|
||||
// auf; Abschicken wählt notfalls erst das Archiv-Home per Dialog.
|
||||
const archiveBtn = document.getElementById("panel-archive")!;
|
||||
const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
||||
const archiveForm = initArchiveForm(
|
||||
archiveBtn,
|
||||
async (meta) => {
|
||||
try {
|
||||
const configured = await invoke<string | null>("panel_archive_dir_cmd", {
|
||||
project,
|
||||
@@ -289,12 +293,15 @@ const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
||||
let dir: string | undefined;
|
||||
if (!configured) {
|
||||
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;
|
||||
dir = chosen as string;
|
||||
}
|
||||
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,
|
||||
dir,
|
||||
folder: meta.folder ?? null,
|
||||
@@ -302,20 +309,44 @@ const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
|
||||
tags: meta.tags,
|
||||
});
|
||||
flash(archiveBtn, "copied", 1400);
|
||||
invoke("reveal_path_cmd", { path });
|
||||
} catch (e) {
|
||||
flash(archiveBtn, "error", 1400);
|
||||
panelToast(`Archivieren fehlgeschlagen: ${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());
|
||||
|
||||
// Splitter: Panelbreite ziehen.
|
||||
splitter.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
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`;
|
||||
fit.fit();
|
||||
};
|
||||
|
||||
+29
-37
@@ -12,9 +12,7 @@ function setup(pending: string | null = null) {
|
||||
const setTitle = vi.fn();
|
||||
const openWiki = vi.fn();
|
||||
const actions = {
|
||||
rename: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
moveFolder: vi.fn(),
|
||||
createFolder: vi.fn(),
|
||||
createDoc: vi.fn(),
|
||||
};
|
||||
@@ -36,6 +34,7 @@ function setup(pending: string | null = null) {
|
||||
}
|
||||
|
||||
const doc = (relpath: string, name: string, extra: object = {}) => ({
|
||||
id: `id-${name}`,
|
||||
relpath,
|
||||
name,
|
||||
title: name.toUpperCase(),
|
||||
@@ -61,9 +60,17 @@ const page = JSON.stringify({
|
||||
backlinks: 2,
|
||||
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",
|
||||
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.
|
||||
document.querySelector<HTMLElement>(".wiki-tree summary .wiki-tree-name")!.click();
|
||||
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-children .wiki-doc-title")!.textContent).toBe(
|
||||
"NEU",
|
||||
@@ -129,7 +136,7 @@ describe("initWikiView — Notiz-Ansicht", () => {
|
||||
view.set(page);
|
||||
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click(); // WURZEL-DOC
|
||||
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");
|
||||
// Meta als Popup am Info-Knopf.
|
||||
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");
|
||||
document.querySelector<HTMLElement>(".wiki-form-submit")!.click();
|
||||
await flush();
|
||||
expect(writeDoc).toHaveBeenCalledWith(
|
||||
"2026-07-19_1000-wurzel-doc.md",
|
||||
"# Neu\n\nGeändert.",
|
||||
);
|
||||
expect(writeDoc).toHaveBeenCalledWith("id-wurzel-doc", "# Neu\n\nGeändert.");
|
||||
// Nach dem Speichern wieder die Anzeige.
|
||||
expect(document.querySelector(".wiki-note-editor")).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 () => {
|
||||
const { view, readDoc } = setup("konzepte/2026-07-19_1005-neu.md");
|
||||
const { view, readDoc } = setup("id-neu");
|
||||
view.set(page);
|
||||
await flush();
|
||||
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");
|
||||
input.value = "Neuer Titel";
|
||||
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 () => {
|
||||
@@ -267,7 +271,7 @@ describe("initWikiView — Aktionen", () => {
|
||||
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click();
|
||||
await flush();
|
||||
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", () => {
|
||||
@@ -280,7 +284,7 @@ describe("initWikiView — Aktionen", () => {
|
||||
const input = form.querySelector<HTMLInputElement>("input")!;
|
||||
input.value = "Deploy Notiz";
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -299,35 +303,23 @@ describe("initWikiView — Aktionen", () => {
|
||||
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();
|
||||
view.set(page);
|
||||
const summary = document.querySelector<HTMLElement>(".wiki-tree summary")!;
|
||||
summary.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
view.set(mergedPage);
|
||||
document
|
||||
.querySelector<HTMLElement>(".wiki-tree summary")!
|
||||
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
||||
items.find((i) => i.textContent === "Neuer Ordner")!.click();
|
||||
const input = document.querySelector<HTMLInputElement>(".wiki-form input")!;
|
||||
expect(input.value).toBe("konzepte/");
|
||||
input.value = "konzepte/neu";
|
||||
input.value = "neu";
|
||||
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", () => {
|
||||
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", () => {
|
||||
it("Rechtsklick auf Dokument: Menü löscht mit ID", () => {
|
||||
const { view, actions } = setup();
|
||||
view.set(page);
|
||||
document
|
||||
@@ -335,7 +327,7 @@ describe("initWikiView — Aktionen", () => {
|
||||
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+83
-89
@@ -12,6 +12,9 @@ import { linkWikiRefs } from "./panel-view";
|
||||
import { deleteAction } from "./tiles";
|
||||
|
||||
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;
|
||||
name: string;
|
||||
title: string;
|
||||
@@ -40,22 +43,21 @@ interface Page {
|
||||
/// Dokument-/Ordner-Operationen — laufen als Tauri-Commands, der neue Stand
|
||||
/// kommt über den Wiki-Puffer zurück.
|
||||
export interface WikiActions {
|
||||
rename(relpath: string, name: string): void;
|
||||
remove(relpath: string): void;
|
||||
moveFolder(folder: string, to: string): void;
|
||||
createFolder(folder: string): void;
|
||||
createDoc(folder: string, name: string): void;
|
||||
remove(id: string): void;
|
||||
/// Neues Kind unterhalb des Knotens (ID; "" = Wurzel).
|
||||
createFolder(parent: string, name: string): void;
|
||||
createDoc(parent: string, name: string): void;
|
||||
}
|
||||
|
||||
export interface WikiCallbacks {
|
||||
/// Leeren Puffer sofort mit der Übersicht füllen (eigenes Fenster).
|
||||
autoStart?: boolean;
|
||||
/// 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).
|
||||
writeDoc(relpath: string, text: string): Promise<void>;
|
||||
writeDoc(id: string, text: string): Promise<void>;
|
||||
/// 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).
|
||||
openWiki(name: string): void;
|
||||
/// Vorgemerkte Auswahl (Suchtreffer-Sprung): einmalig abholen.
|
||||
@@ -134,9 +136,9 @@ function buildTree(p: Page): TreeNode {
|
||||
}
|
||||
|
||||
export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiView {
|
||||
/// Zugeklappte Ordner und Auswahl — Auswahl ist ein Ordnerpfad ("" =
|
||||
/// Wurzel) oder ein Dokument-relpath (endet auf .md); beides überlebt
|
||||
/// Puffer-Updates innerhalb der Ansicht.
|
||||
/// Zugeklappte Knoten und Auswahl — beides über die technische ID der
|
||||
/// Notiz ("" = Archiv-Wurzel); übersteht Umbenennen, Verschieben und
|
||||
/// Puffer-Updates.
|
||||
const closed = new Set<string>();
|
||||
let selected = "";
|
||||
let current: Page | null = null;
|
||||
@@ -146,31 +148,41 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
/// Bearbeitungsmodus der aktuellen Notiz (Editor statt Anzeige).
|
||||
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 {
|
||||
let n = tree;
|
||||
if (!path) return n;
|
||||
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;
|
||||
/// Notiz zur ID (Blatt oder Knotentext).
|
||||
function findDoc(id: string): DocEntry | null {
|
||||
if (!current || !id) return null;
|
||||
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;
|
||||
}
|
||||
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
|
||||
/// 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 d of f.docs) {
|
||||
if ([d.name, d.title, stem(d.relpath)].some((s) => slugify(s) === want)) {
|
||||
select(d.relpath);
|
||||
select(d.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -238,23 +250,24 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
}
|
||||
|
||||
function folderRow(name: string, full: string, node: TreeNode): HTMLElement {
|
||||
const key = node.content?.id ?? full;
|
||||
const det = document.createElement("details");
|
||||
det.className = "wiki-tree-folder";
|
||||
det.open = !closed.has(full);
|
||||
det.open = !closed.has(key);
|
||||
det.addEventListener("toggle", () => {
|
||||
if (det.open) closed.delete(full);
|
||||
else closed.add(full);
|
||||
if (det.open) closed.delete(key);
|
||||
else closed.add(key);
|
||||
});
|
||||
const sum = document.createElement("summary");
|
||||
sum.classList.add(node.content ? "has-content" : "book");
|
||||
if (full === selected || node.content?.relpath === selected) {
|
||||
if (node.content?.id === selected) {
|
||||
sum.classList.add("active");
|
||||
}
|
||||
// Die ganze Zeile wählt die Notiz aus (wie in Trilium); NUR der Pfeil
|
||||
// klappt — deshalb das summary-Default unterbinden und selbst schalten.
|
||||
sum.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
select(full);
|
||||
select(node.content?.id ?? "");
|
||||
});
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "wiki-tree-arrow";
|
||||
@@ -270,16 +283,10 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
sum.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = node.content?.id ?? "";
|
||||
openMenu(e.clientX, e.clientY, [
|
||||
{ label: t("wiki.newDoc"), run: () => newDocForm(full) },
|
||||
{ label: t("wiki.newFolder"), run: () => newFolderForm(full) },
|
||||
{
|
||||
label: t("wiki.folderRename"),
|
||||
run: () =>
|
||||
openForm(t("wiki.folderRename"), t("wiki.folderPath"), full, (v) =>
|
||||
cb.actions.moveFolder(full, v),
|
||||
),
|
||||
},
|
||||
{ label: t("wiki.newDoc"), run: () => newDocForm(id) },
|
||||
{ label: t("wiki.newFolder"), run: () => newFolderForm(id) },
|
||||
]);
|
||||
});
|
||||
sum.append(arrow, typeIcon("node"), label);
|
||||
@@ -290,12 +297,12 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
function docRow(doc: DocEntry): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
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");
|
||||
label.className = "wiki-tree-name";
|
||||
label.textContent = doc.title;
|
||||
row.append(typeIcon("doc"), label);
|
||||
row.addEventListener("click", () => select(doc.relpath));
|
||||
row.addEventListener("click", () => select(doc.id));
|
||||
row.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -303,19 +310,12 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
{
|
||||
label: t("wiki.editDoc"),
|
||||
run: () => {
|
||||
select(doc.relpath);
|
||||
select(doc.id);
|
||||
editing = true;
|
||||
renderMain();
|
||||
},
|
||||
},
|
||||
{
|
||||
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) },
|
||||
{ label: t("wiki.deleteDoc"), run: () => cb.actions.remove(doc.id) },
|
||||
]);
|
||||
});
|
||||
return row;
|
||||
@@ -362,13 +362,15 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
|
||||
// ---------- Anlege-Formular (Ordner/Dokument) ----------
|
||||
|
||||
function newDocForm(folder: string) {
|
||||
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) => cb.actions.createDoc(folder, v));
|
||||
function newDocForm(parent: string) {
|
||||
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) =>
|
||||
cb.actions.createDoc(parent, v),
|
||||
);
|
||||
}
|
||||
|
||||
function newFolderForm(folder: string) {
|
||||
openForm(t("wiki.newFolder"), t("wiki.folderPath"), folder ? `${folder}/` : "", (v) =>
|
||||
cb.actions.createFolder(v),
|
||||
function newFolderForm(parent: string) {
|
||||
openForm(t("wiki.newFolder"), t("wiki.docName"), "", (v) =>
|
||||
cb.actions.createFolder(parent, v),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -430,7 +432,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
function goBack() {
|
||||
while (history.length) {
|
||||
const prev = history.pop()!;
|
||||
const exists = isDoc(prev) ? !!findDoc(prev) : !!findNode(prev);
|
||||
const exists = !!findDoc(prev) || !!nodeById(prev);
|
||||
if (exists) {
|
||||
select(prev, false);
|
||||
return;
|
||||
@@ -461,7 +463,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
// Klick auf den Titel bearbeitet ihn direkt (Frontmatter-Titel der
|
||||
// Notiz); Enter übernimmt, Escape verwirft. Der technische Datei-/
|
||||
// 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) {
|
||||
h.classList.add("editable");
|
||||
h.title = t("wiki.titleEdit");
|
||||
@@ -471,7 +473,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
input.value = title;
|
||||
input.addEventListener("keydown", (e) => {
|
||||
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);
|
||||
});
|
||||
input.addEventListener("blur", () => input.replaceWith(h));
|
||||
@@ -528,9 +530,9 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
function noteBody(doc: DocEntry): HTMLElement {
|
||||
const body = document.createElement("div");
|
||||
body.className = "wiki-note-body";
|
||||
cb.readDoc(doc.relpath).then(
|
||||
cb.readDoc(doc.id).then(
|
||||
(text) => {
|
||||
if (selected !== doc.relpath && findNode(selected)?.content !== doc) return;
|
||||
if (selected !== doc.id) return;
|
||||
body.innerHTML = renderMarkdown(text);
|
||||
linkWikiRefs(body, followWikiLink);
|
||||
},
|
||||
@@ -563,7 +565,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
preview.innerHTML = renderMarkdown(area.value);
|
||||
linkWikiRefs(preview, followWikiLink);
|
||||
};
|
||||
cb.readDoc(doc.relpath).then(
|
||||
cb.readDoc(doc.id).then(
|
||||
(text) => {
|
||||
area.value = text;
|
||||
draw();
|
||||
@@ -576,7 +578,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
);
|
||||
area.addEventListener("input", draw);
|
||||
const save = () => {
|
||||
cb.writeDoc(doc.relpath, area.value).then(
|
||||
cb.writeDoc(doc.id, area.value).then(
|
||||
() => {
|
||||
editing = false;
|
||||
renderMain();
|
||||
@@ -631,7 +633,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
editing = true;
|
||||
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
|
||||
@@ -664,7 +666,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
const main = container.querySelector<HTMLElement>(".wiki-main")!;
|
||||
main.textContent = "";
|
||||
|
||||
if (isDoc(selected)) {
|
||||
if (isLeaf(selected)) {
|
||||
const doc = findDoc(selected);
|
||||
if (!doc) return;
|
||||
if (editing) {
|
||||
@@ -677,9 +679,9 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
return;
|
||||
}
|
||||
|
||||
const node = findNode(selected);
|
||||
const node = nodeById(selected);
|
||||
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 meta: string[] = node.content ? metaParts(node.content) : [];
|
||||
|
||||
@@ -687,11 +689,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
add.className = "wiki-add";
|
||||
add.title = t("wiki.newDoc");
|
||||
add.textContent = "+";
|
||||
add.addEventListener("click", () =>
|
||||
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) =>
|
||||
cb.actions.createDoc(selected, v),
|
||||
),
|
||||
);
|
||||
add.addEventListener("click", () => newDocForm(selected));
|
||||
const actions: HTMLElement[] = node.content
|
||||
? [add, ...docActions(node.content)]
|
||||
: [add];
|
||||
@@ -731,13 +729,15 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
});
|
||||
list.append(caption);
|
||||
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(
|
||||
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))) {
|
||||
list.append(childRow(doc.title, doc, () => select(doc.relpath)));
|
||||
list.append(childRow(doc.title, doc, () => select(doc.id)));
|
||||
}
|
||||
main.append(list);
|
||||
}
|
||||
@@ -746,15 +746,9 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
||||
if (remember && target !== selected) history.push(selected);
|
||||
editing = false;
|
||||
selected = target;
|
||||
// Elternordner eines gewählten Dokuments aufklappen, sonst bleibt die
|
||||
// Hervorhebung unsichtbar.
|
||||
if (isDoc(target)) {
|
||||
const parts = docFolder(target).split("/").filter(Boolean);
|
||||
let path = "";
|
||||
for (const part of parts) {
|
||||
path = path ? `${path}/${part}` : part;
|
||||
closed.delete(path);
|
||||
}
|
||||
// Vorfahren aufklappen, sonst bleibt die Hervorhebung unsichtbar.
|
||||
for (let p = parentOf(target); p?.content; p = parentOf(p.content.id)) {
|
||||
closed.delete(p.content.id);
|
||||
}
|
||||
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
|
||||
// zurück zur Wurzel.
|
||||
const exists = isDoc(selected) ? !!findDoc(selected) : !!findNode(selected);
|
||||
const exists = !!findDoc(selected) || !!nodeById(selected);
|
||||
if (selected && !exists) {
|
||||
selected = "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user