Archiv-Navigator: Baum nur Ordner + flache Inhaltsliste (Zeilen: öffnen/umbenennen/löschen), Ordner/Dokument anlegen (Dateiname ohne Stempel, Datum im Frontmatter), Write-back geöffneter Archiv-Dokumente über Quell-Verknüpfung; Ablösen übergibt aktiven Tab
This commit is contained in:
@@ -66,6 +66,12 @@ fn main() {
|
||||
"search_run",
|
||||
"panel_load",
|
||||
"wiki_open",
|
||||
"archive_move",
|
||||
"archive_rename",
|
||||
"archive_delete",
|
||||
"archive_move_folder",
|
||||
"archive_create_folder",
|
||||
"archive_create_doc",
|
||||
"open_panel_window",
|
||||
])),
|
||||
)
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
"autostart:default",
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"allow-archive-create-doc",
|
||||
"allow-archive-create-folder",
|
||||
"allow-archive-delete",
|
||||
"allow-archive-move",
|
||||
"allow-archive-move-folder",
|
||||
"allow-archive-rename",
|
||||
"allow-buffer-read",
|
||||
"allow-commands-delete",
|
||||
"allow-enabled-modules",
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
"autostart:default",
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"allow-archive-create-doc",
|
||||
"allow-archive-create-folder",
|
||||
"allow-archive-delete",
|
||||
"allow-archive-move",
|
||||
"allow-archive-move-folder",
|
||||
"allow-archive-rename",
|
||||
"allow-buffer-read",
|
||||
"allow-commands-delete",
|
||||
"allow-enabled-modules",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-archive-create-doc"
|
||||
description = "Enables the archive_create_doc command without any pre-configured scope."
|
||||
commands.allow = ["archive_create_doc"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-archive-create-doc"
|
||||
description = "Denies the archive_create_doc command without any pre-configured scope."
|
||||
commands.deny = ["archive_create_doc"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-archive-create-folder"
|
||||
description = "Enables the archive_create_folder command without any pre-configured scope."
|
||||
commands.allow = ["archive_create_folder"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-archive-create-folder"
|
||||
description = "Denies the archive_create_folder command without any pre-configured scope."
|
||||
commands.deny = ["archive_create_folder"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-archive-delete"
|
||||
description = "Enables the archive_delete command without any pre-configured scope."
|
||||
commands.allow = ["archive_delete"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-archive-delete"
|
||||
description = "Denies the archive_delete command without any pre-configured scope."
|
||||
commands.deny = ["archive_delete"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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"]
|
||||
@@ -311,6 +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_delete,
|
||||
terminal::archive_move_folder,
|
||||
terminal::archive_create_folder,
|
||||
terminal::archive_create_doc,
|
||||
terminal::open_panel_window
|
||||
]
|
||||
}
|
||||
|
||||
@@ -275,7 +275,12 @@ fn check_folder(folder: &str) -> Result<&std::path::Path, String> {
|
||||
/// YAML-Frontmatter des Archiv-Dokuments inklusive optionaler
|
||||
/// description/tags aus den Metadaten. Gegenstück: `parse_frontmatter` unten —
|
||||
/// Schreiber und Leser des Formats leben bewusst im selben Modul.
|
||||
fn frontmatter(title: &str, project: &str, iso: &str, meta: &ArchiveMeta) -> String {
|
||||
pub(crate) fn frontmatter(
|
||||
title: &str,
|
||||
project: &str,
|
||||
iso: &str,
|
||||
meta: &ArchiveMeta,
|
||||
) -> String {
|
||||
let mut fm = format!(
|
||||
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
|
||||
title.replace('"', "'"),
|
||||
@@ -398,6 +403,16 @@ pub(crate) fn slugify(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktuelle UTC-Zeit als ISO-String — Frontmatter-`created` neu angelegter
|
||||
/// Dokumente.
|
||||
pub(crate) fn utc_now_iso() -> Result<String, String> {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_secs();
|
||||
Ok(utc_stamp(secs).1)
|
||||
}
|
||||
|
||||
/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`).
|
||||
fn utc_stamp(secs: u64) -> (String, String) {
|
||||
let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60);
|
||||
|
||||
@@ -8,9 +8,7 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::archive::{
|
||||
parse_frontmatter, parse_tag_list, slugify, strip_frontmatter, strip_stamp,
|
||||
};
|
||||
use crate::domain::archive::{parse_frontmatter, parse_tag_list, slugify, strip_stamp};
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub(crate) struct Doc {
|
||||
@@ -22,6 +20,9 @@ pub(crate) struct Doc {
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) tags: Vec<String>,
|
||||
/// Frontmatter-`created` (ISO) — Datumsquelle für Dokumente ohne
|
||||
/// Zeitstempel im Dateinamen.
|
||||
pub(crate) created: Option<String>,
|
||||
/// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel).
|
||||
pub(crate) links: Vec<String>,
|
||||
/// Namen der Dokumente, die per Wikilink hierher zeigen.
|
||||
@@ -107,6 +108,7 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
|
||||
title: fm.get("title").unwrap_or(&name).clone(),
|
||||
description: fm.get("description").cloned(),
|
||||
tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(),
|
||||
created: fm.get("created").cloned(),
|
||||
links: wikilinks(&text),
|
||||
backlinks: Vec::new(),
|
||||
name,
|
||||
@@ -134,9 +136,6 @@ pub(crate) struct WikiPage {
|
||||
pub(crate) tag: Option<String>,
|
||||
pub(crate) total: usize,
|
||||
pub(crate) tags: Vec<TagCount>,
|
||||
/// Die neuesten Dokumente über alle Ordner; leer, wenn es nur einen
|
||||
/// Ordner gibt (dann wäre die Sektion eine Dublette der Liste).
|
||||
pub(crate) recent: Vec<WikiDocEntry>,
|
||||
pub(crate) folders: Vec<WikiFolder>,
|
||||
}
|
||||
|
||||
@@ -155,6 +154,9 @@ pub(crate) struct WikiFolder {
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiDocEntry {
|
||||
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
|
||||
/// Zeilen-Aktionen (umbenennen, löschen).
|
||||
pub(crate) relpath: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
@@ -179,6 +181,16 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
};
|
||||
let mut folders: std::collections::BTreeMap<String, Vec<&Doc>> =
|
||||
std::collections::BTreeMap::new();
|
||||
// Die Übersicht führt alle Ordner, auch dokumentlose — der Navigator zeigt
|
||||
// frisch angelegte Ordner sonst nicht. Schlagwort-Seiten bleiben auf die
|
||||
// Treffer-Ordner beschränkt.
|
||||
if tag.is_none() {
|
||||
let mut dirs = Vec::new();
|
||||
folder_paths(home, home, &mut dirs)?;
|
||||
for d in dirs {
|
||||
folders.entry(d).or_default();
|
||||
}
|
||||
}
|
||||
for doc in &selected {
|
||||
let folder = Path::new(&doc.relpath)
|
||||
.parent()
|
||||
@@ -186,17 +198,6 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
.unwrap_or_default();
|
||||
folders.entry(folder).or_default().push(doc);
|
||||
}
|
||||
let recent = if folders.len() > 1 {
|
||||
// Neueste zuerst über alle Ordner — der Zeitstempel-Stem sortiert
|
||||
// chronologisch, verglichen wird der Dateiname.
|
||||
let mut all = selected.clone();
|
||||
all.sort_by(|a, b| {
|
||||
Path::new(&b.relpath).file_name().cmp(&Path::new(&a.relpath).file_name())
|
||||
});
|
||||
all.into_iter().take(5).map(doc_entry).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(WikiPage {
|
||||
kind: "page",
|
||||
home: home.display().to_string(),
|
||||
@@ -206,7 +207,6 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
.into_iter()
|
||||
.map(|(name, count)| TagCount { name: name.to_string(), count })
|
||||
.collect(),
|
||||
recent,
|
||||
folders: folders
|
||||
.into_iter()
|
||||
.map(|(name, mut list)| {
|
||||
@@ -218,14 +218,41 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
||||
})
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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()))?;
|
||||
let path = entry.path();
|
||||
if entry.file_name().to_string_lossy().starts_with('.') || !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
out.push(path.strip_prefix(home).unwrap().display().to_string());
|
||||
folder_paths(home, &path, out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn doc_entry(doc: &Doc) -> WikiDocEntry {
|
||||
let stem = Path::new(&doc.relpath)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let date = (stem != doc.name).then(|| stem[..10].to_string());
|
||||
// Zeitstempel im Dateinamen, sonst der Frontmatter-`created`-Tag —
|
||||
// im Navigator angelegte Dokumente tragen das Datum nur dort.
|
||||
let date = (stem != doc.name)
|
||||
.then(|| stem[..10].to_string())
|
||||
.or_else(|| {
|
||||
doc
|
||||
.created
|
||||
.as_ref()
|
||||
.filter(|c| c.len() >= 10)
|
||||
.map(|c| c[..10].to_string())
|
||||
});
|
||||
WikiDocEntry {
|
||||
relpath: doc.relpath.clone(),
|
||||
name: doc.name.clone(),
|
||||
title: doc.title.clone(),
|
||||
description: doc.description.clone(),
|
||||
@@ -235,37 +262,16 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Archiv-Dokument als Wiki-Ansicht: Markdown-Rumpf ohne Frontmatter plus
|
||||
/// Metadaten und Backlinks aus dem Scan.
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiDocPage {
|
||||
pub(crate) kind: &'static str,
|
||||
pub(crate) home: String,
|
||||
pub(crate) relpath: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) tags: Vec<String>,
|
||||
pub(crate) backlinks: Vec<String>,
|
||||
pub(crate) markdown: String,
|
||||
}
|
||||
|
||||
pub(crate) fn wiki_doc(home: &Path, target: &str) -> Result<WikiDocPage, String> {
|
||||
let pairs = scan_with_bodies(home)?;
|
||||
/// 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> {
|
||||
let docs = scan_archive(home)?;
|
||||
let want = slugify(target);
|
||||
let (doc, body) = pairs
|
||||
docs
|
||||
.iter()
|
||||
.find(|(d, _)| matches(d, &want))
|
||||
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))?;
|
||||
Ok(WikiDocPage {
|
||||
kind: "doc",
|
||||
home: home.display().to_string(),
|
||||
relpath: doc.relpath.clone(),
|
||||
name: doc.name.clone(),
|
||||
title: doc.title.clone(),
|
||||
tags: doc.tags.clone(),
|
||||
backlinks: doc.backlinks.clone(),
|
||||
markdown: strip_frontmatter(body).to_string(),
|
||||
})
|
||||
.find(|d| matches(d, &want))
|
||||
.map(|d| d.relpath.clone())
|
||||
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))
|
||||
}
|
||||
|
||||
/// Alle `[[ziel]]`-Vorkommen im Text, in Dokumentreihenfolge; bei
|
||||
@@ -358,14 +364,38 @@ mod tests {
|
||||
assert_eq!(adr.title, "ADR Logging");
|
||||
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
|
||||
assert_eq!(adr.date.as_deref(), Some("2026-07-19"));
|
||||
assert_eq!(adr.relpath, "2026-07-19_1000-adr-logging.md");
|
||||
assert_eq!(page.folders[1].name, "konzepte");
|
||||
assert_eq!(page.folders[1].docs[0].name, "notiz-deploy");
|
||||
// Zwei Ordner → Zuletzt-Sektion, neuestes Dokument zuerst, mit
|
||||
// Backlink-Zähler aus dem Scan.
|
||||
let recent: Vec<&str> = page.recent.iter().map(|d| d.name.as_str()).collect();
|
||||
assert_eq!(recent, vec!["notiz-deploy", "adr-logging"]);
|
||||
assert_eq!(page.recent[0].backlinks, 1);
|
||||
assert_eq!(page.recent[1].backlinks, 0);
|
||||
assert_eq!(page.folders[1].docs[0].backlinks, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leere_ordner_und_created_datum() {
|
||||
let home = archiv();
|
||||
fs::create_dir_all(home.join("leer/unter")).unwrap();
|
||||
write(
|
||||
&home,
|
||||
"notiz-plain.md",
|
||||
"---\ntitle: \"Plain\"\ncreated: 2026-07-23T10:00:00Z\n---\n\nText.\n",
|
||||
);
|
||||
let page = archive_page(&home, None).unwrap();
|
||||
let names: Vec<&str> = page.folders.iter().map(|f| f.name.as_str()).collect();
|
||||
// Dokumentlose Ordner erscheinen (Navigator), versteckte weiterhin nicht.
|
||||
assert!(names.contains(&"leer"));
|
||||
assert!(names.contains(&"leer/unter"));
|
||||
assert!(!names.iter().any(|n| n.starts_with(".versteckt")));
|
||||
// Ohne Zeitstempel im Namen kommt das Datum aus dem Frontmatter-created.
|
||||
let plain = page
|
||||
.folders
|
||||
.iter()
|
||||
.find(|f| f.name.is_empty())
|
||||
.unwrap()
|
||||
.docs
|
||||
.iter()
|
||||
.find(|d| d.name == "notiz-plain")
|
||||
.unwrap();
|
||||
assert_eq!(plain.date.as_deref(), Some("2026-07-23"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -381,20 +411,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_doc_mit_rumpf_und_backlinks() {
|
||||
fn resolve_ueber_name_titel_und_stem() {
|
||||
let home = archiv();
|
||||
// Auflösung über Titel; Name und Stem gehen über dieselben Slug-Vergleiche.
|
||||
let doc = wiki_doc(&home, "Notiz Deploy").unwrap();
|
||||
assert_eq!(doc.kind, "doc");
|
||||
assert_eq!(doc.name, "notiz-deploy");
|
||||
assert_eq!(
|
||||
wiki_doc(&home, "2026-07-19_1000-adr-logging").unwrap().name,
|
||||
"adr-logging"
|
||||
resolve_doc(&home, "Notiz Deploy").unwrap(),
|
||||
"konzepte/2026-07-19_1005-notiz-deploy.md"
|
||||
);
|
||||
assert_eq!(doc.title, "Notiz Deploy");
|
||||
assert_eq!(doc.relpath, "konzepte/2026-07-19_1005-notiz-deploy.md");
|
||||
assert_eq!(doc.backlinks, vec!["adr-logging"]);
|
||||
assert_eq!(doc.markdown, "Text ohne Links.\n");
|
||||
assert!(wiki_doc(&home, "fehlt").is_err());
|
||||
assert_eq!(
|
||||
resolve_doc(&home, "2026-07-19_1000-adr-logging").unwrap(),
|
||||
"2026-07-19_1000-adr-logging.md"
|
||||
);
|
||||
assert!(resolve_doc(&home, "fehlt").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Dokument- und Ordner-Operationen im Archiv-Baum (Baum-Navigation im
|
||||
//! Panel): verschieben, umbenennen, löschen. Alle Pfade sind relativ zum
|
||||
//! Archiv-Home und strikt darauf begrenzt — die relpaths kommen aus dem
|
||||
//! Webview, ein Traversal-Bestandteil bricht laut ab.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::domain::archive::{
|
||||
frontmatter, slugify, strip_frontmatter, strip_stamp, utc_now_iso, ArchiveMeta,
|
||||
};
|
||||
|
||||
/// Relativer Pfad ohne Traversal: nur normale Komponenten, nichts Absolutes.
|
||||
fn checked_rel(rel: &str) -> Result<(), String> {
|
||||
if rel.is_empty() {
|
||||
return Err("leerer Pfad".into());
|
||||
}
|
||||
for c in Path::new(rel).components() {
|
||||
match c {
|
||||
Component::Normal(_) => {}
|
||||
_ => return Err(format!("unzulässiger Pfad: {rel}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Vorhandenes Archiv-Dokument zum relpath.
|
||||
fn doc_path(home: &Path, relpath: &str) -> Result<PathBuf, String> {
|
||||
checked_rel(relpath)?;
|
||||
if !relpath.ends_with(".md") {
|
||||
return Err(format!("kein Markdown-Dokument: {relpath}"));
|
||||
}
|
||||
let path = home.join(relpath);
|
||||
if !path.is_file() {
|
||||
return Err(format!("Dokument nicht gefunden: {relpath}"));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Ziel für rename: darf noch nicht existieren — stilles Überschreiben wäre
|
||||
/// Datenverlust.
|
||||
fn fresh_target(target: &Path, home: &Path) -> Result<(), String> {
|
||||
if target.exists() {
|
||||
return Err(format!(
|
||||
"Ziel existiert bereits: {}",
|
||||
target.strip_prefix(home).unwrap_or(target).display()
|
||||
));
|
||||
}
|
||||
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.
|
||||
pub(crate) fn rename_doc(home: &Path, relpath: &str, name: &str) -> Result<String, String> {
|
||||
let src = doc_path(home, relpath)?;
|
||||
let stem = src.file_stem().unwrap().to_string_lossy().to_string();
|
||||
let stamp = &stem[..stem.len() - strip_stamp(&stem).len()];
|
||||
let target = src.with_file_name(format!("{stamp}{}.md", slugify(name)));
|
||||
if target == src {
|
||||
return Ok(relpath.to_string());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn delete_doc(home: &Path, relpath: &str) -> Result<(), String> {
|
||||
let path = doc_path(home, relpath)?;
|
||||
fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Legt einen (auch leeren) Ordner unterhalb des Archiv-Home an.
|
||||
pub(crate) fn create_folder(home: &Path, folder: &str) -> Result<(), String> {
|
||||
checked_rel(folder)?;
|
||||
let dir = home.join(folder);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
}
|
||||
|
||||
/// Legt ein leeres Dokument an: `<slug>.md` im Ordner (leer = Wurzel), das
|
||||
/// Datum steht in der Frontmatter (`created`), nicht im Dateinamen. Liefert
|
||||
/// den relpath.
|
||||
pub(crate) fn create_doc(
|
||||
home: &Path,
|
||||
folder: &str,
|
||||
name: &str,
|
||||
project_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Dokumentname fehlt".into());
|
||||
}
|
||||
if !folder.is_empty() {
|
||||
checked_rel(folder)?;
|
||||
}
|
||||
let dir = home.join(folder);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let target = dir.join(format!("{}.md", slugify(name)));
|
||||
let fm = frontmatter(name, project_name, &utc_now_iso()?, &ArchiveMeta::default());
|
||||
let mut file = match fs::OpenOptions::new().write(true).create_new(true).open(&target) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(format!(
|
||||
"Ziel existiert bereits: {}",
|
||||
target.strip_prefix(home).unwrap_or(&target).display()
|
||||
))
|
||||
}
|
||||
Err(e) => return Err(format!("{}: {e}", target.display())),
|
||||
};
|
||||
std::io::Write::write_all(&mut file, fm.as_bytes())
|
||||
.map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||
}
|
||||
|
||||
/// Schreibt den Panel-Body zurück in ein Archiv-Dokument (Quell-Verknüpfung
|
||||
/// des Dokument-Tabs): der Frontmatter-Block der Datei bleibt, der Rumpf wird
|
||||
/// ersetzt.
|
||||
pub(crate) fn write_body(path: &Path, text: &str) -> Result<(), String> {
|
||||
let doc = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let head = doc.len() - strip_frontmatter(&doc).len();
|
||||
let out = format!("{}{}\n", &doc[..head], text.trim_end());
|
||||
fs::write(path, out).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Verschiebt bzw. benennt einen ganzen Ordner um (fs::rename des
|
||||
/// Verzeichnisses); Eltern des Ziels entstehen bei Bedarf.
|
||||
pub(crate) fn move_folder(home: &Path, folder: &str, to: &str) -> Result<(), String> {
|
||||
checked_rel(folder)?;
|
||||
checked_rel(to)?;
|
||||
let src = home.join(folder);
|
||||
if !src.is_dir() {
|
||||
return Err(format!("Ordner nicht gefunden: {folder}"));
|
||||
}
|
||||
let target = home.join(to);
|
||||
if target == src {
|
||||
return Ok(());
|
||||
}
|
||||
fresh_target(&target, home)?;
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
fs::rename(&src, &target).map_err(|e| format!("{}: {e}", src.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::testutil::tmp_paths;
|
||||
|
||||
fn archiv() -> PathBuf {
|
||||
let home = tmp_paths().home.join("archiv");
|
||||
fs::create_dir_all(home.join("konzepte")).unwrap();
|
||||
fs::write(home.join("2026-07-19_1000-adr-logging.md"), "a").unwrap();
|
||||
fs::write(home.join("konzepte/2026-07-19_1005-notiz-deploy.md"), "b").unwrap();
|
||||
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, "konzepte").is_err()); // kein .md
|
||||
assert!(move_folder(&home, "konzepte", "../raus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn umbenennen_erhaelt_stempel_und_sluggt() {
|
||||
let home = archiv();
|
||||
let rel =
|
||||
rename_doc(&home, "konzepte/2026-07-19_1005-notiz-deploy.md", "Deploy Nötiz!").unwrap();
|
||||
assert_eq!(rel, "konzepte/2026-07-19_1005-deploy-noetiz.md");
|
||||
assert!(home.join(&rel).is_file());
|
||||
|
||||
// Ohne Stempel bleibt der Name pur.
|
||||
fs::write(home.join("plain.md"), "c").unwrap();
|
||||
assert_eq!(rename_doc(&home, "plain.md", "Neu").unwrap(), "neu.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anlegen_ordner_und_dokument() {
|
||||
let home = archiv();
|
||||
create_folder(&home, "notizen/2026").unwrap();
|
||||
assert!(home.join("notizen/2026").is_dir());
|
||||
assert!(create_folder(&home, "../raus").is_err());
|
||||
|
||||
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("created: "));
|
||||
|
||||
// In der Wurzel, gleicher Name kollidiert laut, leerer Name bricht ab.
|
||||
assert_eq!(create_doc(&home, "", "Plain", "proj").unwrap(), "plain.md");
|
||||
let err = create_doc(&home, "notizen/2026", "Deploy Nötiz!", "proj").unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
assert!(create_doc(&home, "", " ", "proj").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_body_ersetzt_rumpf_und_erhaelt_frontmatter() {
|
||||
let home = archiv();
|
||||
let path = home.join("mit-fm.md");
|
||||
fs::write(&path, "---\ntitle: \"T\"\n---\n\nalter Text\n").unwrap();
|
||||
write_body(&path, "neuer Text").unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).unwrap(),
|
||||
"---\ntitle: \"T\"\n---\n\nneuer Text\n"
|
||||
);
|
||||
|
||||
// Ohne Frontmatter wird die Datei komplett ersetzt.
|
||||
let plain = home.join("ohne-fm.md");
|
||||
fs::write(&plain, "alt").unwrap();
|
||||
write_body(&plain, "neu").unwrap();
|
||||
assert_eq!(fs::read_to_string(&plain).unwrap(), "neu\n");
|
||||
|
||||
assert!(write_body(&home.join("fehlt.md"), "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loeschen_und_ordner_verschieben() {
|
||||
let home = archiv();
|
||||
delete_doc(&home, "2026-07-19_1000-adr-logging.md").unwrap();
|
||||
assert!(!home.join("2026-07-19_1000-adr-logging.md").exists());
|
||||
|
||||
move_folder(&home, "konzepte", "notizen/2026").unwrap();
|
||||
assert!(home.join("notizen/2026/2026-07-19_1005-notiz-deploy.md").is_file());
|
||||
assert!(!home.join("konzepte").exists());
|
||||
|
||||
let err = move_folder(&home, "fehlt", "x").unwrap_err();
|
||||
assert!(err.contains("nicht gefunden"));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub(crate) mod archive;
|
||||
pub(crate) mod archive_index;
|
||||
pub(crate) mod archive_ops;
|
||||
pub(crate) mod archive_search;
|
||||
pub(crate) mod credentials;
|
||||
pub(crate) mod modules;
|
||||
|
||||
@@ -71,6 +71,18 @@ pub(crate) fn panel_file(project: &str) -> PathBuf {
|
||||
Paths::real().panels_dir().join(format!("{}.md", checked(project)))
|
||||
}
|
||||
|
||||
/// Quell-Verknüpfung des Panel-Inhalts: absoluter Pfad des Archiv-Dokuments,
|
||||
/// aus dem der Dokument-Tab gerade geladen ist. Solange sie existiert,
|
||||
/// schreibt jeder Editor-Commit den Body dorthin zurück; ein frischer Entwurf
|
||||
/// (write_panel, Session-Start) entfernt sie. Liegt neben der Panel-Datei als
|
||||
/// `<panel>.source`, damit der MCP-Server sie aus AI_CONTROL_PANEL ableiten
|
||||
/// kann.
|
||||
pub(crate) fn panel_source_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{}.md.source", checked(project)))
|
||||
}
|
||||
|
||||
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
|
||||
/// Session-Start geleert). Der Pfad landet als AI_CONTROL_COMMANDS in der
|
||||
/// PTY-Umgebung.
|
||||
|
||||
+10
-2
@@ -324,10 +324,18 @@ fn env_path(var: &str) -> Result<String, Value> {
|
||||
.map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control).")))
|
||||
}
|
||||
|
||||
/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL.
|
||||
/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL. Ein frischer
|
||||
/// Entwurf löst die Quell-Verknüpfung des Dokument-Tabs (`<panel>.source`,
|
||||
/// gesetzt beim Öffnen eines Archiv-Dokuments) — Edits gehören danach wieder
|
||||
/// nur dem Panel, nicht der zuletzt geöffneten Archiv-Datei.
|
||||
fn write_panel_text(text: &str) -> Result<(), Value> {
|
||||
let path = env_path("AI_CONTROL_PANEL")?;
|
||||
std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}")))
|
||||
std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}")))?;
|
||||
match std::fs::remove_file(format!("{path}.source")) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(err(format!("Quell-Verknüpfung nicht gelöst: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn call_write(req: &Value) -> Value {
|
||||
|
||||
+174
-25
@@ -5,7 +5,9 @@ use std::io::{Read, Write};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
use crate::domain::paths::{commands_file, panel_file, search_file, wiki_file, Paths};
|
||||
use crate::domain::paths::{
|
||||
commands_file, panel_file, panel_source_file, search_file, wiki_file, Paths,
|
||||
};
|
||||
use crate::domain::project::{
|
||||
project_config, project_pool_dir, verify_project_dir_in, ProjectConfig,
|
||||
};
|
||||
@@ -145,6 +147,13 @@ pub fn term_start(
|
||||
let _ = std::fs::write(path, "");
|
||||
}
|
||||
}
|
||||
// Quell-Verknüpfung der Vorsession lösen — der Dokument-Puffer startet leer,
|
||||
// Edits dürfen nicht in die zuletzt geöffnete Archiv-Datei laufen.
|
||||
match std::fs::remove_file(panel_source_file(&project)) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
|
||||
let mut cmd = crate::platform::shell_command(&claude_command(&paths));
|
||||
cmd.cwd(&cwd);
|
||||
@@ -419,21 +428,76 @@ pub fn todos_update(
|
||||
std::fs::write(&path, out).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Quell-Verknüpfung des Dokument-Tabs setzen: ab jetzt schreibt jeder
|
||||
/// Editor-Commit den Body in diese Archiv-Datei zurück.
|
||||
fn link_panel_source(project: &str, path: &std::path::Path) -> Result<(), String> {
|
||||
std::fs::write(panel_source_file(project), path.display().to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Aktuelle Quell-Verknüpfung, falls gesetzt.
|
||||
fn panel_source(project: &str) -> Option<std::path::PathBuf> {
|
||||
std::fs::read_to_string(panel_source_file(project))
|
||||
.ok()
|
||||
.map(|s| std::path::PathBuf::from(s.trim()))
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
}
|
||||
|
||||
/// Verknüpfung nach einer Dokument-Operation nachziehen: zeigt sie auf den
|
||||
/// alten Pfad, wandert sie mit; `None` löst sie (gelöschtes Dokument).
|
||||
fn relink(
|
||||
project: &str,
|
||||
old: &std::path::Path,
|
||||
new: Option<&std::path::Path>,
|
||||
) -> Result<(), String> {
|
||||
if panel_source(project).as_deref() != Some(old) {
|
||||
return Ok(());
|
||||
}
|
||||
match new {
|
||||
Some(p) => link_panel_source(project, p),
|
||||
None => std::fs::remove_file(panel_source_file(project)).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie `relink`, für einen verschobenen Ordner: eine Verknüpfung auf ein
|
||||
/// Dokument darin wandert mit.
|
||||
fn relink_folder(
|
||||
project: &str,
|
||||
old: &std::path::Path,
|
||||
new: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
let Some(cur) = panel_source(project) else {
|
||||
return Ok(());
|
||||
};
|
||||
match cur.strip_prefix(old) {
|
||||
Ok(rest) => link_panel_source(project, &new.join(rest)),
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die
|
||||
/// Änderung als `panel-update` an alle Fenster.
|
||||
/// Änderung als `panel-update` an alle Fenster. Zeigt der Dokument-Tab ein
|
||||
/// Archiv-Dokument (Quell-Verknüpfung), wandert der Body implizit in die
|
||||
/// Archiv-Datei zurück; ihre Frontmatter bleibt.
|
||||
#[tauri::command]
|
||||
pub fn panel_set(project: String, text: String) -> Result<(), String> {
|
||||
std::fs::write(panel_file(&project), text).map_err(|e| e.to_string())
|
||||
std::fs::write(panel_file(&project), &text).map_err(|e| e.to_string())?;
|
||||
match panel_source(&project) {
|
||||
Some(src) => crate::domain::archive_ops::write_body(&src, &text),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt ein Archiv-Dokument in den Dokument-Puffer (Treffer-Klick in der
|
||||
/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf. Der Watcher
|
||||
/// meldet den neuen Inhalt als `panel-update`.
|
||||
/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf — und setzt die
|
||||
/// Quell-Verknüpfung fürs Zurückschreiben. Der Watcher meldet den neuen
|
||||
/// Inhalt als `panel-update`.
|
||||
#[tauri::command]
|
||||
pub fn panel_load(project: String, path: String) -> Result<(), String> {
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let body = crate::domain::archive::strip_frontmatter(&text);
|
||||
std::fs::write(panel_file(&project), body).map_err(|e| e.to_string())
|
||||
std::fs::write(panel_file(&project), body).map_err(|e| e.to_string())?;
|
||||
link_panel_source(&project, std::path::Path::new(&path))
|
||||
}
|
||||
|
||||
/// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive und
|
||||
@@ -452,23 +516,102 @@ pub fn search_run(project: String, query: String, tag: Option<String>) -> Result
|
||||
std::fs::write(search_file(&project), payload.to_string()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Öffnet ein Wiki-Ziel (Klick auf einen `[[…]]`-Link oder Suchtreffer):
|
||||
/// `tag:x` → Schlagwort-Seite, `tag:` → Archiv-Übersicht, sonst
|
||||
/// Dokument-Auflösung über den Index. Der `tag:`-Namensraum ist damit dort
|
||||
/// interpretiert, wo archive_page ihn erzeugt — nicht im Frontend. Schreibt
|
||||
/// den Wiki-Puffer; der Watcher meldet ihn als `wiki-update`.
|
||||
/// Öffnet ein Wiki-Ziel: `tag:x` → Schlagwort-Seite, `tag:` →
|
||||
/// Archiv-Übersicht in den Wiki-Puffer (`wiki-update`); ein Dokumentname
|
||||
/// (Wikilink, Backlink) wird über den Index aufgelöst und wie ein
|
||||
/// Suchtreffer in den Dokument-Puffer geladen (`panel-update`).
|
||||
#[tauri::command]
|
||||
pub fn wiki_open(project: String, name: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let json = match name.strip_prefix("tag:") {
|
||||
Some(tag) => serde_json::to_string(&crate::domain::archive_index::archive_page(
|
||||
&home,
|
||||
(!tag.is_empty()).then_some(tag),
|
||||
)?),
|
||||
None => serde_json::to_string(&crate::domain::archive_index::wiki_doc(&home, &name)?),
|
||||
match name.strip_prefix("tag:") {
|
||||
Some(tag) => {
|
||||
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(
|
||||
&home,
|
||||
(!tag.is_empty()).then_some(tag),
|
||||
)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(wiki_file(&project), json).map_err(|e| e.to_string())
|
||||
}
|
||||
None => {
|
||||
let rel = crate::domain::archive_index::resolve_doc(&home, &name)?;
|
||||
let path = home.join(rel);
|
||||
let text =
|
||||
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let body = crate::domain::archive::strip_frontmatter(&text);
|
||||
std::fs::write(panel_file(&project), body).map_err(|e| e.to_string())?;
|
||||
link_panel_source(&project, &path)
|
||||
}
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(wiki_file(&project), json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Frische Archiv-Übersicht in den Wiki-Puffer — Abschluss der
|
||||
/// Dokument-/Ordner-Operationen; der Watcher meldet `wiki-update`.
|
||||
fn wiki_refresh_page(project: &str, home: &std::path::Path) -> Result<(), String> {
|
||||
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> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
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> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
crate::domain::archive_ops::create_folder(&home, &folder)?;
|
||||
wiki_refresh_page(&project, &home)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// bleibt hier unangetastet; der Wiki-Tab lädt sie beim nächsten Aktivieren
|
||||
/// 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> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
|
||||
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())?;
|
||||
link_panel_source(&project, &path)
|
||||
}
|
||||
|
||||
/// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach
|
||||
@@ -476,7 +619,11 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> {
|
||||
/// Async, weil Fenster-Erzeugung aus einem synchronen Command auf dem
|
||||
/// GTK-Mainloop klemmen kann (Tauri-Vorgabe für window create in Commands).
|
||||
#[tauri::command]
|
||||
pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), String> {
|
||||
pub async fn open_panel_window(
|
||||
app: AppHandle,
|
||||
project: String,
|
||||
mode: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let label = format!("panel-{project}");
|
||||
if let Some(w) = app.get_webview_window(&label) {
|
||||
let _ = w.set_focus();
|
||||
@@ -490,11 +637,13 @@ pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), St
|
||||
.as_deref()
|
||||
.or(cfg.name.as_deref())
|
||||
.unwrap_or(project.as_str());
|
||||
let builder = WebviewWindowBuilder::new(
|
||||
&app,
|
||||
&label,
|
||||
WebviewUrl::App(format!("panel.html?project={project}").into()),
|
||||
)
|
||||
// Der beim Ablösen aktive Tab geht als URL-Parameter mit — das neue
|
||||
// Fenster startet dort, statt in seiner Default-Ansicht.
|
||||
let url = match &mode {
|
||||
Some(m) => format!("panel.html?project={project}&mode={m}"),
|
||||
None => format!("panel.html?project={project}"),
|
||||
};
|
||||
let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.into()))
|
||||
.title(format!("{title} — Dokument"))
|
||||
.inner_size(480.0, 640.0)
|
||||
.background_color(tauri::window::Color(r, g, b, 0xff));
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("t()", () => {
|
||||
});
|
||||
|
||||
it("lässt unbekannte Platzhalter stehen", () => {
|
||||
expect(t("wiki.emptyTag", { falsch: "x" })).toBe("Keine Dokumente mit #{tag}.");
|
||||
expect(t("search.noHits", { falsch: "x" })).toBe("Keine Treffer für {scope}");
|
||||
});
|
||||
|
||||
it("gibt bei fehlendem Schlüssel den Pfad zurück", () => {
|
||||
|
||||
+20
-12
@@ -235,18 +235,22 @@ const de = {
|
||||
archive: "Archiv",
|
||||
docOne: "{count} Dokument",
|
||||
docMany: "{count} Dokumente",
|
||||
all: "Alle",
|
||||
backlinks: "Verweise hierher",
|
||||
backlinksLabel: "Verweise hierher: ",
|
||||
emptyTag: "Keine Dokumente mit #{tag}.",
|
||||
emptyArchive: "Das Archiv ist leer.",
|
||||
emptyFolder: "Keine Dokumente in diesem Ordner.",
|
||||
emptyHint:
|
||||
"Archivieren: Archiv-Button im Entwurf oder „archiviere das“ im Chat — mit Ordner, Beschreibung und Schlagwörtern.",
|
||||
recent: "Zuletzt",
|
||||
root: "Wurzel",
|
||||
back: "‹ Archiv",
|
||||
noPage: "Keine Wiki-Seite geladen.",
|
||||
openOverview: "Archiv-Übersicht öffnen",
|
||||
folderRename: "Ordner umbenennen",
|
||||
renameDoc: "Dokument umbenennen",
|
||||
deleteDoc: "Dokument löschen",
|
||||
newFolder: "Neuer Ordner",
|
||||
newDoc: "Neues Dokument",
|
||||
create: "Anlegen",
|
||||
cancel: "Abbrechen",
|
||||
folderPath: "ordner/unterordner",
|
||||
docName: "Name des Dokuments",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -480,18 +484,22 @@ const en: typeof de = {
|
||||
archive: "Archive",
|
||||
docOne: "{count} document",
|
||||
docMany: "{count} documents",
|
||||
all: "All",
|
||||
backlinks: "Links here",
|
||||
backlinksLabel: "Links here: ",
|
||||
emptyTag: "No documents tagged #{tag}.",
|
||||
emptyArchive: "The archive is empty.",
|
||||
emptyFolder: "No documents in this folder.",
|
||||
emptyHint:
|
||||
"To archive: the archive button in the draft, or “archive this” in the chat — with folder, description and tags.",
|
||||
recent: "Recent",
|
||||
root: "Root",
|
||||
back: "‹ Archive",
|
||||
noPage: "No wiki page loaded.",
|
||||
openOverview: "Open archive overview",
|
||||
folderRename: "Rename folder",
|
||||
renameDoc: "Rename document",
|
||||
deleteDoc: "Delete document",
|
||||
newFolder: "New folder",
|
||||
newDoc: "New document",
|
||||
create: "Create",
|
||||
cancel: "Cancel",
|
||||
folderPath: "folder/subfolder",
|
||||
docName: "Document name",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+23
-4
@@ -9,11 +9,30 @@ export const wikiTab: PanelTab = {
|
||||
buffer: "wiki",
|
||||
labelKey: "panel.tabWiki",
|
||||
titleKey: "panel.tabWikiTitle",
|
||||
init: (container, ctx) => initWikiView(container, ctx.openWiki),
|
||||
// Wiki-Tab bei leerem Puffer (Session-Start): Übersicht direkt laden.
|
||||
onActivate: (view, ctx) => {
|
||||
if (view.empty()) ctx.openWiki("tag:");
|
||||
// Dokument-/Ordner-Operationen laufen als Commands; der neue Stand kommt
|
||||
// über den Wiki-Puffer (wiki-update) zurück, Fehler als Toast. Der
|
||||
// Dokument-Sprung lädt wie ein Suchtreffer in den Dokument-Tab.
|
||||
init: (container, ctx) => {
|
||||
const run = (cmd: string, args: Record<string, string>) =>
|
||||
void invoke(cmd, { project: ctx.project, ...args }).catch((e) =>
|
||||
ctx.toast(String(e)),
|
||||
);
|
||||
return initWikiView(container, {
|
||||
openDoc: ctx.openDoc,
|
||||
openWiki: ctx.openWiki,
|
||||
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 }),
|
||||
},
|
||||
});
|
||||
},
|
||||
// Beim Aktivieren die Übersicht frisch laden: das Anlegen eines Dokuments
|
||||
// öffnet den Dokument-Tab, ohne den Wiki-Puffer anzufassen — und auch
|
||||
// direkt archivierte oder von Hand abgelegte Dateien erscheinen so.
|
||||
onActivate: (_view, ctx) => ctx.openWiki("tag:"),
|
||||
};
|
||||
|
||||
export const searchTab: PanelTab = {
|
||||
|
||||
+185
-126
@@ -311,7 +311,7 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Wiki-Ansicht: Übersichts-/Schlagwort-Seiten und Dokumente. */
|
||||
/* Wiki-Ansicht: Ordner-Baum links, flache Inhaltsliste rechts. */
|
||||
#wiki-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -325,6 +325,189 @@
|
||||
background: var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* Baum links, Seite rechts; beide scrollen gemeinsam im Tab. */
|
||||
.wiki-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.wiki-tree {
|
||||
flex: 0 0 150px;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
padding-right: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.wiki-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wiki-tree details > .wiki-tree-children {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.wiki-tree summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 2px;
|
||||
color: var(--muted);
|
||||
font:
|
||||
500 11px/1.4 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.wiki-tree summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.wiki-tree summary::before {
|
||||
content: "▸";
|
||||
color: var(--faint);
|
||||
font-size: 9px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
.wiki-tree details[open] > summary::before {
|
||||
content: "▾";
|
||||
}
|
||||
.wiki-tree-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wiki-tree summary .panel-btn {
|
||||
visibility: hidden;
|
||||
}
|
||||
.wiki-tree summary:hover .panel-btn {
|
||||
visibility: visible;
|
||||
}
|
||||
.wiki-tree summary:hover,
|
||||
.wiki-tree-root:hover {
|
||||
background: var(--tile);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wiki-tree summary.active,
|
||||
.wiki-tree-root.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.wiki-tree-root {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font:
|
||||
500 11px/1.5 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
.wiki-tree-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
color: var(--text);
|
||||
font:
|
||||
400 11px/1.4 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
outline: none;
|
||||
}
|
||||
/* Zeilen-Aktionen erst beim Überfahren der Zeile. */
|
||||
.wiki-doc-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
visibility: hidden;
|
||||
}
|
||||
.wiki-doc:hover .wiki-doc-actions {
|
||||
visibility: visible;
|
||||
}
|
||||
/* Kopfzeile des Baums: Wurzel-Knopf + Ordner anlegen. */
|
||||
.wiki-tree-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.wiki-tree-head .wiki-tree-root {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wiki-add {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 0 5px;
|
||||
font:
|
||||
500 13px/1.4 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wiki-add:hover {
|
||||
background: var(--tile);
|
||||
color: var(--text);
|
||||
}
|
||||
/* Anlege-Formular (Ordner/Dokument) oben in der Liste. */
|
||||
.wiki-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
.wiki-form-title {
|
||||
color: var(--muted);
|
||||
font:
|
||||
500 11px/1.4 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wiki-form .wiki-tree-input {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wiki-form-submit,
|
||||
.wiki-form-cancel {
|
||||
border: none;
|
||||
background: var(--line);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font:
|
||||
500 11px/1.4 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
.wiki-form-submit:hover {
|
||||
background: var(--line-strong);
|
||||
}
|
||||
.wiki-form-cancel {
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
.wiki-form-cancel:hover {
|
||||
background: var(--tile);
|
||||
}
|
||||
.wiki-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -358,12 +541,7 @@
|
||||
sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wiki-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
/* Einstiegs-Knopf im Leerzustand. */
|
||||
.wiki-chip {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
@@ -379,25 +557,6 @@
|
||||
background: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
.wiki-chip.active {
|
||||
background: var(--line);
|
||||
border-color: var(--line-strong);
|
||||
color: var(--accent);
|
||||
}
|
||||
.wiki-folder {
|
||||
margin: 14px 0 2px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--faint);
|
||||
font:
|
||||
500 10px/1 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.wiki-doc {
|
||||
padding: 7px 8px;
|
||||
margin: 4px -8px 0;
|
||||
@@ -434,12 +593,6 @@
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
.wiki-doc-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.wiki-empty {
|
||||
margin-top: 18px;
|
||||
color: var(--muted);
|
||||
@@ -454,100 +607,6 @@
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wiki-doc-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.wiki-back {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font:
|
||||
500 11px/1 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.wiki-back:hover {
|
||||
background: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
.wiki-body {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
.wiki-body h1,
|
||||
.wiki-body h2,
|
||||
.wiki-body h3 {
|
||||
line-height: 1.25;
|
||||
margin: 1em 0 0.4em;
|
||||
}
|
||||
.wiki-body h1 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
.wiki-body h2 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.wiki-body h3 {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.wiki-body :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.wiki-body p,
|
||||
.wiki-body ul,
|
||||
.wiki-body ol,
|
||||
.wiki-body pre,
|
||||
.wiki-body blockquote {
|
||||
margin: 0 0 0.7em;
|
||||
}
|
||||
.wiki-body code {
|
||||
font-family: "JetBrains Mono", Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
background: var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.wiki-body pre {
|
||||
background: var(--tile);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
.wiki-body pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.wiki-body blockquote {
|
||||
border-left: 3px solid var(--line);
|
||||
padding-left: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.wiki-body a {
|
||||
color: var(--accent);
|
||||
}
|
||||
.wiki-backlinks {
|
||||
margin-top: 14px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--faint);
|
||||
font:
|
||||
500 11px/1.6 -apple-system,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
.wiki-backlinks a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Backlink-Zähler in der Dokumentzeile. */
|
||||
.wiki-doc-back {
|
||||
color: var(--faint);
|
||||
|
||||
+3
-2
@@ -123,10 +123,11 @@ export async function wirePanel(
|
||||
flush: () => void view.flush(),
|
||||
});
|
||||
|
||||
// Sprung Dokument → Wiki: löst den angezeigten Titel gegen das Archiv auf.
|
||||
// Sprung Dokument → Wiki: öffnet den Archiv-Navigator (Dokumentseiten im
|
||||
// Wiki gibt es nicht mehr — Dokumente öffnen immer im Dokument-Tab).
|
||||
document
|
||||
.getElementById("panel-wiki-jump")!
|
||||
.addEventListener("click", () => openWiki(titleEl.textContent || ""));
|
||||
.addEventListener("click", () => openWiki("tag:"));
|
||||
|
||||
view.set(draft);
|
||||
await listen<string>("panel-update", (e) => {
|
||||
|
||||
+7
-3
@@ -100,11 +100,15 @@ document.documentElement.style.background = picked.header;
|
||||
document.body.style.background = picked.header;
|
||||
|
||||
const { view, views, mode, draft } = await wirePanel(project);
|
||||
// Ohne Entwurf startet das Fenster in der ToDo-Liste (falls gewählt und
|
||||
// nicht leer), sonst in den Befehlen.
|
||||
// Beim Ablösen kommt der dort aktive Tab als URL-Parameter mit; ohne ihn
|
||||
// (Kaltstart) gilt die Default-Logik: ohne Entwurf die ToDo-Liste (falls
|
||||
// gewählt und nicht leer), sonst die Befehle.
|
||||
const initialMode = new URLSearchParams(location.search).get("mode");
|
||||
const todos = views.get("todo");
|
||||
const cmds = views.get("commands");
|
||||
if (!draft.trim()) {
|
||||
if (initialMode) {
|
||||
mode.to(initialMode);
|
||||
} else if (!draft.trim()) {
|
||||
if (todos && !todos.empty()) mode.to("todo");
|
||||
else if (cmds && !cmds.empty()) mode.to("commands");
|
||||
}
|
||||
|
||||
+4
-1
@@ -254,9 +254,12 @@ await listen("panel-window-closed", () => {
|
||||
headerTabs.hidden = false;
|
||||
});
|
||||
|
||||
// Der aktive Tab wandert mit ins abgelöste Fenster.
|
||||
document
|
||||
.getElementById("panel-detach")!
|
||||
.addEventListener("click", () => invoke("open_panel_window", { project }));
|
||||
.addEventListener("click", () =>
|
||||
invoke("open_panel_window", { project, mode: mode.current() }),
|
||||
);
|
||||
document.getElementById("panel-hide")!.addEventListener("click", () => {
|
||||
hidePanel();
|
||||
mode.clear();
|
||||
|
||||
+181
-82
@@ -1,15 +1,29 @@
|
||||
// Wiki-Ansicht: Übersichts-/Schlagwort-Seiten, Dokumentseite, Leerzustände.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
// Wiki-Ansicht: Ordner-Baum links, flache Inhaltsliste rechts, Zeilen-Aktionen
|
||||
// (öffnen, umbenennen, löschen), Ordner-Umbenennen, Leerzustände.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { initWikiView } from "./wiki-view";
|
||||
|
||||
function setup() {
|
||||
document.body.innerHTML = `<div id="w"></div>`;
|
||||
const onLink = vi.fn();
|
||||
const view = initWikiView(document.getElementById("w")!, onLink);
|
||||
return { view, onLink };
|
||||
const openDoc = vi.fn();
|
||||
const openWiki = vi.fn();
|
||||
const actions = {
|
||||
rename: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
moveFolder: vi.fn(),
|
||||
createFolder: vi.fn(),
|
||||
createDoc: vi.fn(),
|
||||
};
|
||||
const view = initWikiView(document.getElementById("w")!, {
|
||||
openDoc,
|
||||
openWiki,
|
||||
actions,
|
||||
});
|
||||
return { view, openDoc, openWiki, actions };
|
||||
}
|
||||
|
||||
const doc = (name: string, extra: object = {}) => ({
|
||||
const doc = (relpath: string, name: string, extra: object = {}) => ({
|
||||
relpath,
|
||||
name,
|
||||
title: name.toUpperCase(),
|
||||
description: null,
|
||||
@@ -24,67 +38,185 @@ const page = JSON.stringify({
|
||||
home: "/tmp/archiv",
|
||||
tag: null,
|
||||
total: 3,
|
||||
tags: [
|
||||
{ name: "adr", count: 2 },
|
||||
{ name: "infra", count: 1 },
|
||||
],
|
||||
recent: [doc("neu")],
|
||||
tags: [],
|
||||
folders: [
|
||||
{ name: "", docs: [doc("wurzel-doc", { backlinks: 2, tags: ["adr"] })] },
|
||||
{ name: "konzepte", docs: [doc("neu"), doc("alt")] },
|
||||
{
|
||||
name: "",
|
||||
docs: [
|
||||
doc("2026-07-19_1000-wurzel-doc.md", "wurzel-doc", {
|
||||
backlinks: 2,
|
||||
description: "Beschreibung",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{ 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")],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("initWikiView — Seiten", () => {
|
||||
it("rendert Kopf, Chips, Zuletzt- und Ordner-Sektionen", () => {
|
||||
describe("initWikiView — Baum und Liste", () => {
|
||||
it("Baum zeigt nur Ordner, Wurzel ist gewählt und listet ihren Inhalt", () => {
|
||||
const { view } = setup();
|
||||
view.set(page);
|
||||
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("Archiv");
|
||||
expect(document.querySelector(".wiki-head-right")!.textContent).toBe("3 Dokumente");
|
||||
const chips = [...document.querySelectorAll(".wiki-chips .wiki-chip")].map(
|
||||
(c) => c.textContent,
|
||||
);
|
||||
expect(chips).toEqual(["Alle", "#adr 2", "#infra 1"]);
|
||||
const eyebrows = [...document.querySelectorAll(".wiki-folder")].map(
|
||||
// Baum: Wurzel-Knopf + Ordner (keine Dokumentzeilen im Baum).
|
||||
expect(document.querySelector(".wiki-tree-root")!.className).toContain("active");
|
||||
const folders = [...document.querySelectorAll(".wiki-tree .wiki-tree-name")].map(
|
||||
(e) => e.textContent,
|
||||
);
|
||||
expect(eyebrows).toEqual(["Zuletzt", "Wurzel", "konzepte/"]);
|
||||
expect(folders).toEqual(["konzepte", "panel"]);
|
||||
// Liste rechts: nur der direkte Wurzel-Inhalt.
|
||||
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("Archiv");
|
||||
expect(document.querySelector(".wiki-head-right")!.textContent).toBe("1 Dokument");
|
||||
const rows = [...document.querySelectorAll(".wiki-doc .wiki-doc-title")].map(
|
||||
(e) => e.textContent,
|
||||
);
|
||||
expect(rows).toEqual(["WURZEL-DOC"]);
|
||||
expect(document.querySelector(".wiki-doc-back")!.textContent).toBe("↩ 2");
|
||||
expect(document.querySelector(".wiki-doc-desc")!.textContent).toBe("Beschreibung");
|
||||
});
|
||||
|
||||
it("Dokumentzeile öffnet das Dokument, Tag-Chip die Tag-Seite", () => {
|
||||
const { view, onLink } = setup();
|
||||
view.set(page);
|
||||
const rows = document.querySelectorAll<HTMLElement>(".wiki-doc");
|
||||
rows[1].click(); // Wurzel-Dokument (nach der Zuletzt-Zeile)
|
||||
expect(onLink).toHaveBeenCalledWith("wurzel-doc");
|
||||
rows[1].querySelector<HTMLElement>(".wiki-doc-tags .wiki-chip")!.click();
|
||||
expect(onLink).toHaveBeenCalledWith("tag:adr");
|
||||
document.querySelector<HTMLElement>(".wiki-chips .wiki-chip")!.click();
|
||||
expect(onLink).toHaveBeenCalledWith("tag:");
|
||||
});
|
||||
|
||||
it("Tag-Seite: Titel, aktiver Chip, Leerfall", () => {
|
||||
it("Ordner-Klick wählt aus und zeigt dessen flache Liste", () => {
|
||||
const { view } = setup();
|
||||
view.set(page);
|
||||
const names = document.querySelectorAll<HTMLElement>(".wiki-tree .wiki-tree-name");
|
||||
names[0].click(); // konzepte
|
||||
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("konzepte");
|
||||
expect(document.querySelector(".wiki-head-sub")!.textContent).toBe(
|
||||
"/tmp/archiv/konzepte",
|
||||
);
|
||||
const rows = [...document.querySelectorAll(".wiki-doc .wiki-doc-title")].map(
|
||||
(e) => e.textContent,
|
||||
);
|
||||
// Nur direkter Inhalt — das Dokument aus konzepte/panel erscheint nicht.
|
||||
expect(rows).toEqual(["NEU"]);
|
||||
expect(
|
||||
document.querySelector(".wiki-tree summary.active .wiki-tree-name")!.textContent,
|
||||
).toBe("konzepte");
|
||||
});
|
||||
|
||||
it("Dokument-Klick öffnet über den absoluten Pfad", () => {
|
||||
const { view, openDoc } = setup();
|
||||
view.set(page);
|
||||
document.querySelector<HTMLElement>(".wiki-doc")!.click();
|
||||
expect(openDoc).toHaveBeenCalledWith("/tmp/archiv/2026-07-19_1000-wurzel-doc.md");
|
||||
});
|
||||
|
||||
it("Auswahl übersteht ein Puffer-Update, verschwundener Ordner fällt zur Wurzel", () => {
|
||||
const { view } = setup();
|
||||
view.set(page);
|
||||
document.querySelectorAll<HTMLElement>(".wiki-tree .wiki-tree-name")[0].click();
|
||||
view.set(page); // Update: Auswahl bleibt
|
||||
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("konzepte");
|
||||
view.set(
|
||||
JSON.stringify({
|
||||
kind: "page",
|
||||
home: "/a",
|
||||
tag: "adr",
|
||||
total: 0,
|
||||
tags: [{ name: "adr", count: 1 }],
|
||||
recent: [],
|
||||
folders: [],
|
||||
home: "/tmp/archiv",
|
||||
tag: null,
|
||||
total: 1,
|
||||
tags: [],
|
||||
folders: [{ name: "", docs: [doc("2026-07-19_1000-wurzel-doc.md", "wurzel-doc")] }],
|
||||
}),
|
||||
);
|
||||
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("#adr");
|
||||
const active = document.querySelector(".wiki-chip.active")!;
|
||||
expect(active.textContent).toBe("#adr 1");
|
||||
expect(document.querySelector(".wiki-empty strong")!.textContent).toBe(
|
||||
"Keine Dokumente mit #adr.",
|
||||
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("Archiv");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initWikiView — Aktionen", () => {
|
||||
it("Stift an der Zeile: Inline-Umbenennen mit Enter, ohne das Dokument zu öffnen", () => {
|
||||
const { view, actions, openDoc } = setup();
|
||||
view.set(page);
|
||||
const row = document.querySelector<HTMLElement>(".wiki-doc")!;
|
||||
row.querySelector<HTMLElement>(".cmd-edit")!.click();
|
||||
const input = row.querySelector<HTMLInputElement>(".wiki-tree-input")!;
|
||||
expect(input.value).toBe("wurzel-doc");
|
||||
input.value = "Neuer Name";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
expect(actions.rename).toHaveBeenCalledWith(
|
||||
"2026-07-19_1000-wurzel-doc.md",
|
||||
"Neuer Name",
|
||||
);
|
||||
expect(openDoc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Escape stellt die Zeile wieder her", () => {
|
||||
const { view, actions } = setup();
|
||||
view.set(page);
|
||||
document.querySelector<HTMLElement>(".wiki-doc .cmd-edit")!.click();
|
||||
const input = document.querySelector<HTMLInputElement>(".wiki-doc .wiki-tree-input")!;
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
expect(document.querySelector(".wiki-doc .wiki-doc-title")!.textContent).toBe(
|
||||
"WURZEL-DOC",
|
||||
);
|
||||
expect(actions.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Löschen meldet mit relpath, ohne das Dokument zu öffnen", () => {
|
||||
const { view, actions, openDoc } = setup();
|
||||
view.set(page);
|
||||
document.querySelector<HTMLElement>(".wiki-doc .cmd-del")!.click();
|
||||
expect(actions.remove).toHaveBeenCalledWith("2026-07-19_1000-wurzel-doc.md");
|
||||
expect(openDoc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Plus im Listenkopf: Formular legt Dokument im gewählten Ordner an", () => {
|
||||
const { view, actions, openDoc } = setup();
|
||||
view.set(page);
|
||||
document.querySelectorAll<HTMLElement>(".wiki-tree .wiki-tree-name")[0].click();
|
||||
document.querySelector<HTMLElement>(".wiki-head .wiki-add")!.click();
|
||||
const form = document.querySelector<HTMLElement>(".wiki-form")!;
|
||||
expect(form.querySelector(".wiki-form-title")!.textContent).toBe("Neues Dokument");
|
||||
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(document.querySelector(".wiki-form")).toBeNull();
|
||||
expect(openDoc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Abbrechen und Escape schließen das Formular ohne Aktion", () => {
|
||||
const { view, actions } = setup();
|
||||
view.set(page);
|
||||
document.querySelector<HTMLElement>(".wiki-head .wiki-add")!.click();
|
||||
document.querySelector<HTMLElement>(".wiki-form-cancel")!.click();
|
||||
expect(document.querySelector(".wiki-form")).toBeNull();
|
||||
|
||||
document.querySelector<HTMLElement>(".wiki-head .wiki-add")!.click();
|
||||
document
|
||||
.querySelector<HTMLElement>(".wiki-form")!
|
||||
.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
expect(document.querySelector(".wiki-form")).toBeNull();
|
||||
expect(actions.createDoc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Plus im Baum: Formular legt Ordner an, Pfad mit gewähltem Ordner vorbefüllt", () => {
|
||||
const { view, actions } = setup();
|
||||
view.set(page);
|
||||
document.querySelectorAll<HTMLElement>(".wiki-tree .wiki-tree-name")[0].click();
|
||||
document.querySelector<HTMLElement>(".wiki-tree-head .wiki-add")!.click();
|
||||
const input = document.querySelector<HTMLInputElement>(".wiki-form input")!;
|
||||
expect(input.value).toBe("konzepte/");
|
||||
input.value = "konzepte/neu";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
expect(actions.createFolder).toHaveBeenCalledWith("konzepte/neu");
|
||||
});
|
||||
|
||||
it("Ordner-Stift: Inline-Eingabe des Pfads, Enter meldet moveFolder", () => {
|
||||
const { view, actions } = setup();
|
||||
view.set(page);
|
||||
const summary = document.querySelector<HTMLElement>(".wiki-tree summary")!;
|
||||
summary.querySelector<HTMLElement>(".cmd-edit")!.click();
|
||||
const input = summary.querySelector<HTMLInputElement>(".wiki-tree-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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initWikiView — Leerzustände", () => {
|
||||
it("leeres Archiv erklärt das Archivieren", () => {
|
||||
const { view } = setup();
|
||||
view.set(
|
||||
@@ -94,7 +226,6 @@ describe("initWikiView — Seiten", () => {
|
||||
tag: null,
|
||||
total: 0,
|
||||
tags: [],
|
||||
recent: [],
|
||||
folders: [],
|
||||
}),
|
||||
);
|
||||
@@ -102,45 +233,13 @@ describe("initWikiView — Seiten", () => {
|
||||
"Das Archiv ist leer.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initWikiView — Dokument und Leerzustand", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("rendert Markdown, Rücksprung, Tags und Backlinks", () => {
|
||||
const { view, onLink } = setup();
|
||||
view.set(
|
||||
JSON.stringify({
|
||||
kind: "doc",
|
||||
home: "/a",
|
||||
relpath: "konzepte/2026-07-19_1000-x.md",
|
||||
name: "x",
|
||||
title: "X",
|
||||
tags: ["adr"],
|
||||
backlinks: ["anderes-doc"],
|
||||
markdown: "# Überschrift\n\nText mit [[ziel|Label]].\n",
|
||||
}),
|
||||
);
|
||||
expect(document.querySelector(".wiki-body h1")!.textContent).toBe("Überschrift");
|
||||
const wiki = document.querySelector<HTMLElement>(".wiki-body a.wiki")!;
|
||||
expect(wiki.textContent).toBe("Label");
|
||||
wiki.click();
|
||||
expect(onLink).toHaveBeenCalledWith("ziel");
|
||||
document.querySelector<HTMLElement>(".wiki-back")!.click();
|
||||
expect(onLink).toHaveBeenCalledWith("tag:");
|
||||
const back = document.querySelector<HTMLElement>(".wiki-backlinks a")!;
|
||||
back.click();
|
||||
expect(onLink).toHaveBeenCalledWith("anderes-doc");
|
||||
});
|
||||
|
||||
it("leerer Puffer bietet den Übersichts-Einstieg", () => {
|
||||
const { view, onLink } = setup();
|
||||
const { view, openWiki } = setup();
|
||||
view.set("");
|
||||
expect(view.empty()).toBe(true);
|
||||
document.querySelector<HTMLElement>(".wiki-empty .wiki-chip")!.click();
|
||||
expect(onLink).toHaveBeenCalledWith("tag:");
|
||||
expect(openWiki).toHaveBeenCalledWith("tag:");
|
||||
view.set(page);
|
||||
expect(view.empty()).toBe(false);
|
||||
});
|
||||
|
||||
+268
-144
@@ -1,15 +1,16 @@
|
||||
/// Wiki-Ansicht des Panels: rendert den Wiki-Puffer (show_archive/wiki_open).
|
||||
/// Übersichts- und Schlagwort-Seiten kommen als strukturierte Daten (Kopfzeile,
|
||||
/// Schlagwort-Chips, Ordner-Sektionen mit Dokumentzeilen), Dokumente als
|
||||
/// gerendertes Markdown mit Backlinks. Titel, Beschreibungen und Pfade sind
|
||||
/// Fremdtext und gehen nie durch innerHTML; nur der Markdown-Rumpf läuft wie
|
||||
/// im Entwurf durch marked.
|
||||
/// Wiki-Ansicht des Panels: Ordner-Navigator übers Archiv. Links der Baum —
|
||||
/// nur Ordner, oberster Knoten ist die Archiv-Wurzel; Klick auf einen Ordner
|
||||
/// zeigt rechts die flache Liste seines direkten Inhalts. Klick auf eine
|
||||
/// Dokumentzeile lädt das Dokument in den Dokument-Tab (wie ein
|
||||
/// Suchtreffer); an der Zeile: umbenennen (inline) und löschen, am
|
||||
/// Ordnerknoten: umbenennen. Titel, Beschreibungen und Pfade sind Fremdtext
|
||||
/// und gehen nie durch innerHTML.
|
||||
|
||||
import { renderMarkdown } from "./markdown";
|
||||
import { linkWikiRefs } from "./panel-view";
|
||||
import { t } from "./messages";
|
||||
import { deleteAction, editAction } from "./tiles";
|
||||
|
||||
interface DocEntry {
|
||||
relpath: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
@@ -29,19 +30,25 @@ interface Page {
|
||||
tag?: string | null;
|
||||
total: number;
|
||||
tags: { name: string; count: number }[];
|
||||
recent: DocEntry[];
|
||||
folders: Folder[];
|
||||
}
|
||||
|
||||
interface DocPage {
|
||||
kind: "doc";
|
||||
home: string;
|
||||
relpath: string;
|
||||
name: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
backlinks: string[];
|
||||
markdown: string;
|
||||
/// 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;
|
||||
}
|
||||
|
||||
export interface WikiCallbacks {
|
||||
/// Dokument in den Dokument-Tab laden (absoluter Pfad, wie Suchtreffer).
|
||||
openDoc(path: string): void;
|
||||
/// Wiki-Ziel laden (`tag:` = Übersicht in den Puffer, Einstiegs-Chip).
|
||||
openWiki(name: string): void;
|
||||
actions: WikiActions;
|
||||
}
|
||||
|
||||
export interface WikiView {
|
||||
@@ -50,54 +57,160 @@ export interface WikiView {
|
||||
empty(): boolean;
|
||||
}
|
||||
|
||||
export function initWikiView(
|
||||
container: HTMLElement,
|
||||
onLink: (name: string) => void,
|
||||
): WikiView {
|
||||
function chip(label: string, target: string, active = false): HTMLElement {
|
||||
const b = document.createElement("button");
|
||||
b.className = "wiki-chip" + (active ? " active" : "");
|
||||
b.textContent = label;
|
||||
b.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onLink(target);
|
||||
});
|
||||
return b;
|
||||
}
|
||||
/// Verschachtelter Ordner-Knoten, aus den Ordnerpfaden gebaut.
|
||||
interface TreeNode {
|
||||
children: Map<string, TreeNode>;
|
||||
}
|
||||
|
||||
/// Kopfzeile einer Seite: Titel + Home-Pfad links, Dokumentzahl rechts.
|
||||
function pageHead(p: Page): HTMLElement {
|
||||
const head = document.createElement("div");
|
||||
head.className = "wiki-head";
|
||||
const left = document.createElement("div");
|
||||
left.className = "wiki-head-left";
|
||||
const title = document.createElement("div");
|
||||
title.className = "wiki-head-title";
|
||||
title.textContent = p.tag ? `#${p.tag}` : t("wiki.archive");
|
||||
const sub = document.createElement("div");
|
||||
sub.className = "wiki-head-sub";
|
||||
sub.textContent = p.home;
|
||||
left.append(title, sub);
|
||||
const count = document.createElement("div");
|
||||
count.className = "wiki-head-right";
|
||||
count.textContent = t(p.total === 1 ? "wiki.docOne" : "wiki.docMany", { count: p.total });
|
||||
head.append(left, count);
|
||||
return head;
|
||||
}
|
||||
|
||||
/// Schlagwort-Leiste: „Alle“ plus ein Chip pro Schlagwort mit Zähler; der
|
||||
/// aktive Filter ist markiert.
|
||||
function tagBar(p: Page): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "wiki-chips";
|
||||
bar.append(chip(t("wiki.all"), "tag:", !p.tag));
|
||||
for (const t of p.tags) {
|
||||
bar.append(chip(`#${t.name} ${t.count}`, `tag:${t.name}`, p.tag === t.name));
|
||||
function buildTree(folders: Folder[]): TreeNode {
|
||||
const root: TreeNode = { children: new Map() };
|
||||
for (const folder of folders) {
|
||||
if (!folder.name) continue;
|
||||
let node = root;
|
||||
for (const part of folder.name.split("/")) {
|
||||
if (!node.children.has(part)) {
|
||||
node.children.set(part, { children: new Map() });
|
||||
}
|
||||
node = node.children.get(part)!;
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiView {
|
||||
/// Zugeklappte Ordner und gewählter Ordner ("" = Wurzel) — überleben
|
||||
/// Puffer-Updates innerhalb der Ansicht.
|
||||
const closed = new Set<string>();
|
||||
let selected = "";
|
||||
let current: Page | null = null;
|
||||
|
||||
// ---------- Baum-Spalte (nur Ordner) ----------
|
||||
|
||||
function folderRow(name: string, full: string, node: TreeNode): HTMLElement {
|
||||
const det = document.createElement("details");
|
||||
det.className = "wiki-tree-folder";
|
||||
det.open = !closed.has(full);
|
||||
det.addEventListener("toggle", () => {
|
||||
if (det.open) closed.delete(full);
|
||||
else closed.add(full);
|
||||
});
|
||||
const sum = document.createElement("summary");
|
||||
if (full === selected) sum.classList.add("active");
|
||||
const label = document.createElement("span");
|
||||
label.className = "wiki-tree-name";
|
||||
label.textContent = name;
|
||||
// Name wählt den Ordner aus (ohne zu klappen); der Pfeil davor klappt.
|
||||
label.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
select(full);
|
||||
});
|
||||
const rename = editAction(t("wiki.folderRename"), () => {
|
||||
const input = document.createElement("input");
|
||||
input.className = "wiki-tree-input";
|
||||
input.value = full;
|
||||
sum.replaceChildren(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
input.addEventListener("keydown", (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
cb.actions.moveFolder(full, input.value.trim());
|
||||
} else if (e.key === "Escape") {
|
||||
sum.replaceChildren(label, rename);
|
||||
}
|
||||
});
|
||||
input.addEventListener("click", (e) => e.preventDefault());
|
||||
});
|
||||
rename.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
sum.append(label, rename);
|
||||
det.append(sum, renderChildren(node, full));
|
||||
return det;
|
||||
}
|
||||
|
||||
function docRow(doc: DocEntry): HTMLElement {
|
||||
function renderChildren(node: TreeNode, path: string): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.className = "wiki-tree-children";
|
||||
for (const [name, child] of [...node.children].sort((a, b) =>
|
||||
a[0].localeCompare(b[0]),
|
||||
)) {
|
||||
box.append(folderRow(name, path ? `${path}/${name}` : name, child));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderTree(p: Page): HTMLElement {
|
||||
const aside = document.createElement("aside");
|
||||
aside.className = "wiki-tree";
|
||||
const head = document.createElement("div");
|
||||
head.className = "wiki-tree-head";
|
||||
const root = document.createElement("button");
|
||||
root.className = "wiki-tree-root" + (selected === "" ? " active" : "");
|
||||
root.textContent = t("wiki.archive");
|
||||
root.addEventListener("click", () => select(""));
|
||||
const add = document.createElement("button");
|
||||
add.className = "wiki-add";
|
||||
add.title = t("wiki.newFolder");
|
||||
add.textContent = "+";
|
||||
add.addEventListener("click", () =>
|
||||
openForm(t("wiki.newFolder"), t("wiki.folderPath"), selected ? `${selected}/` : "", (v) =>
|
||||
cb.actions.createFolder(v),
|
||||
),
|
||||
);
|
||||
head.append(root, add);
|
||||
aside.append(head, renderChildren(buildTree(p.folders), ""));
|
||||
return aside;
|
||||
}
|
||||
|
||||
// ---------- Anlege-Formular (Ordner/Dokument) ----------
|
||||
|
||||
/// Kleines Formular oben in der Liste: ein Textfeld, Anlegen/Abbrechen;
|
||||
/// Enter legt an, Escape schließt. Leere oder ungültige Eingaben meldet
|
||||
/// das Backend als Toast.
|
||||
function openForm(
|
||||
title: string,
|
||||
placeholder: string,
|
||||
initial: string,
|
||||
onSubmit: (value: string) => void,
|
||||
) {
|
||||
const main = container.querySelector<HTMLElement>(".wiki-main")!;
|
||||
main.querySelector(".wiki-form")?.remove();
|
||||
const form = document.createElement("div");
|
||||
form.className = "wiki-form";
|
||||
const caption = document.createElement("div");
|
||||
caption.className = "wiki-form-title";
|
||||
caption.textContent = title;
|
||||
const input = document.createElement("input");
|
||||
input.className = "wiki-tree-input";
|
||||
input.placeholder = placeholder;
|
||||
input.value = initial;
|
||||
const submit = document.createElement("button");
|
||||
submit.className = "wiki-form-submit";
|
||||
submit.textContent = t("wiki.create");
|
||||
const cancel = document.createElement("button");
|
||||
cancel.className = "wiki-form-cancel";
|
||||
cancel.textContent = t("wiki.cancel");
|
||||
const fire = () => {
|
||||
const value = input.value.trim();
|
||||
form.remove();
|
||||
onSubmit(value);
|
||||
};
|
||||
submit.addEventListener("click", fire);
|
||||
cancel.addEventListener("click", () => form.remove());
|
||||
form.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") fire();
|
||||
else if (e.key === "Escape") form.remove();
|
||||
});
|
||||
form.append(caption, input, submit, cancel);
|
||||
main.querySelector(".wiki-head")!.after(form);
|
||||
input.focus();
|
||||
}
|
||||
|
||||
// ---------- Liste rechts: direkter Ordnerinhalt ----------
|
||||
|
||||
function docRow(p: Page, doc: DocEntry): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "wiki-doc";
|
||||
const line = document.createElement("div");
|
||||
@@ -119,6 +232,29 @@ export function initWikiView(
|
||||
date.textContent = doc.date;
|
||||
line.append(date);
|
||||
}
|
||||
const acts = document.createElement("div");
|
||||
acts.className = "wiki-doc-actions";
|
||||
const rename = editAction(t("wiki.renameDoc"), () => {
|
||||
const input = document.createElement("input");
|
||||
input.className = "wiki-tree-input";
|
||||
input.value = doc.name;
|
||||
row.replaceChildren(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
cb.actions.rename(doc.relpath, input.value);
|
||||
} else if (e.key === "Escape" && current) {
|
||||
renderMain(current);
|
||||
}
|
||||
});
|
||||
input.addEventListener("click", (e) => e.stopPropagation());
|
||||
});
|
||||
acts.append(rename, deleteAction(t("wiki.deleteDoc"), () => cb.actions.remove(doc.relpath)));
|
||||
for (const btn of acts.querySelectorAll("button")) {
|
||||
btn.addEventListener("click", (e) => e.stopPropagation());
|
||||
}
|
||||
line.append(acts);
|
||||
row.append(line);
|
||||
if (doc.description) {
|
||||
const desc = document.createElement("div");
|
||||
@@ -126,95 +262,70 @@ export function initWikiView(
|
||||
desc.textContent = doc.description;
|
||||
row.append(desc);
|
||||
}
|
||||
if (doc.tags.length) {
|
||||
const tags = document.createElement("div");
|
||||
tags.className = "wiki-doc-tags";
|
||||
for (const t of doc.tags) tags.append(chip(`#${t}`, `tag:${t}`));
|
||||
row.append(tags);
|
||||
}
|
||||
row.addEventListener("click", () => onLink(doc.name));
|
||||
row.addEventListener("click", () => cb.openDoc(`${p.home}/${doc.relpath}`));
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderPage(p: Page) {
|
||||
container.append(pageHead(p));
|
||||
if (p.tags.length) container.append(tagBar(p));
|
||||
if (p.total === 0) {
|
||||
function renderMain(p: Page) {
|
||||
const main = container.querySelector<HTMLElement>(".wiki-main")!;
|
||||
main.textContent = "";
|
||||
const docs = p.folders.find((f) => f.name === selected)?.docs ?? [];
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "wiki-head";
|
||||
const left = document.createElement("div");
|
||||
left.className = "wiki-head-left";
|
||||
const title = document.createElement("div");
|
||||
title.className = "wiki-head-title";
|
||||
title.textContent = selected ? selected.split("/").pop()! : t("wiki.archive");
|
||||
const sub = document.createElement("div");
|
||||
sub.className = "wiki-head-sub";
|
||||
sub.textContent = selected ? `${p.home}/${selected}` : p.home;
|
||||
left.append(title, sub);
|
||||
const count = document.createElement("div");
|
||||
count.className = "wiki-head-right";
|
||||
count.textContent = t(docs.length === 1 ? "wiki.docOne" : "wiki.docMany", {
|
||||
count: docs.length,
|
||||
});
|
||||
const add = document.createElement("button");
|
||||
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),
|
||||
),
|
||||
);
|
||||
head.append(left, count, add);
|
||||
main.append(head);
|
||||
|
||||
if (docs.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "wiki-empty";
|
||||
const line = document.createElement("strong");
|
||||
line.textContent = p.tag ? t("wiki.emptyTag", { tag: p.tag }) : t("wiki.emptyArchive");
|
||||
line.textContent = p.total === 0 ? t("wiki.emptyArchive") : t("wiki.emptyFolder");
|
||||
empty.append(line);
|
||||
if (!p.tag) {
|
||||
empty.append(t("wiki.emptyHint"));
|
||||
}
|
||||
container.append(empty);
|
||||
if (p.total === 0) empty.append(t("wiki.emptyHint"));
|
||||
main.append(empty);
|
||||
return;
|
||||
}
|
||||
if (p.recent.length) {
|
||||
const eyebrow = document.createElement("div");
|
||||
eyebrow.className = "wiki-folder";
|
||||
eyebrow.textContent = t("wiki.recent");
|
||||
container.append(eyebrow);
|
||||
for (const doc of p.recent) container.append(docRow(doc));
|
||||
}
|
||||
for (const folder of p.folders) {
|
||||
if (folder.name) {
|
||||
const eyebrow = document.createElement("div");
|
||||
eyebrow.className = "wiki-folder";
|
||||
eyebrow.textContent = `${folder.name}/`;
|
||||
container.append(eyebrow);
|
||||
} else if (p.recent.length) {
|
||||
const eyebrow = document.createElement("div");
|
||||
eyebrow.className = "wiki-folder";
|
||||
eyebrow.textContent = t("wiki.root");
|
||||
container.append(eyebrow);
|
||||
}
|
||||
for (const doc of folder.docs) container.append(docRow(doc));
|
||||
}
|
||||
for (const doc of docs) main.append(docRow(p, doc));
|
||||
}
|
||||
|
||||
function renderDoc(d: DocPage) {
|
||||
const head = document.createElement("div");
|
||||
head.className = "wiki-doc-head";
|
||||
const back = document.createElement("button");
|
||||
back.className = "wiki-back";
|
||||
back.textContent = t("wiki.back");
|
||||
back.addEventListener("click", () => onLink("tag:"));
|
||||
const path = document.createElement("div");
|
||||
path.className = "wiki-head-sub";
|
||||
path.textContent = d.relpath;
|
||||
head.append(back, path);
|
||||
container.append(head);
|
||||
if (d.tags.length) {
|
||||
const tags = document.createElement("div");
|
||||
tags.className = "wiki-chips";
|
||||
for (const t of d.tags) tags.append(chip(`#${t}`, `tag:${t}`));
|
||||
container.append(tags);
|
||||
}
|
||||
const body = document.createElement("div");
|
||||
body.className = "wiki-body";
|
||||
body.innerHTML = renderMarkdown(d.markdown);
|
||||
linkWikiRefs(body, onLink);
|
||||
container.append(body);
|
||||
if (d.backlinks.length) {
|
||||
const back = document.createElement("div");
|
||||
back.className = "wiki-backlinks";
|
||||
back.append(t("wiki.backlinksLabel"));
|
||||
d.backlinks.forEach((name, i) => {
|
||||
if (i) back.append(" · ");
|
||||
const a = document.createElement("a");
|
||||
a.href = "#";
|
||||
a.className = "wiki";
|
||||
a.textContent = name;
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
onLink(name);
|
||||
});
|
||||
back.append(a);
|
||||
});
|
||||
container.append(back);
|
||||
}
|
||||
function select(folder: string) {
|
||||
selected = folder;
|
||||
if (current) render(current);
|
||||
}
|
||||
|
||||
function render(p: Page) {
|
||||
container.textContent = "";
|
||||
const layout = document.createElement("div");
|
||||
layout.className = "wiki-layout";
|
||||
const main = document.createElement("div");
|
||||
main.className = "wiki-main";
|
||||
layout.append(renderTree(p), main);
|
||||
container.append(layout);
|
||||
renderMain(p);
|
||||
}
|
||||
|
||||
/// Leerer Puffer: Einstieg statt leerer Fläche.
|
||||
@@ -224,7 +335,11 @@ export function initWikiView(
|
||||
const line = document.createElement("strong");
|
||||
line.textContent = t("wiki.noPage");
|
||||
empty.append(line);
|
||||
empty.append(chip(t("wiki.openOverview"), "tag:"));
|
||||
const open = document.createElement("button");
|
||||
open.className = "wiki-chip";
|
||||
open.textContent = t("wiki.openOverview");
|
||||
open.addEventListener("click", () => cb.openWiki("tag:"));
|
||||
empty.append(open);
|
||||
container.append(empty);
|
||||
}
|
||||
|
||||
@@ -234,12 +349,21 @@ export function initWikiView(
|
||||
container.textContent = "";
|
||||
loaded = !!text.trim();
|
||||
if (!loaded) {
|
||||
current = null;
|
||||
renderIntro();
|
||||
return;
|
||||
}
|
||||
const data: Page | DocPage = JSON.parse(text);
|
||||
if (data.kind === "doc") renderDoc(data);
|
||||
else renderPage(data);
|
||||
current = JSON.parse(text);
|
||||
// Der gewählte Ordner kann nach Umbenennen/Löschen weg sein — dann
|
||||
// zurück zur Wurzel. Zwischenordner (nur Unterordner, keine eigenen
|
||||
// Dokumente) bleiben wählbar.
|
||||
const exists = current!.folders.some(
|
||||
(f) => f.name === selected || f.name.startsWith(`${selected}/`),
|
||||
);
|
||||
if (selected && !exists) {
|
||||
selected = "";
|
||||
}
|
||||
render(current!);
|
||||
},
|
||||
empty: () => !loaded,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user