diff --git a/package-lock.json b/package-lock.json index ef1cd44..54bd2a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-control", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-control", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@fontsource/jetbrains-mono": "^5.2.8", @@ -20,6 +20,15 @@ "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "marked": "^16.4.2", + "prosemirror-commands": "^1.7.1", + "prosemirror-history": "^1.5.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-basic": "^1.2.4", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-view": "^1.42.2", "vue": "^3.5.39", "vue-i18n": "^11.4.6" }, @@ -1692,6 +1701,12 @@ "ot": "bin/ot" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1762,6 +1777,112 @@ "node": ">8.0.0" } }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz", + "integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/rolldown": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", @@ -1796,6 +1917,12 @@ "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2139,6 +2266,12 @@ "typescript": ">=5.0.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", diff --git a/package.json b/package.json index f9a8036..76da09a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,15 @@ "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "marked": "^16.4.2", + "prosemirror-commands": "^1.7.1", + "prosemirror-history": "^1.5.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-basic": "^1.2.4", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-view": "^1.42.2", "vue": "^3.5.39", "vue-i18n": "^11.4.6" }, diff --git a/src-tauri/build.rs b/src-tauri/build.rs index fb3bf9b..6d4ca5e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -74,6 +74,7 @@ fn main() { "archive_delete", "archive_create_folder", "archive_create_doc", + "archive_create_html", "open_panel_window", ])), ) diff --git a/src-tauri/capabilities/panel.json b/src-tauri/capabilities/panel.json index e9e9a2c..cab1353 100644 --- a/src-tauri/capabilities/panel.json +++ b/src-tauri/capabilities/panel.json @@ -22,6 +22,7 @@ "dialog:default", "clipboard-manager:allow-write-text", "allow-archive-create-doc", + "allow-archive-create-html", "allow-archive-create-folder", "allow-archive-delete", "allow-archive-read", diff --git a/src-tauri/capabilities/terminal.json b/src-tauri/capabilities/terminal.json index becb7d1..cddb4aa 100644 --- a/src-tauri/capabilities/terminal.json +++ b/src-tauri/capabilities/terminal.json @@ -21,6 +21,7 @@ "dialog:default", "clipboard-manager:allow-write-text", "allow-archive-create-doc", + "allow-archive-create-html", "allow-archive-create-folder", "allow-archive-delete", "allow-archive-read", diff --git a/src-tauri/permissions/autogenerated/archive_create_html.toml b/src-tauri/permissions/autogenerated/archive_create_html.toml new file mode 100644 index 0000000..680310e --- /dev/null +++ b/src-tauri/permissions/autogenerated/archive_create_html.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-archive-create-html" +description = "Enables the archive_create_html command without any pre-configured scope." +commands.allow = ["archive_create_html"] + +[[permission]] +identifier = "deny-archive-create-html" +description = "Denies the archive_create_html command without any pre-configured scope." +commands.deny = ["archive_create_html"] diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 83aa244..442e57d 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -319,6 +319,7 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + terminal::archive_delete, terminal::archive_create_folder, terminal::archive_create_doc, + terminal::archive_create_html, terminal::open_panel_window ] } diff --git a/src-tauri/src/domain/archive_html.rs b/src-tauri/src/domain/archive_html.rs new file mode 100644 index 0000000..1f8aed9 --- /dev/null +++ b/src-tauri/src/domain/archive_html.rs @@ -0,0 +1,214 @@ +//! HTML-Notizen: Gegenstück zu den Frontmatter-Funktionen in archive.rs. +//! Dieselben Metadaten (id, title, project, created, description, tags) +//! stehen hier an den Stellen, die HTML dafür vorsieht — `` und +//! `<meta name=…>`. Geparst wird mit denselben Mitteln wie der +//! Frontmatter-Block: Zeichenkettensuche, kein HTML-Parser; die Dateien +//! schreibt dieselbe Anwendung. + +use std::collections::HashMap; + +/// Gerüst einer neuen HTML-Notiz. +pub(crate) fn skeleton(title: &str, project: &str, iso: &str) -> String { + format!( + "<!doctype html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n\ + <meta name=\"id\" content=\"{}\">\n\ + <meta name=\"project\" content=\"{}\">\n\ + <meta name=\"created\" content=\"{iso}\">\n\ + <meta name=\"source\" content=\"ai-control\">\n\ + <title>{}\n\n\n\n\n", + uuid::Uuid::new_v4(), + escape(project), + escape(title), + ) +} + +/// Metadaten einer HTML-Notiz: `` plus alle `<meta name=… content=…>`. +/// Schlagwörter stehen in `keywords`, kommagetrennt. +pub(crate) fn parse_meta(text: &str) -> HashMap<String, String> { + let mut map = HashMap::new(); + if let Some(title) = between(text, "<title>", "") { + map.insert("title".to_string(), unescape(title.trim())); + } + let mut rest = text; + while let Some(start) = rest.find("') else { + break; + }; + let tag = &rest[..end]; + rest = &rest[end + 1..]; + if let (Some(name), Some(content)) = (attr(tag, "name"), attr(tag, "content")) { + map.insert(name, unescape(&content)); + } + } + map +} + +/// Setzt den Anzeige-Titel: den Inhalt von ``. +pub(crate) fn set_title(text: &str, title: &str) -> Result<String, String> { + let start = text.find("<title>").ok_or("kein <title> in der HTML-Notiz")?; + let end = text.find("").ok_or("kein in der HTML-Notiz")?; + Ok(format!("{}{}{}", &text[..start + 7], escape(title), &text[end..])) +} + +/// Setzt ein `<meta name=… content=…>`; fehlt es, kommt es vor den `<title>`. +pub(crate) fn set_meta(text: &str, name: &str, value: &str) -> Result<String, String> { + let line = format!("<meta name=\"{name}\" content=\"{}\">", escape(value)); + let mut rest = text; + let mut offset = 0; + while let Some(start) = rest.find("<meta ") { + let abs = offset + start; + rest = &rest[start + 6..]; + offset = abs + 6; + let Some(end) = rest.find('>') else { + break; + }; + if attr(&rest[..end], "name").as_deref() == Some(name) { + return Ok(format!("{}{line}{}", &text[..abs], &text[offset + end + 1..])); + } + rest = &rest[end + 1..]; + offset += end + 1; + } + let at = text.find("<title>").ok_or("kein <head> in der HTML-Notiz")?; + Ok(format!("{}{line}\n{}", &text[..at], &text[at..])) +} + +/// Rumpf der Notiz: Inhalt zwischen `<body>` und `</body>`; ohne die Marken +/// gilt der ganze Text als Rumpf. +pub(crate) fn body(text: &str) -> &str { + match (text.find("<body>"), text.rfind("</body>")) { + (Some(a), Some(b)) if b > a + 6 => text[a + 6..b].trim_matches('\n'), + _ => text, + } +} + +/// Ersetzt den Rumpf; Kopf und Marken bleiben. +pub(crate) fn replace_body(text: &str, html: &str) -> Result<String, String> { + let a = text.find("<body>").ok_or("kein <body> in der HTML-Notiz")?; + let b = text.rfind("</body>").ok_or("kein <body> in der HTML-Notiz")?; + Ok(format!("{}\n{}\n{}", &text[..a + 6], html.trim(), &text[b..])) +} + +/// Reiner Text einer HTML-Notiz — Grundlage für den Suchindex. +pub(crate) fn strip_tags(text: &str) -> String { + let mut out = String::new(); + let mut rest = body(text); + // Skript- und Stilblöcke tragen keinen Lesetext. + for marker in ["script", "style"] { + while let Some(a) = rest.find(&format!("<{marker}")) { + let Some(b) = rest[a..].find(&format!("</{marker}>")) else { + break; + }; + let cut = format!("{}{}", &rest[..a], &rest[a + b + marker.len() + 3..]); + out.clear(); + return strip_tags_inner(&cut); + } + } + strip_tags_inner(rest) +} + +fn strip_tags_inner(text: &str) -> String { + let mut out = String::new(); + let mut in_tag = false; + for c in text.chars() { + match c { + '<' => in_tag = true, + '>' => { + in_tag = false; + out.push(' '); + } + _ if !in_tag => out.push(c), + _ => {} + } + } + unescape(out.split_whitespace().collect::<Vec<_>>().join(" ").as_str()) +} + +/// Wert eines Attributs im Tag-Inneren (`name="wert"`). +fn attr(tag: &str, name: &str) -> Option<String> { + let key = format!("{name}=\""); + let start = tag.find(&key)? + key.len(); + let end = tag[start..].find('"')? + start; + Some(tag[start..end].to_string()) +} + +fn between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> { + let start = text.find(open)? + open.len(); + let end = text[start..].find(close)? + start; + Some(&text[start..end]) +} + +fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +fn unescape(s: &str) -> String { + s.replace(""", "\"") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn doc() -> String { + skeleton("Titel", "proj", "2026-07-26T10:00:00Z") + } + + #[test] + fn geruest_traegt_id_und_titel() { + let html = doc(); + let meta = parse_meta(&html); + assert_eq!(meta.get("title").map(String::as_str), Some("Titel")); + assert_eq!(meta.get("id").map(String::len), Some(36)); + assert_eq!(meta.get("project").map(String::as_str), Some("proj")); + assert_eq!(meta.get("created").map(String::as_str), Some("2026-07-26T10:00:00Z")); + assert_eq!(body(&html), ""); + } + + #[test] + fn titel_und_meta_setzen() { + let html = set_title(&doc(), "Neuer <Titel>").unwrap(); + assert_eq!(parse_meta(&html).get("title").map(String::as_str), Some("Neuer <Titel>")); + + // Vorhandenes meta wird ersetzt, fehlendes ergänzt. + let html = set_meta(&html, "project", "andere").unwrap(); + let html = set_meta(&html, "description", "Kurz \"gefasst\"").unwrap(); + let meta = parse_meta(&html); + assert_eq!(meta.get("project").map(String::as_str), Some("andere")); + assert_eq!(meta.get("description").map(String::as_str), Some("Kurz \"gefasst\"")); + assert_eq!(meta.get("title").map(String::as_str), Some("Neuer <Titel>")); + // Kein Duplikat. + assert_eq!(html.matches("name=\"project\"").count(), 1); + } + + #[test] + fn rumpf_lesen_und_ersetzen() { + let html = replace_body(&doc(), "<p>Erster Absatz</p>").unwrap(); + assert_eq!(body(&html), "<p>Erster Absatz</p>"); + let html = replace_body(&html, "<p>Zweiter</p>").unwrap(); + assert_eq!(body(&html), "<p>Zweiter</p>"); + // Der Kopf bleibt unangetastet. + assert_eq!(parse_meta(&html).get("title").map(String::as_str), Some("Titel")); + } + + #[test] + fn suchtext_ohne_markup() { + let html = replace_body( + &doc(), + "<h1>Über&#nbsp;schrift</h1>\n<p>Text mit <b>Fett</b> und & Zeichen.</p>", + ) + .unwrap(); + let text = strip_tags(&html); + assert!(text.contains("Text mit Fett und & Zeichen.")); + assert!(!text.contains('<')); + } + + #[test] + fn fehlende_marken_brechen_laut_ab() { + assert!(set_title("<html></html>", "T").is_err()); + assert!(replace_body("<html></html>", "x").is_err()); + } +} diff --git a/src-tauri/src/domain/archive_index.rs b/src-tauri/src/domain/archive_index.rs index af14661..e48e29e 100644 --- a/src-tauri/src/domain/archive_index.rs +++ b/src-tauri/src/domain/archive_index.rs @@ -17,6 +17,8 @@ pub(crate) struct Doc { pub(crate) id: String, /// Pfad relativ zum Archiv-Home (Eigenschaft, keine Identität). pub(crate) relpath: String, + /// Notiz-Typ: `md` oder `html` — bestimmt Anzeige und Editor. + pub(crate) kind: &'static str, /// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel. pub(crate) name: String, /// Frontmatter-Titel, sonst der Name. @@ -91,7 +93,7 @@ fn walk(home: &Path, dir: &Path, docs: &mut Vec<(Doc, String)>) -> Result<(), St } if path.is_dir() { walk(home, &path, docs)?; - } else if fname.ends_with(".md") { + } else if fname.ends_with(".md") || fname.ends_with(".html") { docs.push(read_doc(home, &path)?); } } @@ -109,7 +111,15 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> { let modified = crate::domain::archive::utc_stamp(mtime).1[..10].to_string(); let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string(); let name = strip_stamp(&stem).to_string(); - let fm = parse_frontmatter(&text); + let html = path.extension().is_some_and(|e| e == "html"); + let kind = if html { "html" } else { "md" }; + // Beide Typen tragen dieselben Angaben, nur an ihrer jeweils üblichen + // Stelle: Markdown im Frontmatter, HTML in <title>/<meta>. + let fm = if html { + crate::domain::archive_html::parse_meta(&text) + } else { + parse_frontmatter(&text) + }; let relpath = path .strip_prefix(home) .map_err(|e| format!("{}: {e}", path.display()))? @@ -120,14 +130,23 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> { relpath, title: fm.get("title").unwrap_or(&name).clone(), description: fm.get("description").cloned(), - tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(), + tags: fm + .get(if html { "keywords" } else { "tags" }) + .map(|t| parse_tag_list(t)) + .unwrap_or_default(), created: fm.get("created").cloned(), links: wikilinks(&text), backlinks: Vec::new(), modified, + kind, name, }; - Ok((doc, text)) + let indexed = if html { + crate::domain::archive_html::strip_tags(&text) + } else { + text + }; + Ok((doc, indexed)) } /// Slug-Vergleich eines Wikilink-Ziels gegen Name, Titel und Datei-Stem. @@ -170,6 +189,8 @@ pub(crate) struct WikiFolder { pub(crate) struct WikiDocEntry { /// Technische ID — Adressat aller Aktionen. pub(crate) id: String, + /// Notiz-Typ (`md`/`html`) — Symbol im Baum, Anzeige und Editor. + pub(crate) kind: &'static str, /// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der /// Zeilen-Aktionen (umbenennen, löschen). pub(crate) relpath: String, @@ -265,8 +286,14 @@ pub(crate) fn folder_nodes(home: &Path) -> Result<Vec<FolderNode>, String> { .iter() .map(|ext| home.join(&path).with_extension(ext)) .find(|p| p.is_file()) - .and_then(|p| fs::read_to_string(p).ok()) - .map(|text| parse_frontmatter(&text)) + .and_then(|p| Some((p.extension()?.to_string_lossy().to_string(), fs::read_to_string(p).ok()?))) + .map(|(ext, text)| { + if ext == "html" { + crate::domain::archive_html::parse_meta(&text) + } else { + parse_frontmatter(&text) + } + }) .unwrap_or_default(); let title = fm.get("title").cloned().unwrap_or_else(|| name.clone()); let id = fm.get("id").cloned().unwrap_or_default(); @@ -310,6 +337,7 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry { }); WikiDocEntry { id: doc.id.clone(), + kind: doc.kind, relpath: doc.relpath.clone(), name: doc.name.clone(), title: doc.title.clone(), diff --git a/src-tauri/src/domain/archive_ops.rs b/src-tauri/src/domain/archive_ops.rs index 2874a57..971d918 100644 --- a/src-tauri/src/domain/archive_ops.rs +++ b/src-tauri/src/domain/archive_ops.rs @@ -27,8 +27,8 @@ fn checked_rel(rel: &str) -> Result<(), String> { /// Vorhandenes Archiv-Dokument zum relpath. pub(crate) 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}")); + if !relpath.ends_with(".md") && !relpath.ends_with(".html") { + return Err(format!("keine Notiz-Datei: {relpath}")); } let path = home.join(relpath); if !path.is_file() { @@ -37,6 +37,11 @@ pub(crate) fn doc_path(home: &Path, relpath: &str) -> Result<PathBuf, String> { Ok(path) } +/// Ist die Datei eine HTML-Notiz? +fn is_html(path: &Path) -> bool { + path.extension().is_some_and(|e| e == "html") +} + /// Ziel für rename: darf noch nicht existieren — stilles Überschreiben wäre /// Datenverlust. fn fresh_target(target: &Path, home: &Path) -> Result<(), String> { @@ -112,6 +117,40 @@ pub(crate) fn create_doc( Ok(target.strip_prefix(home).unwrap().display().to_string()) } +/// Legt eine leere HTML-Notiz an: `<slug>.html` im Ordner (leer = Wurzel). +/// Liefert den relpath. +pub(crate) fn create_html( + 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!("{}.html", slugify(name))); + let doc = crate::domain::archive_html::skeleton(name, project_name, &utc_now_iso()?); + 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, doc.as_bytes()) + .map_err(|e| format!("{}: {e}", target.display()))?; + Ok(target.strip_prefix(home).unwrap().display().to_string()) +} + /// Setzt den Anzeige-Titel einer Notiz: `title:` im Frontmatter der Datei. /// Der Dateiname bleibt — er ist der technische Name (Modellvorgabe: der /// angezeigte Titel steht im Text). Ohne Frontmatter-Block bekommt die Datei @@ -122,6 +161,10 @@ pub(crate) fn set_title(path: &Path, title: &str) -> Result<(), String> { return Err("Titel fehlt".into()); } let doc = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + if is_html(path) { + let out = crate::domain::archive_html::set_title(&doc, title)?; + return fs::write(path, out).map_err(|e| format!("{}: {e}", path.display())); + } let rest = doc .strip_prefix("---\n") .ok_or_else(|| format!("kein Frontmatter-Block: {}", path.display()))?; @@ -143,6 +186,10 @@ pub(crate) fn set_title(path: &Path, title: &str) -> Result<(), String> { /// 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()))?; + if is_html(path) { + let out = crate::domain::archive_html::replace_body(&doc, text)?; + return fs::write(path, out).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())) @@ -182,8 +229,17 @@ pub(crate) fn move_folder(home: &Path, folder: &str, to: &str) -> Result<(), Str /// über die ID. Dateien ohne ID (von Hand angelegt, Altbestand) bekommen hier /// eine. pub(crate) fn ensure_ids(home: &Path) -> Result<(), String> { - for path in md_files(home)? { + for path in note_files(home)? { let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + if is_html(&path) { + if crate::domain::archive_html::parse_meta(&text).contains_key("id") { + continue; + } + let id = uuid::Uuid::new_v4().to_string(); + let out = crate::domain::archive_html::set_meta(&text, "id", &id)?; + fs::write(&path, out).map_err(|e| format!("{}: {e}", path.display()))?; + continue; + } if crate::domain::archive::parse_frontmatter(&text).contains_key("id") { continue; } @@ -199,9 +255,9 @@ pub(crate) fn ensure_ids(home: &Path) -> Result<(), String> { Ok(()) } -/// Alle Markdown-Dateien unterhalb von `dir`, rekursiv; versteckte Einträge -/// bleiben außen vor. -fn md_files(dir: &Path) -> Result<Vec<PathBuf>, String> { +/// Alle Notiz-Dateien (`.md`, `.html`) unterhalb von `dir`, rekursiv; +/// versteckte Einträge bleiben außen vor. +fn note_files(dir: &Path) -> Result<Vec<PathBuf>, String> { let mut out = Vec::new(); for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? { let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?; @@ -211,8 +267,8 @@ fn md_files(dir: &Path) -> Result<Vec<PathBuf>, String> { } let path = entry.path(); if path.is_dir() { - out.extend(md_files(&path)?); - } else if name.ends_with(".md") { + out.extend(note_files(&path)?); + } else if name.ends_with(".md") || name.ends_with(".html") { out.push(path); } } @@ -254,14 +310,16 @@ fn ensure_dir_texts(dir: &Path, project_name: &str) -> Result<(), String> { Ok(()) } -/// Namen (Stem ohne Zeitstempel) aller Markdown-Dateien direkt im Ordner. +/// Namen (Stem ohne Zeitstempel) aller Notiz-Dateien direkt im Ordner. fn md_stems(dir: &Path) -> Result<std::collections::HashSet<String>, String> { let mut out = std::collections::HashSet::new(); for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? { let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?; let name = entry.file_name().to_string_lossy().to_string(); - if let Some(stem) = name.strip_suffix(".md") { - out.insert(strip_stamp(stem).to_string()); + for ext in [".md", ".html"] { + if let Some(stem) = name.strip_suffix(ext) { + out.insert(strip_stamp(stem).to_string()); + } } } Ok(out) diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 2c8d14e..0317970 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod archive; pub(crate) mod archive_index; +pub(crate) mod archive_html; pub(crate) mod archive_ops; pub(crate) mod archive_search; pub(crate) mod credentials; diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 1fff9b7..34ab83f 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -690,15 +690,49 @@ fn join_under( /// frisch (zwei konkurrierende Puffer-Events würden sonst um den aktiven Tab /// rennen). #[tauri::command] -pub fn archive_create_doc(project: String, parent: String, name: String) -> Result<(), String> { +pub fn archive_create_doc( + project: String, + parent: String, + name: String, +) -> Result<String, String> { let home = crate::domain::archive::require_archive_home(&project)?; let display = crate::domain::project::display_name_in(&Paths::real(), &project)?; let rel_new = join_under(&home, &parent, &name)?; let folder = rel_new.rsplit_once('/').map(|(h, _)| h.to_string()).unwrap_or_default(); let rel = crate::domain::archive_ops::create_doc(&home, &folder, &name, &display)?; - let path = home.join(rel); - std::fs::write(panel_file(&project), "").map_err(|e| e.to_string())?; - link_panel_source(&project, &path) + // Bearbeitet wird im Archiv — der Entwurfs-Puffer bleibt unberührt; die + // frische Übersicht zeigt die neue Notiz, ihre ID öffnet dort den Editor. + crate::domain::archive_ops::ensure_ids(&home)?; + wiki_refresh_page(&project, &home)?; + note_id(&home.join(rel)) +} + +/// Technische ID einer eben angelegten Notiz. +fn note_id(path: &std::path::Path) -> Result<String, String> { + let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + let meta = if path.extension().is_some_and(|e| e == "html") { + crate::domain::archive_html::parse_meta(&text) + } else { + crate::domain::archive::parse_frontmatter(&text) + }; + meta.get("id").cloned().ok_or_else(|| format!("keine ID in {}", path.display())) +} + +/// Legt eine leere HTML-Notiz unter dem Knoten an (Kontextmenü im Baum). +#[tauri::command] +pub fn archive_create_html( + project: String, + parent: String, + name: String, +) -> Result<String, String> { + let home = crate::domain::archive::require_archive_home(&project)?; + let display = crate::domain::project::display_name_in(&Paths::real(), &project)?; + let rel_new = join_under(&home, &parent, &name)?; + let folder = rel_new.rsplit_once('/').map(|(h, _)| h.to_string()).unwrap_or_default(); + let rel = crate::domain::archive_ops::create_html(&home, &folder, &name, &display)?; + crate::domain::archive_ops::ensure_ids(&home)?; + wiki_refresh_page(&project, &home)?; + note_id(&home.join(rel)) } /// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8c87c8e..f2ea72a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,7 +13,7 @@ "macOSPrivateApi": true, "windows": [], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost", + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost", "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420" } }, diff --git a/src/html-editor.ts b/src/html-editor.ts new file mode 100644 index 0000000..ea02ce8 --- /dev/null +++ b/src/html-editor.ts @@ -0,0 +1,173 @@ +/// WYSIWYG-Editor für HTML-Notizen, direkt auf ProseMirror (MIT, ohne +/// Anbieter-Stufe). HTML ist hier nicht Umweg, sondern Format: `DOMParser` +/// liest den Rumpf der Notiz ins Dokumentmodell, `DOMSerializer` schreibt ihn +/// zurück — was gespeichert wird, ist genau das, was der Editor zeigt. +/// Markdown-Notizen behalten den Rohtext-Editor mit Vorschau. + +import { baseKeymap, chainCommands, setBlockType, toggleMark } from "prosemirror-commands"; +import { history, redo, undo } from "prosemirror-history"; +import { keymap } from "prosemirror-keymap"; +import { DOMParser as PMDOMParser, DOMSerializer, Schema, type MarkType } from "prosemirror-model"; +import { addColumnAfter, addRowAfter, deleteTable, goToNextCell, tableNodes, tableEditing } from "prosemirror-tables"; +import { schema as basic } from "prosemirror-schema-basic"; +import { liftListItem, sinkListItem, splitListItem, wrapInList } from "prosemirror-schema-list"; +import { EditorState, type Command } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +// Basisstile des Editors (Cursor, Auswahl) — gehören zum Paket. +import "prosemirror-view/style/prosemirror.css"; +import { t } from "./messages"; + +/// Schema: Grundbausteine plus Listen und Tabellen — mehr braucht eine Notiz +/// nicht, und jeder zusätzliche Knoten wäre HTML, das wir beim Zurücklesen +/// wieder verstehen müssten. +const listNodes = { + ordered_list: { + content: "list_item+", + group: "block", + parseDOM: [{ tag: "ol" }], + toDOM: () => ["ol", 0] as const, + }, + bullet_list: { + content: "list_item+", + group: "block", + parseDOM: [{ tag: "ul" }], + toDOM: () => ["ul", 0] as const, + }, + list_item: { + content: "paragraph block*", + parseDOM: [{ tag: "li" }], + toDOM: () => ["li", 0] as const, + defining: true, + }, +}; + +const schema = new Schema({ + nodes: basic.spec.nodes + .append(listNodes) + .append(tableNodes({ tableGroup: "block", cellContent: "block+", cellAttributes: {} })), + marks: basic.spec.marks, +}); + +export interface HtmlEditor { + el: HTMLElement; + /// Aktueller Rumpf als HTML. + html(): string; + focus(): void; + destroy(): void; +} + +/// Knopf der Werkzeugleiste; `run` liefert true, wenn der Befehl greift. +function button(label: string, title: string, run: () => void): HTMLElement { + const b = document.createElement("button"); + b.type = "button"; + b.className = "html-tool"; + b.textContent = label; + b.title = title; + // mousedown statt click: sonst verliert die Auswahl im Editor den Fokus, + // bevor der Befehl auf ihr arbeiten kann. + b.addEventListener("mousedown", (e) => { + e.preventDefault(); + run(); + }); + return b; +} + +export function initHtmlEditor(html: string): HtmlEditor { + const el = document.createElement("div"); + el.className = "html-editor"; + const bar = document.createElement("div"); + bar.className = "html-toolbar"; + const host = document.createElement("div"); + host.className = "html-surface"; + el.append(bar, host); + + // Rumpf der Notiz ins Dokumentmodell lesen. + const source = document.createElement("div"); + source.innerHTML = html; + const doc = PMDOMParser.fromSchema(schema).parse(source); + + const view = new EditorView(host, { + state: EditorState.create({ + doc, + plugins: [ + history(), + keymap({ + "Mod-z": undo, + "Mod-y": redo, + "Mod-Shift-z": redo, + "Mod-b": toggleMark(schema.marks.strong), + "Mod-i": toggleMark(schema.marks.em), + Enter: chainCommands(splitListItem(schema.nodes.list_item), baseKeymap.Enter), + Tab: goToNextCell(1), + "Shift-Tab": goToNextCell(-1), + }), + keymap(baseKeymap), + tableEditing(), + ], + }), + }); + + const run = (cmd: Command) => { + cmd(view.state, view.dispatch, view); + view.focus(); + }; + const mark = (m: MarkType) => run(toggleMark(m)); + const heading = (level: number) => + run(setBlockType(schema.nodes.heading, { level })); + + /// Tabelle mit zwei Zeilen und zwei Spalten einsetzen. + const insertTable: Command = (state, dispatch) => { + const { table, table_row, table_cell, paragraph } = schema.nodes; + const cell = () => table_cell.createAndFill()!; + const row = table_row.create(null, [cell(), cell()]); + const node = table.create(null, [row, row.copy(row.content)]); + if (dispatch) dispatch(state.tr.replaceSelectionWith(node).scrollIntoView()); + void paragraph; + return true; + }; + + bar.append( + button("H1", t("html.h1"), () => heading(1)), + button("H2", t("html.h2"), () => heading(2)), + button("¶", t("html.paragraph"), () => run(setBlockType(schema.nodes.paragraph))), + button("B", t("html.bold"), () => mark(schema.marks.strong)), + button("I", t("html.italic"), () => mark(schema.marks.em)), + button("•", t("html.bulletList"), () => run(wrapInList(schema.nodes.bullet_list))), + button("1.", t("html.orderedList"), () => run(wrapInList(schema.nodes.ordered_list))), + button("→", t("html.indent"), () => run(sinkListItem(schema.nodes.list_item))), + button("←", t("html.outdent"), () => run(liftListItem(schema.nodes.list_item))), + button("⊞", t("html.table"), () => run(insertTable)), + button("+↓", t("html.rowAfter"), () => run(addRowAfter)), + button("+→", t("html.colAfter"), () => run(addColumnAfter)), + button("⌫", t("html.deleteTable"), () => run(deleteTable)), + button("🔗", t("html.link"), () => setLink()), + ); + + /// Link auf der Auswahl setzen oder entfernen. + function setLink() { + const { from, to } = view.state.selection; + if (from === to) return; + const has = view.state.doc.rangeHasMark(from, to, schema.marks.link); + if (has) { + run(toggleMark(schema.marks.link)); + return; + } + const href = window.prompt(t("html.linkPrompt"), "https://"); + if (!href) return; + run(toggleMark(schema.marks.link, { href })); + } + + return { + el, + html() { + const fragment = DOMSerializer.fromSchema(schema).serializeFragment( + view.state.doc.content, + ); + const out = document.createElement("div"); + out.append(fragment); + return out.innerHTML; + }, + focus: () => view.focus(), + destroy: () => view.destroy(), + }; +} diff --git a/src/messages.ts b/src/messages.ts index 8975abc..d7703f7 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -239,6 +239,23 @@ const de = { hits: "{count} Treffer {scope}", noHits: "Keine Treffer für {scope}", }, + html: { + h1: "Überschrift 1", + h2: "Überschrift 2", + paragraph: "Absatz", + bold: "Fett", + italic: "Kursiv", + bulletList: "Aufzählung", + orderedList: "Nummerierte Liste", + indent: "Einrücken", + outdent: "Ausrücken", + table: "Tabelle einfügen", + rowAfter: "Zeile darunter", + colAfter: "Spalte rechts", + deleteTable: "Tabelle löschen", + link: "Link setzen/entfernen", + linkPrompt: "Adresse des Links", + }, wiki: { archive: "Archiv", docOne: "{count} Dokument", @@ -261,6 +278,7 @@ const de = { deleteDoc: "Dokument löschen", newFolder: "Neuer Ordner", newDoc: "Neues Dokument", + newHtml: "Neue HTML-Notiz", create: "Anlegen", cancel: "Abbrechen", docName: "Name des Dokuments", @@ -501,6 +519,23 @@ const en: typeof de = { hits: "{count} hits {scope}", noHits: "No hits for {scope}", }, + html: { + h1: "Heading 1", + h2: "Heading 2", + paragraph: "Paragraph", + bold: "Bold", + italic: "Italic", + bulletList: "Bullet list", + orderedList: "Numbered list", + indent: "Indent", + outdent: "Outdent", + table: "Insert table", + rowAfter: "Row below", + colAfter: "Column right", + deleteTable: "Delete table", + link: "Set/remove link", + linkPrompt: "Link address", + }, wiki: { archive: "Archive", docOne: "{count} document", @@ -523,6 +558,7 @@ const en: typeof de = { deleteDoc: "Delete document", newFolder: "New folder", newDoc: "New document", + newHtml: "New HTML note", create: "Create", cancel: "Cancel", docName: "Document name", diff --git a/src/modules/archive.ts b/src/modules/archive.ts index 47158e2..6391547 100644 --- a/src/modules/archive.ts +++ b/src/modules/archive.ts @@ -24,6 +24,12 @@ export const wikiTab: PanelTab = { void invoke(cmd, { project: ctx.project, ...args }).catch((e) => ctx.toast(String(e)), ); + // Anlegen liefert die ID der neuen Notiz zurück. + const create = (cmd: string, args: Record<string, string>) => + invoke<string>(cmd, { project: ctx.project, ...args }).catch((e) => { + ctx.toast(String(e)); + throw e; + }); return initWikiView(container, { autoStart: ctx.standalone, readDoc: (id) => invoke("archive_read", { project: ctx.project, id }), @@ -38,7 +44,8 @@ export const wikiTab: PanelTab = { actions: { remove: (id) => run("archive_delete", { id }), createFolder: (parent, name) => run("archive_create_folder", { parent, name }), - createDoc: (parent, name) => run("archive_create_doc", { parent, name }), + createDoc: (parent, name) => create("archive_create_doc", { parent, name }), + createHtml: (parent, name) => create("archive_create_html", { parent, name }), }, }); }, diff --git a/src/panel-tiles.css b/src/panel-tiles.css index c8dd9cb..4c77cb3 100644 --- a/src/panel-tiles.css +++ b/src/panel-tiles.css @@ -684,6 +684,76 @@ .wiki-note-actions { visibility: visible; } +/* WYSIWYG-Editor der HTML-Notizen (ProseMirror). */ +.html-editor { + display: flex; + flex-direction: column; + gap: 10px; +} +.html-toolbar { + display: flex; + flex-wrap: wrap; + gap: 4px; + background: var(--tile); + border: 1px solid var(--line-strong); + border-radius: 8px; + padding: 6px; +} +.html-tool { + min-width: 32px; + border: 1px solid transparent; + background: var(--surface); + color: var(--text); + border-radius: 5px; + padding: 5px 8px; + cursor: pointer; + font: + 600 14px/1.2 -apple-system, + system-ui, + sans-serif; +} +.html-tool:hover { + border-color: var(--accent); +} +.html-surface .ProseMirror { + min-height: 55vh; + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: 8px; + padding: 14px 16px; + outline: none; + color: var(--text); + font: + 400 17px/1.6 -apple-system, + system-ui, + sans-serif; +} +.html-surface .ProseMirror:focus { + border-color: var(--accent); +} +.html-surface .ProseMirror table { + border-collapse: collapse; + width: 100%; + margin: 0 0 0.7em; +} +.html-surface .ProseMirror td, +.html-surface .ProseMirror th { + border: 1px solid var(--line-strong); + padding: 6px 8px; + vertical-align: top; +} +.html-surface .ProseMirror .selectedCell::after { + content: ""; + position: absolute; + inset: 0; + background: var(--accent); + opacity: 0.18; + pointer-events: none; +} +.html-surface .ProseMirror td, +.html-surface .ProseMirror th { + position: relative; +} /* Markdown-Editor der Notiz (Bearbeiten im Archiv): Rohtext links, Live-Vorschau rechts. */ .wiki-edit-split { @@ -848,6 +918,16 @@ .wiki-note-body a { color: var(--accent); } +.wiki-note-body table { + border-collapse: collapse; + margin: 0 0 0.7em; +} +.wiki-note-body td, +.wiki-note-body th { + border: 1px solid var(--line-strong); + padding: 6px 8px; + vertical-align: top; +} .wiki-note-children { margin-top: 26px; background: var(--tile); diff --git a/src/panel.ts b/src/panel.ts index 95ea898..8d1ca05 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -146,7 +146,7 @@ const archiveForm = initArchiveForm( { // Ordner-Vorschläge aus dem Archiv; „Auf Platte legen" schreibt den // Entwurf an einen frei gewählten Pfad (ohne Archiv, ohne Frontmatter). - folders: () => invoke<string[]>("archive_folders", { project }), + folders: () => invoke("archive_folders", { project }), onSave: async () => { try { const { save } = await import("@tauri-apps/plugin-dialog"); diff --git a/src/terminal.ts b/src/terminal.ts index 69442aa..2773482 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -318,7 +318,7 @@ const archiveForm = initArchiveForm( { // Ordner-Vorschläge aus dem Archiv; „Auf Platte legen" schreibt den // Entwurf an einen frei gewählten Pfad (ohne Archiv, ohne Frontmatter). - folders: () => invoke<string[]>("archive_folders", { project }), + folders: () => invoke("archive_folders", { project }), onSave: async () => { try { const { save } = await import("@tauri-apps/plugin-dialog"); diff --git a/src/wiki-view.test.ts b/src/wiki-view.test.ts index f24c60e..7ccd79e 100644 --- a/src/wiki-view.test.ts +++ b/src/wiki-view.test.ts @@ -14,7 +14,8 @@ function setup(pending: string | null = null) { const actions = { remove: vi.fn(), createFolder: vi.fn(), - createDoc: vi.fn(), + createDoc: vi.fn(() => Promise.resolve("id-neu-doc")), + createHtml: vi.fn(() => Promise.resolve("id-neu-html")), }; let pendingSelect = pending; const view = initWikiView(document.getElementById("w")!, { @@ -43,6 +44,7 @@ const doc = (relpath: string, name: string, extra: object = {}) => ({ date: "2026-07-19", backlinks: 0, modified: "2026-07-24", + kind: "md", ...extra, }); @@ -219,6 +221,95 @@ describe("initWikiView — Notiz-Ansicht", () => { }); }); +describe("initWikiView — HTML-Notizen", () => { + const htmlPage = JSON.stringify({ + kind: "page", + home: "/tmp/archiv", + tag: null, + total: 1, + tags: [], + folders: [ + { name: "", docs: [doc("seite.html", "seite", { kind: "html" })] }, + ], + }); + + it("zeigt HTML ohne Markdown-Renderer und trägt ein eigenes Symbol", async () => { + document.body.innerHTML = `<div id="w"></div>`; + const readDoc = vi.fn(() => Promise.resolve("<p>Roher <b>Text</b></p>")); + const view = initWikiView(document.getElementById("w")!, { + autoStart: true, + readDoc, + writeDoc: vi.fn(() => Promise.resolve()), + setTitle: vi.fn(), + openWiki: vi.fn(), + actions: { + remove: vi.fn(), + createFolder: vi.fn(), + createDoc: vi.fn(() => Promise.resolve("")), + createHtml: vi.fn(() => Promise.resolve("")), + }, + }); + view.set(htmlPage); + document.querySelector<HTMLElement>(".wiki-tree-doc")!.click(); + await flush(); + // Der Rumpf steht als Markup in der Ansicht — kein <p> aus Markdown. + expect(document.querySelector(".wiki-note-body b")!.textContent).toBe("Text"); + expect(readDoc).toHaveBeenCalledWith("id-seite"); + }); + + it("Kontextmenü legt HTML-Notizen unter dem Knoten an", () => { + const { view, actions } = setup(); + view.set(mergedPage); + document + .querySelector<HTMLElement>(".wiki-tree summary")! + .dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")]; + items.find((i) => i.textContent === "Neue HTML-Notiz")!.click(); + const input = document.querySelector<HTMLInputElement>(".wiki-form input")!; + input.value = "Seite"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(actions.createHtml).toHaveBeenCalledWith("id-konzepte", "Seite"); + }); +}); + +describe("initWikiView — Anlegen öffnet den Editor", () => { + it("wählt die neue Notiz aus und zeigt den Editor", async () => { + const { view, actions } = setup(); + view.set(page); + document + .querySelector<HTMLElement>(".wiki-tree summary")! + .dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")]; + items.find((i) => i.textContent === "Neues Dokument")!.click(); + const input = document.querySelector<HTMLInputElement>(".wiki-form input")!; + input.value = "Frisch"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(actions.createDoc).toHaveBeenCalledWith("id-konzepte", "Frisch"); + await flush(); + + // Die Übersicht kommt nach; erst dann greift die Vormerkung. + expect(document.querySelector(".wiki-note-editor")).toBeNull(); + view.set( + JSON.stringify({ + kind: "page", + home: "/tmp/archiv", + tag: null, + total: 1, + tags: [], + folders: [ + { + name: "", + docs: [doc("frisch.md", "frisch", { id: "id-neu-doc", title: "Frisch" })], + }, + ], + }), + ); + await flush(); + expect(document.querySelector(".wiki-note-title")!.textContent).toBe("Frisch"); + expect(document.querySelector(".wiki-note-editor")).not.toBeNull(); + }); +}); + describe("initWikiView — Zurück", () => { it("Zurück-Knopf führt zur vorherigen Auswahl", async () => { const { view } = setup(); diff --git a/src/wiki-view.ts b/src/wiki-view.ts index 08d3802..d70c673 100644 --- a/src/wiki-view.ts +++ b/src/wiki-view.ts @@ -6,6 +6,7 @@ /// Inhalt die Kindliste. Titel, Beschreibungen und Pfade sind Fremdtext und /// gehen nie durch innerHTML; der Markdown-Body läuft durch renderMarkdown. +import { initHtmlEditor } from "./html-editor"; import { renderMarkdown } from "./markdown"; import { t } from "./messages"; import { linkWikiRefs } from "./panel-view"; @@ -24,6 +25,8 @@ interface DocEntry { backlinks: number; /// Letzte Änderung (Datei-mtime, YYYY-MM-DD). modified: string; + /// Notiz-Typ: `md` (Markdown) oder `html`. + kind: "md" | "html"; } interface Folder { @@ -46,7 +49,9 @@ export interface WikiActions { remove(id: string): void; /// Neues Kind unterhalb des Knotens (ID; "" = Wurzel). createFolder(parent: string, name: string): void; - createDoc(parent: string, name: string): void; + /// Liefern die ID der neuen Notiz — die Ansicht öffnet sie im Editor. + createDoc(parent: string, name: string): Promise<string>; + createHtml(parent: string, name: string): Promise<string>; } export interface WikiCallbacks { @@ -147,6 +152,25 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie const history: string[] = []; /// Bearbeitungsmodus der aktuellen Notiz (Editor statt Anzeige). let editing = false; + /// Eben angelegte Notiz: sobald sie in der Übersicht auftaucht, wird sie + /// ausgewählt und der Editor geöffnet. + let pendingEdit: string | null = null; + + /// Neue Notiz übernehmen — je nachdem, ob die frische Übersicht schon da + /// ist, sofort oder beim nächsten Puffer-Update. + function openNew(id: string) { + pendingEdit = id; + if (findDoc(id)) applyPendingEdit(); + } + + function applyPendingEdit() { + if (!pendingEdit || !findDoc(pendingEdit)) return; + const id = pendingEdit; + pendingEdit = null; + select(id); + editing = true; + renderMain(); + } /// Ist die ID ein Blatt (Notiz ohne Kinder)? const isLeaf = (id: string) => !!id && !nodeById(id); @@ -239,13 +263,16 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie /// Typ-Symbol im Baum (Stroke-SVG wie die übrigen App-Icons): neutraler /// Knoten-Kreis für Ordner-Notizen, Blatt für Dokumente. - function typeIcon(kind: "node" | "doc"): HTMLElement { + function typeIcon(kind: "node" | "doc" | "html"): HTMLElement { const span = document.createElement("span"); span.className = "wiki-tree-icon"; - span.innerHTML = - kind === "node" - ? `<svg width="17" height="17" viewBox="0 0 16 16"><circle cx="8" cy="8" r="4.2"/></svg>` - : `<svg width="17" height="17" viewBox="0 0 16 16"><path d="M4 1.5h5.5L12.5 5v9a.9.9 0 0 1-.9.9H4a.9.9 0 0 1-.9-.9V2.4a.9.9 0 0 1 .9-.9z"/><path d="M9.5 1.5V5H13"/><path d="M5.8 8h4.4M5.8 10.5h4.4"/></svg>`; + const shapes = { + node: `<circle cx="8" cy="8" r="4.2"/>`, + doc: `<path d="M4 1.5h5.5L12.5 5v9a.9.9 0 0 1-.9.9H4a.9.9 0 0 1-.9-.9V2.4a.9.9 0 0 1 .9-.9z"/><path d="M9.5 1.5V5H13"/><path d="M5.8 8h4.4M5.8 10.5h4.4"/>`, + // HTML-Notiz: dasselbe Blatt, spitze Klammern statt Textzeilen. + html: `<path d="M4 1.5h5.5L12.5 5v9a.9.9 0 0 1-.9.9H4a.9.9 0 0 1-.9-.9V2.4a.9.9 0 0 1 .9-.9z"/><path d="M9.5 1.5V5H13"/><path d="M6.6 8.2 5.2 9.6l1.4 1.4M9.4 8.2l1.4 1.4-1.4 1.4"/>`, + }; + span.innerHTML = `<svg width="17" height="17" viewBox="0 0 16 16">${shapes[kind]}</svg>`; return span; } @@ -286,6 +313,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie const id = node.content?.id ?? ""; openMenu(e.clientX, e.clientY, [ { label: t("wiki.newDoc"), run: () => newDocForm(id) }, + { label: t("wiki.newHtml"), run: () => newHtmlForm(id) }, { label: t("wiki.newFolder"), run: () => newFolderForm(id) }, ]); }); @@ -301,7 +329,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie const label = document.createElement("span"); label.className = "wiki-tree-name"; label.textContent = doc.title; - row.append(typeIcon("doc"), label); + row.append(typeIcon(doc.kind === "html" ? "html" : "doc"), label); row.addEventListener("click", () => select(doc.id)); row.addEventListener("contextmenu", (e) => { e.preventDefault(); @@ -352,6 +380,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie e.stopPropagation(); openMenu(e.clientX, e.clientY, [ { label: t("wiki.newDoc"), run: () => newDocForm("") }, + { label: t("wiki.newHtml"), run: () => newHtmlForm("") }, { label: t("wiki.newFolder"), run: () => newFolderForm("") }, ]); }); @@ -363,9 +392,15 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie // ---------- Anlege-Formular (Ordner/Dokument) ---------- function newDocForm(parent: string) { - openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) => - cb.actions.createDoc(parent, v), - ); + openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) => { + void cb.actions.createDoc(parent, v).then(openNew); + }); + } + + function newHtmlForm(parent: string) { + openForm(t("wiki.newHtml"), t("wiki.docName"), "", (v) => { + void cb.actions.createHtml(parent, v).then(openNew); + }); } function newFolderForm(parent: string) { @@ -533,7 +568,10 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie cb.readDoc(doc.id).then( (text) => { if (selected !== doc.id) return; - body.innerHTML = renderMarkdown(text); + // HTML-Notizen sind bereits Markup; Markdown läuft durch den + // Renderer. Beides stammt aus dem eigenen Archiv und geht durch + // dieselbe Wikilink-Verdrahtung. + body.innerHTML = doc.kind === "html" ? text : renderMarkdown(text); linkWikiRefs(body, followWikiLink); }, (e) => { @@ -554,6 +592,33 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie const err = document.createElement("div"); err.className = "wiki-note-error"; err.hidden = true; + const fail = (e: unknown) => { + err.hidden = false; + err.textContent = String(e); + }; + + // HTML-Notizen: WYSIWYG auf ProseMirror, das Format bleibt HTML. + if (doc.kind === "html") { + let editor: { html(): string; destroy(): void } | null = null; + cb.readDoc(doc.id).then((text) => { + if (selected !== doc.id || !editing) return; + const ed = initHtmlEditor(text); + editor = ed; + box.append(ed.el); + ed.focus(); + }, fail); + return { + el: box, + save: () => { + if (!editor) return; + cb.writeDoc(doc.id, editor.html()).then(() => { + editor?.destroy(); + editing = false; + renderMain(); + }, fail); + }, + }; + } const split = document.createElement("div"); split.className = "wiki-edit-split"; const area = document.createElement("textarea"); @@ -562,32 +627,21 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie const preview = document.createElement("div"); preview.className = "wiki-note-body wiki-edit-preview"; const draw = () => { - preview.innerHTML = renderMarkdown(area.value); + preview.innerHTML = + doc.kind === "html" ? area.value : renderMarkdown(area.value); linkWikiRefs(preview, followWikiLink); }; - cb.readDoc(doc.id).then( - (text) => { - area.value = text; - draw(); - area.focus(); - }, - (e) => { - err.hidden = false; - err.textContent = String(e); - }, - ); + cb.readDoc(doc.id).then((text) => { + area.value = text; + draw(); + area.focus(); + }, fail); area.addEventListener("input", draw); const save = () => { - cb.writeDoc(doc.id, area.value).then( - () => { - editing = false; - renderMain(); - }, - (e) => { - err.hidden = false; - err.textContent = String(e); - }, - ); + cb.writeDoc(doc.id, area.value).then(() => { + editing = false; + renderMain(); + }, fail); }; area.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "s") { @@ -801,6 +855,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie selected = ""; } render(); + applyPendingEdit(); }, empty: () => !loaded, };