HTML-Notizen mit WYSIWYG auf ProseMirror
Zweiter Notiz-Typ neben Markdown. Beide tragen dieselben Angaben, nur an ihrer jeweils üblichen Stelle: Markdown im Frontmatter, HTML in <title> und <meta name=…>. - domain/archive_html.rs: Gerüst, Metadaten, Titel setzen, Rumpf lesen und ersetzen, Text ohne Markup für den Suchindex. - Der Scan nimmt .html auf; Doc und WikiDocEntry führen `kind` (md|html). doc_path, set_title, write_body, ensure_ids und die Knotentext-Regel arbeiten typbewusst; ein Knotentext darf .md oder .html sein. - archive_create_html legt eine leere HTML-Notiz an; Anlegen schreibt nicht mehr in den Entwurfs-Puffer, sondern liefert die ID der neuen Notiz — die Ansicht wählt sie aus und öffnet den Editor. - Anzeige: HTML-Notizen erscheinen als Markup statt durch den Markdown-Renderer, mit klickbaren Wikilinks; eigenes Symbol im Baum. - Editor: html-editor.ts auf ProseMirror (MIT, ohne Anbieter-Stufe). DOMParser liest den Rumpf, DOMSerializer schreibt ihn zurück — gespeichert wird, was der Editor zeigt. Werkzeugleiste mit Überschriften, Auszeichnung, Listen, Ein-/Ausrücken, Tabellen und Links; Tastenkürzel für fett, kursiv, Rückgängig und Zellenwechsel. Markdown behält den Rohtext-Editor mit Live-Vorschau. - CSP: style-src erlaubt 'unsafe-inline', sonst blockiert die Richtlinie die Stil-Attribute in HTML-Notizen und alles erscheint unformatiert. script-src bleibt streng.
This commit is contained in:
@@ -74,6 +74,7 @@ fn main() {
|
||||
"archive_delete",
|
||||
"archive_create_folder",
|
||||
"archive_create_doc",
|
||||
"archive_create_html",
|
||||
"open_panel_window",
|
||||
])),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -319,6 +319,7 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
||||
terminal::archive_delete,
|
||||
terminal::archive_create_folder,
|
||||
terminal::archive_create_doc,
|
||||
terminal::archive_create_html,
|
||||
terminal::open_panel_window
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 — `<title>` 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>{}</title>\n</head>\n<body>\n</body>\n</html>\n",
|
||||
uuid::Uuid::new_v4(),
|
||||
escape(project),
|
||||
escape(title),
|
||||
)
|
||||
}
|
||||
|
||||
/// Metadaten einer HTML-Notiz: `<title>` 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>", "</title>") {
|
||||
map.insert("title".to_string(), unescape(title.trim()));
|
||||
}
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find("<meta ") {
|
||||
rest = &rest[start + 6..];
|
||||
let Some(end) = 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 `<title>`.
|
||||
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("</title>").ok_or("kein <title> 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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user