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",
|
"search_run",
|
||||||
"panel_load",
|
"panel_load",
|
||||||
"wiki_open",
|
"wiki_open",
|
||||||
|
"archive_move",
|
||||||
|
"archive_rename",
|
||||||
|
"archive_delete",
|
||||||
|
"archive_move_folder",
|
||||||
|
"archive_create_folder",
|
||||||
|
"archive_create_doc",
|
||||||
"open_panel_window",
|
"open_panel_window",
|
||||||
])),
|
])),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,12 @@
|
|||||||
"autostart:default",
|
"autostart:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"clipboard-manager:allow-write-text",
|
"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-buffer-read",
|
||||||
"allow-commands-delete",
|
"allow-commands-delete",
|
||||||
"allow-enabled-modules",
|
"allow-enabled-modules",
|
||||||
|
|||||||
@@ -20,6 +20,12 @@
|
|||||||
"autostart:default",
|
"autostart:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"clipboard-manager:allow-write-text",
|
"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-buffer-read",
|
||||||
"allow-commands-delete",
|
"allow-commands-delete",
|
||||||
"allow-enabled-modules",
|
"allow-enabled-modules",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-archive-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::search_run,
|
||||||
terminal::panel_load,
|
terminal::panel_load,
|
||||||
terminal::wiki_open,
|
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
|
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
|
/// YAML-Frontmatter des Archiv-Dokuments inklusive optionaler
|
||||||
/// description/tags aus den Metadaten. Gegenstück: `parse_frontmatter` unten —
|
/// description/tags aus den Metadaten. Gegenstück: `parse_frontmatter` unten —
|
||||||
/// Schreiber und Leser des Formats leben bewusst im selben Modul.
|
/// 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!(
|
let mut fm = format!(
|
||||||
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
|
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
|
||||||
title.replace('"', "'"),
|
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`).
|
/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`).
|
||||||
fn utc_stamp(secs: u64) -> (String, String) {
|
fn utc_stamp(secs: u64) -> (String, String) {
|
||||||
let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60);
|
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::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::domain::archive::{
|
use crate::domain::archive::{parse_frontmatter, parse_tag_list, slugify, strip_stamp};
|
||||||
parse_frontmatter, parse_tag_list, slugify, strip_frontmatter, strip_stamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, Clone)]
|
#[derive(serde::Serialize, Clone)]
|
||||||
pub(crate) struct Doc {
|
pub(crate) struct Doc {
|
||||||
@@ -22,6 +20,9 @@ pub(crate) struct Doc {
|
|||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
pub(crate) description: Option<String>,
|
pub(crate) description: Option<String>,
|
||||||
pub(crate) tags: Vec<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).
|
/// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel).
|
||||||
pub(crate) links: Vec<String>,
|
pub(crate) links: Vec<String>,
|
||||||
/// Namen der Dokumente, die per Wikilink hierher zeigen.
|
/// 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(),
|
title: fm.get("title").unwrap_or(&name).clone(),
|
||||||
description: fm.get("description").cloned(),
|
description: fm.get("description").cloned(),
|
||||||
tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(),
|
tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(),
|
||||||
|
created: fm.get("created").cloned(),
|
||||||
links: wikilinks(&text),
|
links: wikilinks(&text),
|
||||||
backlinks: Vec::new(),
|
backlinks: Vec::new(),
|
||||||
name,
|
name,
|
||||||
@@ -134,9 +136,6 @@ pub(crate) struct WikiPage {
|
|||||||
pub(crate) tag: Option<String>,
|
pub(crate) tag: Option<String>,
|
||||||
pub(crate) total: usize,
|
pub(crate) total: usize,
|
||||||
pub(crate) tags: Vec<TagCount>,
|
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>,
|
pub(crate) folders: Vec<WikiFolder>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +154,9 @@ pub(crate) struct WikiFolder {
|
|||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub(crate) struct WikiDocEntry {
|
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) name: String,
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
pub(crate) description: Option<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>> =
|
let mut folders: std::collections::BTreeMap<String, Vec<&Doc>> =
|
||||||
std::collections::BTreeMap::new();
|
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 {
|
for doc in &selected {
|
||||||
let folder = Path::new(&doc.relpath)
|
let folder = Path::new(&doc.relpath)
|
||||||
.parent()
|
.parent()
|
||||||
@@ -186,17 +198,6 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
folders.entry(folder).or_default().push(doc);
|
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 {
|
Ok(WikiPage {
|
||||||
kind: "page",
|
kind: "page",
|
||||||
home: home.display().to_string(),
|
home: home.display().to_string(),
|
||||||
@@ -206,7 +207,6 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(name, count)| TagCount { name: name.to_string(), count })
|
.map(|(name, count)| TagCount { name: name.to_string(), count })
|
||||||
.collect(),
|
.collect(),
|
||||||
recent,
|
|
||||||
folders: folders
|
folders: folders
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(name, mut list)| {
|
.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 {
|
fn doc_entry(doc: &Doc) -> WikiDocEntry {
|
||||||
let stem = Path::new(&doc.relpath)
|
let stem = Path::new(&doc.relpath)
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.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 {
|
WikiDocEntry {
|
||||||
|
relpath: doc.relpath.clone(),
|
||||||
name: doc.name.clone(),
|
name: doc.name.clone(),
|
||||||
title: doc.title.clone(),
|
title: doc.title.clone(),
|
||||||
description: doc.description.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
|
/// Löst ein Wikilink-Ziel (Name, Titel oder Datei-Stem) gegen das Archiv auf
|
||||||
/// Metadaten und Backlinks aus dem Scan.
|
/// und liefert den relpath des Dokuments.
|
||||||
#[derive(serde::Serialize)]
|
pub(crate) fn resolve_doc(home: &Path, target: &str) -> Result<String, String> {
|
||||||
pub(crate) struct WikiDocPage {
|
let docs = scan_archive(home)?;
|
||||||
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)?;
|
|
||||||
let want = slugify(target);
|
let want = slugify(target);
|
||||||
let (doc, body) = pairs
|
docs
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(d, _)| matches(d, &want))
|
.find(|d| matches(d, &want))
|
||||||
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))?;
|
.map(|d| d.relpath.clone())
|
||||||
Ok(WikiDocPage {
|
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alle `[[ziel]]`-Vorkommen im Text, in Dokumentreihenfolge; bei
|
/// Alle `[[ziel]]`-Vorkommen im Text, in Dokumentreihenfolge; bei
|
||||||
@@ -358,14 +364,38 @@ mod tests {
|
|||||||
assert_eq!(adr.title, "ADR Logging");
|
assert_eq!(adr.title, "ADR Logging");
|
||||||
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
|
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
|
||||||
assert_eq!(adr.date.as_deref(), Some("2026-07-19"));
|
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].name, "konzepte");
|
||||||
assert_eq!(page.folders[1].docs[0].name, "notiz-deploy");
|
assert_eq!(page.folders[1].docs[0].name, "notiz-deploy");
|
||||||
// Zwei Ordner → Zuletzt-Sektion, neuestes Dokument zuerst, mit
|
assert_eq!(page.folders[1].docs[0].backlinks, 1);
|
||||||
// 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"]);
|
#[test]
|
||||||
assert_eq!(page.recent[0].backlinks, 1);
|
fn leere_ordner_und_created_datum() {
|
||||||
assert_eq!(page.recent[1].backlinks, 0);
|
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]
|
#[test]
|
||||||
@@ -381,20 +411,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wiki_doc_mit_rumpf_und_backlinks() {
|
fn resolve_ueber_name_titel_und_stem() {
|
||||||
let home = archiv();
|
let home = archiv();
|
||||||
// Auflösung über Titel; Name und Stem gehen über dieselben Slug-Vergleiche.
|
// 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!(
|
assert_eq!(
|
||||||
wiki_doc(&home, "2026-07-19_1000-adr-logging").unwrap().name,
|
resolve_doc(&home, "Notiz Deploy").unwrap(),
|
||||||
"adr-logging"
|
"konzepte/2026-07-19_1005-notiz-deploy.md"
|
||||||
);
|
);
|
||||||
assert_eq!(doc.title, "Notiz Deploy");
|
assert_eq!(
|
||||||
assert_eq!(doc.relpath, "konzepte/2026-07-19_1005-notiz-deploy.md");
|
resolve_doc(&home, "2026-07-19_1000-adr-logging").unwrap(),
|
||||||
assert_eq!(doc.backlinks, vec!["adr-logging"]);
|
"2026-07-19_1000-adr-logging.md"
|
||||||
assert_eq!(doc.markdown, "Text ohne Links.\n");
|
);
|
||||||
assert!(wiki_doc(&home, "fehlt").is_err());
|
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;
|
||||||
pub(crate) mod archive_index;
|
pub(crate) mod archive_index;
|
||||||
|
pub(crate) mod archive_ops;
|
||||||
pub(crate) mod archive_search;
|
pub(crate) mod archive_search;
|
||||||
pub(crate) mod credentials;
|
pub(crate) mod credentials;
|
||||||
pub(crate) mod modules;
|
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)))
|
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
|
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
|
||||||
/// Session-Start geleert). Der Pfad landet als AI_CONTROL_COMMANDS in der
|
/// Session-Start geleert). Der Pfad landet als AI_CONTROL_COMMANDS in der
|
||||||
/// PTY-Umgebung.
|
/// 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).")))
|
.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> {
|
fn write_panel_text(text: &str) -> Result<(), Value> {
|
||||||
let path = env_path("AI_CONTROL_PANEL")?;
|
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 {
|
fn call_write(req: &Value) -> Value {
|
||||||
|
|||||||
+171
-22
@@ -5,7 +5,9 @@ use std::io::{Read, Write};
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
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::{
|
use crate::domain::project::{
|
||||||
project_config, project_pool_dir, verify_project_dir_in, ProjectConfig,
|
project_config, project_pool_dir, verify_project_dir_in, ProjectConfig,
|
||||||
};
|
};
|
||||||
@@ -145,6 +147,13 @@ pub fn term_start(
|
|||||||
let _ = std::fs::write(path, "");
|
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));
|
let mut cmd = crate::platform::shell_command(&claude_command(&paths));
|
||||||
cmd.cwd(&cwd);
|
cmd.cwd(&cwd);
|
||||||
@@ -419,21 +428,76 @@ pub fn todos_update(
|
|||||||
std::fs::write(&path, out).map_err(|e| e.to_string())
|
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
|
/// 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]
|
#[tauri::command]
|
||||||
pub fn panel_set(project: String, text: String) -> Result<(), String> {
|
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
|
/// Lädt ein Archiv-Dokument in den Dokument-Puffer (Treffer-Klick in der
|
||||||
/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf. Der Watcher
|
/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf — und setzt die
|
||||||
/// meldet den neuen Inhalt als `panel-update`.
|
/// Quell-Verknüpfung fürs Zurückschreiben. Der Watcher meldet den neuen
|
||||||
|
/// Inhalt als `panel-update`.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn panel_load(project: String, path: String) -> Result<(), String> {
|
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 text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
||||||
let body = crate::domain::archive::strip_frontmatter(&text);
|
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
|
/// 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())
|
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):
|
/// Öffnet ein Wiki-Ziel: `tag:x` → Schlagwort-Seite, `tag:` →
|
||||||
/// `tag:x` → Schlagwort-Seite, `tag:` → Archiv-Übersicht, sonst
|
/// Archiv-Übersicht in den Wiki-Puffer (`wiki-update`); ein Dokumentname
|
||||||
/// Dokument-Auflösung über den Index. Der `tag:`-Namensraum ist damit dort
|
/// (Wikilink, Backlink) wird über den Index aufgelöst und wie ein
|
||||||
/// interpretiert, wo archive_page ihn erzeugt — nicht im Frontend. Schreibt
|
/// Suchtreffer in den Dokument-Puffer geladen (`panel-update`).
|
||||||
/// den Wiki-Puffer; der Watcher meldet ihn als `wiki-update`.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn wiki_open(project: String, name: String) -> Result<(), String> {
|
pub fn wiki_open(project: String, name: String) -> Result<(), String> {
|
||||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||||
let json = match name.strip_prefix("tag:") {
|
match name.strip_prefix("tag:") {
|
||||||
Some(tag) => serde_json::to_string(&crate::domain::archive_index::archive_page(
|
Some(tag) => {
|
||||||
|
let json = serde_json::to_string(&crate::domain::archive_index::archive_page(
|
||||||
&home,
|
&home,
|
||||||
(!tag.is_empty()).then_some(tag),
|
(!tag.is_empty()).then_some(tag),
|
||||||
)?),
|
)?)
|
||||||
None => serde_json::to_string(&crate::domain::archive_index::wiki_doc(&home, &name)?),
|
|
||||||
}
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
std::fs::write(wiki_file(&project), json).map_err(|e| e.to_string())
|
std::fs::write(wiki_file(&project), json).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// 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
|
/// Async, weil Fenster-Erzeugung aus einem synchronen Command auf dem
|
||||||
/// GTK-Mainloop klemmen kann (Tauri-Vorgabe für window create in Commands).
|
/// GTK-Mainloop klemmen kann (Tauri-Vorgabe für window create in Commands).
|
||||||
#[tauri::command]
|
#[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}");
|
let label = format!("panel-{project}");
|
||||||
if let Some(w) = app.get_webview_window(&label) {
|
if let Some(w) = app.get_webview_window(&label) {
|
||||||
let _ = w.set_focus();
|
let _ = w.set_focus();
|
||||||
@@ -490,11 +637,13 @@ pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), St
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.or(cfg.name.as_deref())
|
.or(cfg.name.as_deref())
|
||||||
.unwrap_or(project.as_str());
|
.unwrap_or(project.as_str());
|
||||||
let builder = WebviewWindowBuilder::new(
|
// Der beim Ablösen aktive Tab geht als URL-Parameter mit — das neue
|
||||||
&app,
|
// Fenster startet dort, statt in seiner Default-Ansicht.
|
||||||
&label,
|
let url = match &mode {
|
||||||
WebviewUrl::App(format!("panel.html?project={project}").into()),
|
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"))
|
.title(format!("{title} — Dokument"))
|
||||||
.inner_size(480.0, 640.0)
|
.inner_size(480.0, 640.0)
|
||||||
.background_color(tauri::window::Color(r, g, b, 0xff));
|
.background_color(tauri::window::Color(r, g, b, 0xff));
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe("t()", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("lässt unbekannte Platzhalter stehen", () => {
|
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", () => {
|
it("gibt bei fehlendem Schlüssel den Pfad zurück", () => {
|
||||||
|
|||||||
+20
-12
@@ -235,18 +235,22 @@ const de = {
|
|||||||
archive: "Archiv",
|
archive: "Archiv",
|
||||||
docOne: "{count} Dokument",
|
docOne: "{count} Dokument",
|
||||||
docMany: "{count} Dokumente",
|
docMany: "{count} Dokumente",
|
||||||
all: "Alle",
|
|
||||||
backlinks: "Verweise hierher",
|
backlinks: "Verweise hierher",
|
||||||
backlinksLabel: "Verweise hierher: ",
|
|
||||||
emptyTag: "Keine Dokumente mit #{tag}.",
|
|
||||||
emptyArchive: "Das Archiv ist leer.",
|
emptyArchive: "Das Archiv ist leer.",
|
||||||
|
emptyFolder: "Keine Dokumente in diesem Ordner.",
|
||||||
emptyHint:
|
emptyHint:
|
||||||
"Archivieren: Archiv-Button im Entwurf oder „archiviere das“ im Chat — mit Ordner, Beschreibung und Schlagwörtern.",
|
"Archivieren: Archiv-Button im Entwurf oder „archiviere das“ im Chat — mit Ordner, Beschreibung und Schlagwörtern.",
|
||||||
recent: "Zuletzt",
|
|
||||||
root: "Wurzel",
|
|
||||||
back: "‹ Archiv",
|
|
||||||
noPage: "Keine Wiki-Seite geladen.",
|
noPage: "Keine Wiki-Seite geladen.",
|
||||||
openOverview: "Archiv-Übersicht öffnen",
|
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",
|
archive: "Archive",
|
||||||
docOne: "{count} document",
|
docOne: "{count} document",
|
||||||
docMany: "{count} documents",
|
docMany: "{count} documents",
|
||||||
all: "All",
|
|
||||||
backlinks: "Links here",
|
backlinks: "Links here",
|
||||||
backlinksLabel: "Links here: ",
|
|
||||||
emptyTag: "No documents tagged #{tag}.",
|
|
||||||
emptyArchive: "The archive is empty.",
|
emptyArchive: "The archive is empty.",
|
||||||
|
emptyFolder: "No documents in this folder.",
|
||||||
emptyHint:
|
emptyHint:
|
||||||
"To archive: the archive button in the draft, or “archive this” in the chat — with folder, description and tags.",
|
"To archive: the archive button in the draft, or “archive this” in the chat — with folder, description and tags.",
|
||||||
recent: "Recent",
|
|
||||||
root: "Root",
|
|
||||||
back: "‹ Archive",
|
|
||||||
noPage: "No wiki page loaded.",
|
noPage: "No wiki page loaded.",
|
||||||
openOverview: "Open archive overview",
|
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",
|
buffer: "wiki",
|
||||||
labelKey: "panel.tabWiki",
|
labelKey: "panel.tabWiki",
|
||||||
titleKey: "panel.tabWikiTitle",
|
titleKey: "panel.tabWikiTitle",
|
||||||
init: (container, ctx) => initWikiView(container, ctx.openWiki),
|
// Dokument-/Ordner-Operationen laufen als Commands; der neue Stand kommt
|
||||||
// Wiki-Tab bei leerem Puffer (Session-Start): Übersicht direkt laden.
|
// über den Wiki-Puffer (wiki-update) zurück, Fehler als Toast. Der
|
||||||
onActivate: (view, ctx) => {
|
// Dokument-Sprung lädt wie ein Suchtreffer in den Dokument-Tab.
|
||||||
if (view.empty()) ctx.openWiki("tag:");
|
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 = {
|
export const searchTab: PanelTab = {
|
||||||
|
|||||||
+185
-126
@@ -311,7 +311,7 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wiki-Ansicht: Übersichts-/Schlagwort-Seiten und Dokumente. */
|
/* Wiki-Ansicht: Ordner-Baum links, flache Inhaltsliste rechts. */
|
||||||
#wiki-content {
|
#wiki-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -325,6 +325,189 @@
|
|||||||
background: var(--line);
|
background: var(--line);
|
||||||
border-radius: 4px;
|
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 {
|
.wiki-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -358,12 +541,7 @@
|
|||||||
sans-serif;
|
sans-serif;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.wiki-chips {
|
/* Einstiegs-Knopf im Leerzustand. */
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
margin: 10px 0 4px;
|
|
||||||
}
|
|
||||||
.wiki-chip {
|
.wiki-chip {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -379,25 +557,6 @@
|
|||||||
background: var(--line);
|
background: var(--line);
|
||||||
color: var(--text);
|
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 {
|
.wiki-doc {
|
||||||
padding: 7px 8px;
|
padding: 7px 8px;
|
||||||
margin: 4px -8px 0;
|
margin: 4px -8px 0;
|
||||||
@@ -434,12 +593,6 @@
|
|||||||
system-ui,
|
system-ui,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
}
|
}
|
||||||
.wiki-doc-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
.wiki-empty {
|
.wiki-empty {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -454,100 +607,6 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 600;
|
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. */
|
/* Backlink-Zähler in der Dokumentzeile. */
|
||||||
.wiki-doc-back {
|
.wiki-doc-back {
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
|
|||||||
+3
-2
@@ -123,10 +123,11 @@ export async function wirePanel(
|
|||||||
flush: () => void view.flush(),
|
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
|
document
|
||||||
.getElementById("panel-wiki-jump")!
|
.getElementById("panel-wiki-jump")!
|
||||||
.addEventListener("click", () => openWiki(titleEl.textContent || ""));
|
.addEventListener("click", () => openWiki("tag:"));
|
||||||
|
|
||||||
view.set(draft);
|
view.set(draft);
|
||||||
await listen<string>("panel-update", (e) => {
|
await listen<string>("panel-update", (e) => {
|
||||||
|
|||||||
+7
-3
@@ -100,11 +100,15 @@ document.documentElement.style.background = picked.header;
|
|||||||
document.body.style.background = picked.header;
|
document.body.style.background = picked.header;
|
||||||
|
|
||||||
const { view, views, mode, draft } = await wirePanel(project);
|
const { view, views, mode, draft } = await wirePanel(project);
|
||||||
// Ohne Entwurf startet das Fenster in der ToDo-Liste (falls gewählt und
|
// Beim Ablösen kommt der dort aktive Tab als URL-Parameter mit; ohne ihn
|
||||||
// nicht leer), sonst in den Befehlen.
|
// (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 todos = views.get("todo");
|
||||||
const cmds = views.get("commands");
|
const cmds = views.get("commands");
|
||||||
if (!draft.trim()) {
|
if (initialMode) {
|
||||||
|
mode.to(initialMode);
|
||||||
|
} else if (!draft.trim()) {
|
||||||
if (todos && !todos.empty()) mode.to("todo");
|
if (todos && !todos.empty()) mode.to("todo");
|
||||||
else if (cmds && !cmds.empty()) mode.to("commands");
|
else if (cmds && !cmds.empty()) mode.to("commands");
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -254,9 +254,12 @@ await listen("panel-window-closed", () => {
|
|||||||
headerTabs.hidden = false;
|
headerTabs.hidden = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Der aktive Tab wandert mit ins abgelöste Fenster.
|
||||||
document
|
document
|
||||||
.getElementById("panel-detach")!
|
.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", () => {
|
document.getElementById("panel-hide")!.addEventListener("click", () => {
|
||||||
hidePanel();
|
hidePanel();
|
||||||
mode.clear();
|
mode.clear();
|
||||||
|
|||||||
+181
-82
@@ -1,15 +1,29 @@
|
|||||||
// Wiki-Ansicht: Übersichts-/Schlagwort-Seiten, Dokumentseite, Leerzustände.
|
// Wiki-Ansicht: Ordner-Baum links, flache Inhaltsliste rechts, Zeilen-Aktionen
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
// (öffnen, umbenennen, löschen), Ordner-Umbenennen, Leerzustände.
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { initWikiView } from "./wiki-view";
|
import { initWikiView } from "./wiki-view";
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
document.body.innerHTML = `<div id="w"></div>`;
|
document.body.innerHTML = `<div id="w"></div>`;
|
||||||
const onLink = vi.fn();
|
const openDoc = vi.fn();
|
||||||
const view = initWikiView(document.getElementById("w")!, onLink);
|
const openWiki = vi.fn();
|
||||||
return { view, onLink };
|
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,
|
name,
|
||||||
title: name.toUpperCase(),
|
title: name.toUpperCase(),
|
||||||
description: null,
|
description: null,
|
||||||
@@ -24,67 +38,185 @@ const page = JSON.stringify({
|
|||||||
home: "/tmp/archiv",
|
home: "/tmp/archiv",
|
||||||
tag: null,
|
tag: null,
|
||||||
total: 3,
|
total: 3,
|
||||||
tags: [
|
tags: [],
|
||||||
{ name: "adr", count: 2 },
|
|
||||||
{ name: "infra", count: 1 },
|
|
||||||
],
|
|
||||||
recent: [doc("neu")],
|
|
||||||
folders: [
|
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", () => {
|
describe("initWikiView — Baum und Liste", () => {
|
||||||
it("rendert Kopf, Chips, Zuletzt- und Ordner-Sektionen", () => {
|
it("Baum zeigt nur Ordner, Wurzel ist gewählt und listet ihren Inhalt", () => {
|
||||||
const { view } = setup();
|
const { view } = setup();
|
||||||
view.set(page);
|
view.set(page);
|
||||||
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("Archiv");
|
// Baum: Wurzel-Knopf + Ordner (keine Dokumentzeilen im Baum).
|
||||||
expect(document.querySelector(".wiki-head-right")!.textContent).toBe("3 Dokumente");
|
expect(document.querySelector(".wiki-tree-root")!.className).toContain("active");
|
||||||
const chips = [...document.querySelectorAll(".wiki-chips .wiki-chip")].map(
|
const folders = [...document.querySelectorAll(".wiki-tree .wiki-tree-name")].map(
|
||||||
(c) => c.textContent,
|
|
||||||
);
|
|
||||||
expect(chips).toEqual(["Alle", "#adr 2", "#infra 1"]);
|
|
||||||
const eyebrows = [...document.querySelectorAll(".wiki-folder")].map(
|
|
||||||
(e) => e.textContent,
|
(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-back")!.textContent).toBe("↩ 2");
|
||||||
|
expect(document.querySelector(".wiki-doc-desc")!.textContent).toBe("Beschreibung");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Dokumentzeile öffnet das Dokument, Tag-Chip die Tag-Seite", () => {
|
it("Ordner-Klick wählt aus und zeigt dessen flache Liste", () => {
|
||||||
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", () => {
|
|
||||||
const { view } = setup();
|
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(
|
view.set(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
kind: "page",
|
kind: "page",
|
||||||
home: "/a",
|
home: "/tmp/archiv",
|
||||||
tag: "adr",
|
tag: null,
|
||||||
total: 0,
|
total: 1,
|
||||||
tags: [{ name: "adr", count: 1 }],
|
tags: [],
|
||||||
recent: [],
|
folders: [{ name: "", docs: [doc("2026-07-19_1000-wurzel-doc.md", "wurzel-doc")] }],
|
||||||
folders: [],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("#adr");
|
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("Archiv");
|
||||||
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.",
|
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", () => {
|
it("leeres Archiv erklärt das Archivieren", () => {
|
||||||
const { view } = setup();
|
const { view } = setup();
|
||||||
view.set(
|
view.set(
|
||||||
@@ -94,7 +226,6 @@ describe("initWikiView — Seiten", () => {
|
|||||||
tag: null,
|
tag: null,
|
||||||
total: 0,
|
total: 0,
|
||||||
tags: [],
|
tags: [],
|
||||||
recent: [],
|
|
||||||
folders: [],
|
folders: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -102,45 +233,13 @@ describe("initWikiView — Seiten", () => {
|
|||||||
"Das Archiv ist leer.",
|
"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", () => {
|
it("leerer Puffer bietet den Übersichts-Einstieg", () => {
|
||||||
const { view, onLink } = setup();
|
const { view, openWiki } = setup();
|
||||||
view.set("");
|
view.set("");
|
||||||
expect(view.empty()).toBe(true);
|
expect(view.empty()).toBe(true);
|
||||||
document.querySelector<HTMLElement>(".wiki-empty .wiki-chip")!.click();
|
document.querySelector<HTMLElement>(".wiki-empty .wiki-chip")!.click();
|
||||||
expect(onLink).toHaveBeenCalledWith("tag:");
|
expect(openWiki).toHaveBeenCalledWith("tag:");
|
||||||
view.set(page);
|
view.set(page);
|
||||||
expect(view.empty()).toBe(false);
|
expect(view.empty()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
+263
-139
@@ -1,15 +1,16 @@
|
|||||||
/// Wiki-Ansicht des Panels: rendert den Wiki-Puffer (show_archive/wiki_open).
|
/// Wiki-Ansicht des Panels: Ordner-Navigator übers Archiv. Links der Baum —
|
||||||
/// Übersichts- und Schlagwort-Seiten kommen als strukturierte Daten (Kopfzeile,
|
/// nur Ordner, oberster Knoten ist die Archiv-Wurzel; Klick auf einen Ordner
|
||||||
/// Schlagwort-Chips, Ordner-Sektionen mit Dokumentzeilen), Dokumente als
|
/// zeigt rechts die flache Liste seines direkten Inhalts. Klick auf eine
|
||||||
/// gerendertes Markdown mit Backlinks. Titel, Beschreibungen und Pfade sind
|
/// Dokumentzeile lädt das Dokument in den Dokument-Tab (wie ein
|
||||||
/// Fremdtext und gehen nie durch innerHTML; nur der Markdown-Rumpf läuft wie
|
/// Suchtreffer); an der Zeile: umbenennen (inline) und löschen, am
|
||||||
/// im Entwurf durch marked.
|
/// 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 { t } from "./messages";
|
||||||
|
import { deleteAction, editAction } from "./tiles";
|
||||||
|
|
||||||
interface DocEntry {
|
interface DocEntry {
|
||||||
|
relpath: string;
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
@@ -29,19 +30,25 @@ interface Page {
|
|||||||
tag?: string | null;
|
tag?: string | null;
|
||||||
total: number;
|
total: number;
|
||||||
tags: { name: string; count: number }[];
|
tags: { name: string; count: number }[];
|
||||||
recent: DocEntry[];
|
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DocPage {
|
/// Dokument-/Ordner-Operationen — laufen als Tauri-Commands, der neue Stand
|
||||||
kind: "doc";
|
/// kommt über den Wiki-Puffer zurück.
|
||||||
home: string;
|
export interface WikiActions {
|
||||||
relpath: string;
|
rename(relpath: string, name: string): void;
|
||||||
name: string;
|
remove(relpath: string): void;
|
||||||
title: string;
|
moveFolder(folder: string, to: string): void;
|
||||||
tags: string[];
|
createFolder(folder: string): void;
|
||||||
backlinks: string[];
|
createDoc(folder: string, name: string): void;
|
||||||
markdown: string;
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface WikiView {
|
||||||
@@ -50,54 +57,160 @@ export interface WikiView {
|
|||||||
empty(): boolean;
|
empty(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initWikiView(
|
/// Verschachtelter Ordner-Knoten, aus den Ordnerpfaden gebaut.
|
||||||
container: HTMLElement,
|
interface TreeNode {
|
||||||
onLink: (name: string) => void,
|
children: Map<string, TreeNode>;
|
||||||
): WikiView {
|
}
|
||||||
function chip(label: string, target: string, active = false): HTMLElement {
|
|
||||||
const b = document.createElement("button");
|
function buildTree(folders: Folder[]): TreeNode {
|
||||||
b.className = "wiki-chip" + (active ? " active" : "");
|
const root: TreeNode = { children: new Map() };
|
||||||
b.textContent = label;
|
for (const folder of folders) {
|
||||||
b.addEventListener("click", (e) => {
|
if (!folder.name) continue;
|
||||||
e.stopPropagation();
|
let node = root;
|
||||||
onLink(target);
|
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 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);
|
||||||
});
|
});
|
||||||
return b;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kopfzeile einer Seite: Titel + Home-Pfad links, Dokumentzahl rechts.
|
function renderChildren(node: TreeNode, path: string): HTMLElement {
|
||||||
function pageHead(p: Page): 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");
|
const head = document.createElement("div");
|
||||||
head.className = "wiki-head";
|
head.className = "wiki-tree-head";
|
||||||
const left = document.createElement("div");
|
const root = document.createElement("button");
|
||||||
left.className = "wiki-head-left";
|
root.className = "wiki-tree-root" + (selected === "" ? " active" : "");
|
||||||
const title = document.createElement("div");
|
root.textContent = t("wiki.archive");
|
||||||
title.className = "wiki-head-title";
|
root.addEventListener("click", () => select(""));
|
||||||
title.textContent = p.tag ? `#${p.tag}` : t("wiki.archive");
|
const add = document.createElement("button");
|
||||||
const sub = document.createElement("div");
|
add.className = "wiki-add";
|
||||||
sub.className = "wiki-head-sub";
|
add.title = t("wiki.newFolder");
|
||||||
sub.textContent = p.home;
|
add.textContent = "+";
|
||||||
left.append(title, sub);
|
add.addEventListener("click", () =>
|
||||||
const count = document.createElement("div");
|
openForm(t("wiki.newFolder"), t("wiki.folderPath"), selected ? `${selected}/` : "", (v) =>
|
||||||
count.className = "wiki-head-right";
|
cb.actions.createFolder(v),
|
||||||
count.textContent = t(p.total === 1 ? "wiki.docOne" : "wiki.docMany", { count: p.total });
|
),
|
||||||
head.append(left, count);
|
);
|
||||||
return head;
|
head.append(root, add);
|
||||||
|
aside.append(head, renderChildren(buildTree(p.folders), ""));
|
||||||
|
return aside;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schlagwort-Leiste: „Alle“ plus ein Chip pro Schlagwort mit Zähler; der
|
// ---------- Anlege-Formular (Ordner/Dokument) ----------
|
||||||
/// aktive Filter ist markiert.
|
|
||||||
function tagBar(p: Page): HTMLElement {
|
/// Kleines Formular oben in der Liste: ein Textfeld, Anlegen/Abbrechen;
|
||||||
const bar = document.createElement("div");
|
/// Enter legt an, Escape schließt. Leere oder ungültige Eingaben meldet
|
||||||
bar.className = "wiki-chips";
|
/// das Backend als Toast.
|
||||||
bar.append(chip(t("wiki.all"), "tag:", !p.tag));
|
function openForm(
|
||||||
for (const t of p.tags) {
|
title: string,
|
||||||
bar.append(chip(`#${t.name} ${t.count}`, `tag:${t.name}`, p.tag === t.name));
|
placeholder: string,
|
||||||
}
|
initial: string,
|
||||||
return bar;
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
function docRow(doc: DocEntry): HTMLElement {
|
// ---------- Liste rechts: direkter Ordnerinhalt ----------
|
||||||
|
|
||||||
|
function docRow(p: Page, doc: DocEntry): HTMLElement {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "wiki-doc";
|
row.className = "wiki-doc";
|
||||||
const line = document.createElement("div");
|
const line = document.createElement("div");
|
||||||
@@ -119,6 +232,29 @@ export function initWikiView(
|
|||||||
date.textContent = doc.date;
|
date.textContent = doc.date;
|
||||||
line.append(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);
|
row.append(line);
|
||||||
if (doc.description) {
|
if (doc.description) {
|
||||||
const desc = document.createElement("div");
|
const desc = document.createElement("div");
|
||||||
@@ -126,95 +262,70 @@ export function initWikiView(
|
|||||||
desc.textContent = doc.description;
|
desc.textContent = doc.description;
|
||||||
row.append(desc);
|
row.append(desc);
|
||||||
}
|
}
|
||||||
if (doc.tags.length) {
|
row.addEventListener("click", () => cb.openDoc(`${p.home}/${doc.relpath}`));
|
||||||
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));
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage(p: Page) {
|
function renderMain(p: Page) {
|
||||||
container.append(pageHead(p));
|
const main = container.querySelector<HTMLElement>(".wiki-main")!;
|
||||||
if (p.tags.length) container.append(tagBar(p));
|
main.textContent = "";
|
||||||
if (p.total === 0) {
|
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");
|
const empty = document.createElement("div");
|
||||||
empty.className = "wiki-empty";
|
empty.className = "wiki-empty";
|
||||||
const line = document.createElement("strong");
|
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);
|
empty.append(line);
|
||||||
if (!p.tag) {
|
if (p.total === 0) empty.append(t("wiki.emptyHint"));
|
||||||
empty.append(t("wiki.emptyHint"));
|
main.append(empty);
|
||||||
}
|
|
||||||
container.append(empty);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (p.recent.length) {
|
for (const doc of docs) main.append(docRow(p, doc));
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDoc(d: DocPage) {
|
function select(folder: string) {
|
||||||
const head = document.createElement("div");
|
selected = folder;
|
||||||
head.className = "wiki-doc-head";
|
if (current) render(current);
|
||||||
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 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.
|
/// Leerer Puffer: Einstieg statt leerer Fläche.
|
||||||
@@ -224,7 +335,11 @@ export function initWikiView(
|
|||||||
const line = document.createElement("strong");
|
const line = document.createElement("strong");
|
||||||
line.textContent = t("wiki.noPage");
|
line.textContent = t("wiki.noPage");
|
||||||
empty.append(line);
|
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);
|
container.append(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,12 +349,21 @@ export function initWikiView(
|
|||||||
container.textContent = "";
|
container.textContent = "";
|
||||||
loaded = !!text.trim();
|
loaded = !!text.trim();
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
|
current = null;
|
||||||
renderIntro();
|
renderIntro();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data: Page | DocPage = JSON.parse(text);
|
current = JSON.parse(text);
|
||||||
if (data.kind === "doc") renderDoc(data);
|
// Der gewählte Ordner kann nach Umbenennen/Löschen weg sein — dann
|
||||||
else renderPage(data);
|
// 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,
|
empty: () => !loaded,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user