From b127269e89c305b0aabb05c1396e6f65ca047eb8 Mon Sep 17 00:00:00 2001 From: marcusH Date: Sun, 19 Jul 2026 23:04:17 +0200 Subject: [PATCH] =?UTF-8?q?Bugfix-Runde=20Panel/Archiv:=20Datenverlust,=20?= =?UTF-8?q?Suche,=20L=C3=B6schen,=20Markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProjectConfig reicht unbekannte Keys durch (serde flatten). Bisher verlor jeder read-modify-write die Felder neuerer Versionen; so ging archiveHome bei der Pool-Zuweisung durch einen älteren Build verloren. - Archiv-Suche: Nutzereingabe wird in einen FTS5-Ausdruck übersetzt, der nicht scheitern kann. ai-control, C++ oder eine halbe Phrase warfen bisher einen Syntaxfehler — bei der Live-Suche in jedem Tippzwischenstand. - commands_delete: Befehlstext als Identität, Indizes werden geprüft. Bisher Panik oder Löschen der falschen Kachel, wenn die Panel-Indizes der Datei hinterherhingen. - Archivieren überschreibt keine Datei mehr: Der Stempel hat Minutenauflösung, bei Kollision wird -2, -3, ... angehängt. - archive_panel prüft erst auf leeres Panel, dann wird das Archiv-Home gesetzt; bisher blieb ein umkonfiguriertes Home trotz Meldung "nicht archiviert". - Kaputte .claude.json eines Pools wird nicht mehr durch eine frische ersetzt; nur eine fehlende Datei rechtfertigt das. - Archiv-Formular leert beim Schließen — sonst galten Ordner und Schlagwörter des vorigen Dokuments unbemerkt fürs nächste. - Markdown fürs Panel entschärft (src/markdown.ts): rohes HTML wird als Text ausgegeben, Link- und Bildziele auf harmlose Schemata beschränkt. Das Panel teilt sich den Webview mit dem Terminal, dessen term_write in die PTY schreibt; ohne CSP war das ein Weg von Fremdinhalt zu Befehlsausführung. Regressionstests je Fix: 73 Rust-Tests, 35 Frontend-Tests. --- src-tauri/src/domain/archive.rs | 59 ++++++++++++++--- src-tauri/src/domain/archive_search.rs | 87 ++++++++++++++++++++++++- src-tauri/src/domain/pool.rs | 17 +++-- src-tauri/src/domain/project.rs | 37 ++++++++++- src-tauri/src/terminal.rs | 20 +++++- src/archive-form.test.ts | 20 ++++++ src/archive-form.ts | 7 ++ src/commands-view.test.ts | 6 +- src/commands-view.ts | 4 +- src/markdown.test.ts | Bin 0 -> 2561 bytes src/markdown.ts | 64 ++++++++++++++++++ src/panel-view.ts | 4 +- src/panel-wiring.ts | 5 +- src/wiki-view.ts | 4 +- 14 files changed, 306 insertions(+), 28 deletions(-) create mode 100644 src/markdown.test.ts create mode 100644 src/markdown.ts diff --git a/src-tauri/src/domain/archive.rs b/src-tauri/src/domain/archive.rs index 6d15530..c80648c 100644 --- a/src-tauri/src/domain/archive.rs +++ b/src-tauri/src/domain/archive.rs @@ -145,18 +145,23 @@ pub(crate) fn archive_panel_content( dir_override: Option<&str>, meta: &ArchiveMeta, ) -> Result { - if let Some(d) = dir_override { - set_project_archive_home(project, d)?; - } - let home = require_archive_home(project)?; - let dir = match &meta.folder { - Some(f) => home.join(check_folder(f)?), - None => home, - }; + // Erst prüfen, dann konfigurieren: `set_project_archive_home` legt Ordner an + // und trägt eine Berechtigung in die settings.json des Projekts ein. Käme das + // vor der Leer-Prüfung, hinterließe ein Archivieren mit leerem Panel die + // Meldung „nicht archiviert“ — und ein dauerhaft umgestelltes Archiv-Home. let text = fs::read_to_string(panel_file(project)).unwrap_or_default(); if text.trim().is_empty() { return Err("Panel ist leer — nichts zu archivieren".into()); } + let folder = meta.folder.as_deref().map(check_folder).transpose()?; + if let Some(d) = dir_override { + set_project_archive_home(project, d)?; + } + let home = require_archive_home(project)?; + let dir = match folder { + Some(f) => home.join(f), + None => home, + }; fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; let secs = SystemTime::now() @@ -165,12 +170,29 @@ pub(crate) fn archive_panel_content( .as_secs(); let (stamp, iso) = utc_stamp(secs); let title = first_line(&text); - let path = dir.join(format!("{stamp}-{}.md", slugify(&title))); + let path = free_path(&dir, &stamp, &slugify(&title)); let doc = format!("{}{}\n", frontmatter(&title, project, &iso, meta), text.trim_end()); fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?; Ok(path) } +/// Freier Dateiname für das Archiv-Dokument. +/// +/// Der Zeitstempel hat Minutenauflösung; zweimal Archivieren innerhalb einer +/// Minute mit derselben Titelzeile ergäbe denselben Namen. Da mit `fs::write` +/// geschrieben wird, wäre das stiller Datenverlust — ausgerechnet im Archiv, +/// das die dauerhafte Ablage ist. Darum bei Kollision `-2`, `-3`, … anhängen. +fn free_path(dir: &std::path::Path, stamp: &str, slug: &str) -> PathBuf { + let first = dir.join(format!("{stamp}-{slug}.md")); + if !first.exists() { + return first; + } + (2..) + .map(|n| dir.join(format!("{stamp}-{slug}-{n}.md"))) + .find(|p| !p.exists()) + .expect("unendlicher Zahlenraum") +} + /// Unterordner-Pfad: relativ, nur normale Komponenten (kein `..`, kein Root). fn check_folder(folder: &str) -> Result<&std::path::Path, String> { let p = std::path::Path::new(folder); @@ -347,6 +369,25 @@ mod tests { assert_eq!(iso, "2026-07-11T11:14:15Z"); } + /// Zweimal Archivieren in derselben Minute mit gleichem Titel darf die erste + /// Datei nicht überschreiben — der Stempel hat nur Minutenauflösung. + #[test] + fn gleicher_stempel_und_titel_kollidiert_nicht() { + let dir = crate::domain::testutil::tmp_paths().home.join("archiv"); + fs::create_dir_all(&dir).unwrap(); + let erste = free_path(&dir, "2026-07-19_2118", "notiz"); + assert_eq!(erste.file_name().unwrap(), "2026-07-19_2118-notiz.md"); + fs::write(&erste, "alt").unwrap(); + + let zweite = free_path(&dir, "2026-07-19_2118", "notiz"); + assert_eq!(zweite.file_name().unwrap(), "2026-07-19_2118-notiz-2.md"); + fs::write(&zweite, "neu").unwrap(); + + assert_eq!(fs::read_to_string(&erste).unwrap(), "alt"); + let dritte = free_path(&dir, "2026-07-19_2118", "notiz"); + assert_eq!(dritte.file_name().unwrap(), "2026-07-19_2118-notiz-3.md"); + } + #[test] fn civil_from_days_referenz() { assert_eq!(civil_from_days(0), (1970, 1, 1)); diff --git a/src-tauri/src/domain/archive_search.rs b/src-tauri/src/domain/archive_search.rs index 3dce00e..6616e68 100644 --- a/src-tauri/src/domain/archive_search.rs +++ b/src-tauri/src/domain/archive_search.rs @@ -28,6 +28,12 @@ pub(crate) fn search( tag: Option<&str>, limit: usize, ) -> Result, String> { + // Eingabe ohne jedes indexierbare Zeichen (`!?`, ein einzelnes `"`) findet + // nichts — das ist kein Fehler, sondern ein leeres Ergebnis. Nur die von + // vornherein leere Anfrage ohne Tag bleibt ein Fehler. + if !query.trim().is_empty() && sanitize_query(query).is_empty() && tag.is_none() { + return Ok(Vec::new()); + } let conn = build_index(home)?; let expr = match_expr(query, tag)?; let mut stmt = conn @@ -46,16 +52,64 @@ pub(crate) fn search( /// MATCH-Ausdruck aus Query und optionalem Tag-Filter. fn match_expr(query: &str, tag: Option<&str>) -> Result { - let q = query.trim(); + let q = sanitize_query(query); let t = tag.map(|t| format!("tags:\"{}\"", t.replace('"', ""))); match (q.is_empty(), t) { (false, Some(t)) => Ok(format!("({q}) AND {t}")), - (false, None) => Ok(q.to_string()), + (false, None) => Ok(q), (true, Some(t)) => Ok(t), (true, None) => Err("leere Suchanfrage".into()), } } +/// Übersetzt die Nutzereingabe in einen FTS5-Ausdruck, der nicht scheitern kann. +/// +/// Roh durchgereicht ist fast jede natürliche Eingabe ein Syntaxfehler: `ai-control` +/// liest FTS5 als Spaltenfilter (`no such column: control`), `C++` und eine offene +/// Klammer brechen den Parser. Die Live-Suche schickt zudem jeden Zwischenstand beim +/// Tippen ab, also auch das halbe `"Phrase`. Darum wird jedes Wort als Phrase +/// gequotet — die Tokenizer-Regeln bleiben dieselben, nur die Operatorzeichen +/// verlieren ihre Sonderbedeutung. Erhalten bleiben die zwei Formen, die Nutzer +/// bewusst tippen: "Phrasen in Anführungszeichen" und Präfix*. +fn sanitize_query(query: &str) -> String { + let mut out: Vec = Vec::new(); + let mut chars = query.chars().peekable(); + while let Some(c) = chars.next() { + if c.is_whitespace() { + continue; + } + if c == '"' { + // Phrase bis zum schließenden Anführungszeichen — fehlt es (Tippzwischen- + // stand), gilt der Rest der Eingabe als Phrase. + let phrase: String = chars.by_ref().take_while(|&c| c != '"').collect(); + push_term(&mut out, &phrase, false); + continue; + } + let mut term: String = c.to_string(); + while let Some(&n) = chars.peek() { + if n.is_whitespace() { + break; + } + term.push(n); + chars.next(); + } + let prefix = term.ends_with('*'); + push_term(&mut out, term.trim_end_matches('*'), prefix); + } + out.join(" ") +} + +/// Hängt `term` als gequotete Phrase an, sofern überhaupt etwas Indexierbares +/// darin steht. Reine Satzzeichen ergäben die leere Phrase `""` — für FTS5 ein +/// Syntaxfehler. +fn push_term(out: &mut Vec, term: &str, prefix: bool) { + if !term.chars().any(char::is_alphanumeric) { + return; + } + let quoted = term.replace('"', ""); + out.push(if prefix { format!("\"{quoted}\"*") } else { format!("\"{quoted}\"") }); +} + /// Baut den FTS5-Index in-memory aus dem Archiv-Baum. fn build_index(home: &Path) -> Result { let conn = Connection::open_in_memory().map_err(|e| e.to_string())?; @@ -136,4 +190,33 @@ mod tests { let home = archiv(); assert!(search(&home, " ", None, 10).is_err()); } + + /// Eingaben, die roh durchgereicht einen FTS5-Syntaxfehler warfen. Die + /// Bindestrich-Fälle sind die wichtigsten: Archiv-Dokumente heißen selbst so. + #[test] + fn sonderzeichen_werfen_keinen_syntaxfehler() { + let home = archiv(); + for q in ["ai-control", "adr-log", "TODO: fix", "C++", "wiki (", "\"halbe Phrase", "!?"] { + assert!(search(&home, q, None, 10).is_ok(), "Suche scheiterte an „{q}“"); + } + } + + #[test] + fn bindestrich_wort_findet_dokument() { + let home = archiv(); + let hits = search(&home, "adr-logging", None, 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].title, "ADR Logging"); + } + + /// Die Live-Suche schickt jeden Tippzwischenstand ab — keiner darf scheitern. + #[test] + fn tippzwischenstaende_scheitern_nie() { + let home = archiv(); + let ziel = "\"Strukturiertes Logging\""; + for n in 1..=ziel.chars().count() { + let teil: String = ziel.chars().take(n).collect(); + assert!(search(&home, &teil, None, 10).is_ok(), "Suche scheiterte an „{teil}“"); + } + } } diff --git a/src-tauri/src/domain/pool.rs b/src-tauri/src/domain/pool.rs index 12e0029..5c87c36 100644 --- a/src-tauri/src/domain/pool.rs +++ b/src-tauri/src/domain/pool.rs @@ -257,10 +257,19 @@ fn register_mcp_server(pool_dir: &std::path::Path) { "alwaysLoad": true, }); let cfg = pool_dir.join(".claude.json"); - let mut v = fs::read_to_string(&cfg) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .unwrap_or_else(|| serde_json::json!({})); + // Nur eine fehlende Datei rechtfertigt ein frisches Objekt. Ist sie da, aber + // unlesbar oder kaputtes JSON (abgebrochener Schreibvorgang, volle Platte), + // wird sie in Ruhe gelassen: Sie ist claudes Live-State mit Projekt-Zustand + // und MCP-Einträgen Dritter. Sie hier zu ersetzen macht aus einem + // reparierbaren Schaden einen endgültigen. + let mut v = match fs::read_to_string(&cfg) { + Ok(s) => match serde_json::from_str::(&s) { + Ok(v) => v, + Err(_) => return, + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}), + Err(_) => return, + }; let Some(obj) = v.as_object_mut() else { return }; let Some(servers) = obj .entry("mcpServers") diff --git a/src-tauri/src/domain/project.rs b/src-tauri/src/domain/project.rs index 5319573..7a7fd46 100644 --- a/src-tauri/src/domain/project.rs +++ b/src-tauri/src/domain/project.rs @@ -39,6 +39,12 @@ pub(crate) struct ProjectConfig { skip_serializing_if = "Option::is_none" )] pub(crate) archive_home: Option, + /// Alle Keys, die dieser Build nicht kennt — unverändert durchgereicht. + /// Ohne das verliert jeder read-modify-write (Pool-Zuweisung, Terminal- + /// Einstellungen) still die Felder neuerer Versionen: serde verwirft + /// Unbekanntes beim Deserialisieren, und geschrieben wird die ganze Datei. + #[serde(flatten)] + pub(crate) rest: serde_json::Map, } #[derive(Serialize, Deserialize, Default, Clone)] @@ -226,7 +232,11 @@ pub(crate) fn create_project_full_in( reg.insert(name.to_string(), dir.clone()); save_registry(paths, ®)?; - let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_home: None }; + let cfg = ProjectConfig { + pool: pool.map(str::to_string), + terminal, + ..ProjectConfig::default() + }; if cfg.pool.is_some() || !cfg.terminal.is_empty() { write_project_config_in(paths, name, &cfg)?; } @@ -489,6 +499,31 @@ mod tests { use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths}; use crate::domain::todo::TODO_FILE; + /// Der read-modify-write darf keine Keys verlieren, die dieser Build nicht + /// kennt. Genau daran ging `archiveHome` verloren: Ein älterer Build ohne das + /// Feld hat bei der Pool-Zuweisung die ganze Datei neu geschrieben. + #[test] + fn pool_zuweisen_erhaelt_fremde_keys() { + let p = tmp_paths(); + let pool = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + create_project(&p, "proj").unwrap(); + let cfg_path = project_config_path(&p, "proj").unwrap(); + fs::write( + &cfg_path, + r#"{"archiveHome":"~/archiv","zukunftsfeld":{"a":1},"terminal":{"theme":"monokai"}}"#, + ) + .unwrap(); + + assign_pool_in(&p, "proj", &pool).unwrap(); + + let raw = fs::read_to_string(&cfg_path).unwrap(); + let v: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(v["archiveHome"], "~/archiv"); + assert_eq!(v["zukunftsfeld"]["a"], 1); + assert_eq!(v["terminal"]["theme"], "monokai"); + assert_eq!(v["pool"], serde_json::Value::String(pool)); + } + #[test] fn projekt_wizard_scaffold() { let p = tmp_paths(); diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 5d41782..0072c24 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -249,8 +249,19 @@ pub fn commands_read(project: String) -> String { /// Panel). `line` ist der Index der nicht-leeren JSONL-Zeile, `entry` der /// Index im commands-Array des Records; ein leer gewordener Record fällt mit /// weg. Der Watcher meldet den neuen Stand als `commands-update`. +/// +/// `cmd` ist der angezeigte Befehlstext und dient als Identität: Die Indizes +/// des Panels sind der Datei um bis zu ein Watcher-Intervall hinterher, und +/// ein leer gewordener Record verschiebt alle folgenden. Ohne Abgleich löscht +/// ein Doppelklick — oder das zweite Fenster auf derselben Liste — die falsche +/// Kachel oder greift ins Leere. #[tauri::command] -pub fn commands_delete(project: String, line: usize, entry: usize) -> Result<(), String> { +pub fn commands_delete( + project: String, + line: usize, + entry: usize, + cmd: String, +) -> Result<(), String> { let path = commands_file(&project); let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let mut records: Vec = text @@ -258,9 +269,14 @@ pub fn commands_delete(project: String, line: usize, entry: usize) -> Result<(), .filter(|l| !l.trim().is_empty()) .map(|l| serde_json::from_str(l).map_err(|e| e.to_string())) .collect::>()?; - let cmds = records[line]["commands"] + let cmds = records + .get_mut(line) + .ok_or("Befehl bereits entfernt")?["commands"] .as_array_mut() .ok_or("Record ohne commands")?; + if cmds.get(entry).map(|c| c["cmd"].as_str() != Some(cmd.as_str())).unwrap_or(true) { + return Err("Befehl bereits entfernt".into()); + } cmds.remove(entry); if cmds.is_empty() { records.remove(line); diff --git a/src/archive-form.test.ts b/src/archive-form.test.ts index 5dcca72..940ab31 100644 --- a/src/archive-form.test.ts +++ b/src/archive-form.test.ts @@ -40,6 +40,26 @@ describe("initArchiveForm", () => { expect(root.hidden).toBe(true); }); + /// Sonst gelten Ordner und Schlagwörter des vorigen Dokuments unbemerkt für + /// das nächste — wer direkt Enter drückt, archiviert es falsch einsortiert. + it("startet nach dem Archivieren wieder leer", () => { + const { form, onSubmit, root, inputs } = setup(); + form.toggle(); + inputs[0].value = "konzepte/panel"; + inputs[1].value = "Beschreibung A"; + inputs[2].value = "panel, wiki"; + root.querySelector(".archive-form-submit")!.click(); + + form.toggle(); + expect(inputs.map((i) => i.value)).toEqual(["", "", ""]); + root.querySelector(".archive-form-submit")!.click(); + expect(onSubmit).toHaveBeenLastCalledWith({ + folder: undefined, + description: undefined, + tags: [], + }); + }); + it("Enter schickt ab, Escape schließt ohne Abschicken", () => { const { form, onSubmit, root } = setup(); form.toggle(); diff --git a/src/archive-form.ts b/src/archive-form.ts index b4fd210..cf4db0b 100644 --- a/src/archive-form.ts +++ b/src/archive-form.ts @@ -40,8 +40,15 @@ export function initArchiveForm( .map((t) => t.trim()) .filter(Boolean), }); + // Beim Schließen leeren: Das Formular gehört zum Fenster, nicht zum Dokument. + // Stehengebliebene Werte würden beim nächsten Öffnen unbemerkt für ein anderes + // Dokument gelten — wer direkt Enter drückt, archiviert es mit dem Ordner und + // den Schlagwörtern des vorigen. const close = () => { form.hidden = true; + folder.value = ""; + desc.value = ""; + tags.value = ""; }; const open = () => { const r = anchor.getBoundingClientRect(); diff --git a/src/commands-view.test.ts b/src/commands-view.test.ts index 9df9045..79a1875 100644 --- a/src/commands-view.test.ts +++ b/src/commands-view.test.ts @@ -94,12 +94,14 @@ describe("initCommandsView", () => { expect(view.empty()).toBe(true); }); - it("meldet Löschen mit Record- und Eintrags-Index", () => { + /// Der Befehlstext geht mit, weil die Indizes der Datei hinterherhinken + /// können; das Backend gleicht damit ab, bevor es löscht. + it("meldet Löschen mit Record-Index, Eintrags-Index und Befehl", () => { const onDelete = vi.fn(); const view = initCommandsView(document.getElementById("c")!, onDelete); view.set(jsonl); const dels = document.querySelectorAll(".cmd-del"); dels[1].click(); // zweite Kachel im Record 1 - expect(onDelete).toHaveBeenCalledWith(1, 1); + expect(onDelete).toHaveBeenCalledWith(1, 1, "pwd"); }); }); diff --git a/src/commands-view.ts b/src/commands-view.ts index 8857c57..bb8496c 100644 --- a/src/commands-view.ts +++ b/src/commands-view.ts @@ -72,7 +72,7 @@ function deleteBtn(onClick: () => void): HTMLButtonElement { export function initCommandsView( container: HTMLElement, - onDelete: (line: number, entry: number) => void, + onDelete: (line: number, entry: number, cmd: string) => void, ): CommandsView { let count = 0; @@ -128,7 +128,7 @@ export function initCommandsView( note.textContent = entry.note; body.append(note); } - tile.append(body, copyBtn(() => entry.cmd), deleteBtn(() => onDelete(i, j))); + tile.append(body, copyBtn(() => entry.cmd), deleteBtn(() => onDelete(i, j, entry.cmd))); block.append(tile); }); container.append(block); diff --git a/src/markdown.test.ts b/src/markdown.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..68975427090120b270cb8afc81dbab2a989f00f4 GIT binary patch literal 2561 zcmb_e&2HO95boJeG1(R|mC6)ywIu_#YcwsKAgGNNh;6`2azt*m+*NmnvTDOXA0qc$ z@))`KP5LCA<&R3qYLf!J2qHNp~=v_mTs2WTeAv`M`inpgLg)w5qyUZjw+wagflp0a>%hDnDc9847yhr zuh00Ohquz89zrHPs1iKt1P%Ox$wJ{R+$yZIg|8RrN@b)5tuUt;-l()7&kGmU@UNSK zto-!xQ{fWnIFT~(PS;%|)(aOyhz2k@g!>)fhP%%M#=25!^w71q7737A;|U5XHB4YV zXC%Ic3kFPInJaUFOM;T2d~u9qO=a>Iu^1fiZqNc9g(QQR?1?obm5Ic-c=_27EDyVc z;}JhJ3m!N#rw+KE8uDp@Dr2HRajho6Gz6PKrV!pCKcbl-5yFjSBAj(Mu&{1s%aY*F zKi-4XzD>s3kaJ?n>UKgl&p8U|N^O0J9fG$(5GLeLkO@3hrg;2lawd@(0OZZFOLwZV zMAP8%N}5b_W`6d>%7|Mca+MG`TQCK+M7Wq{cP1A+Nh07+cNJ@g3!6J6h0McNnlO-* z*|Z9~#;HR|*wzq-cX?KB0AYtjZj`OD9Wc0hi$Ngz7~fP{M^EC3wGu?YMXu|iUKupg@*9L2@JbnBHzn44S}+b;84O_O zm;_oG?;5(Tko<#kYktSU3FdUz^)?>0FW?qg*KP$@Z4|A3jiogWw@bFh_O$+7(?JvZ zJg0*-6^AYpZPOXSuW})qsQtY&WoCZJBID*Y_nxqLSmY4i2L%61yO%Sq(%E&?%ilFU z*jo8Um%Vjc!+VX^JAFZa4Vl3)&IwsScg;bCijVR4&OUx%)*Y&Zon)iasdW&wzT|KU zb_$o`NBOfnPk&NxX}{}?mx)mLS1qFb_kE<7;(Ac~V(_Ye^Shf%hVYwvE^GQQxM^j* zW^b&FGkc5fM0$+)416BOhjq2LI>;o+{k>zQ*=mNMA3yqFY>idJJHUW>XfRKIgb1y|8`mG;h6vM3D;I;lMb9i*29y!gCT72zIBg+Xzuq%BV5XI zt}(VQA0_eJo#YJ4tQsaAfM4$-45L=Gewbk>)!= zCW9iw^gSY;;z`xc6ISaqabdD-K;G!(e=^>nuPv>BkXKiNnJR7N(Js)f=n{j@j+;Ux z4}hnREXCkiGm67SB{cUGgGBRTBly7-lHG>y5e+1J9S&?oy=la%4dWO1kn-*1@dNu( zIJjW~_R3r>4)EPor3|Au48GN&k#Qk?w*^L`x44@j(UeCbt`89z=SaO}HG9p1llFG8 J+Y>+#!CzWYPuBnd literal 0 HcmV?d00001 diff --git a/src/markdown.ts b/src/markdown.ts new file mode 100644 index 0000000..304a024 --- /dev/null +++ b/src/markdown.ts @@ -0,0 +1,64 @@ +/// Markdown-Rendering fürs Panel — eine gemeinsame, entschärfte marked-Instanz +/// für Dokument-Tab und Wiki. +/// +/// Warum überhaupt entschärft: marked reicht seit v5 rohes HTML unverändert +/// durch (die alte `sanitize`-Option gibt es nicht mehr), und das Ergebnis geht +/// per innerHTML ins Panel. Das Panel liegt im selben Webview wie das Terminal, +/// dessen `term_write`-Command die PTY über das Fensterlabel adressiert — +/// eingeschleustes Skript könnte also Eingaben in die laufende Shell schreiben. +/// Archiv-Dokumente sind normalerweise die eigenen, aber sobald eines von außen +/// kommt (Git-Sync, geteiltes Verzeichnis, eingefügter Fremdtext), wird aus +/// Markdown-Anzeige Befehlsausführung. Eine CSP gibt es nicht als zweite +/// Schranke (tauri.conf.json: `csp: null`). +/// +/// Der Ansatz kommt ohne Sanitizer-Abhängigkeit aus, weil er an der Quelle +/// ansetzt statt am fertigen HTML: Rohes HTML wird als Text ausgegeben, und +/// Link- wie Bildziele müssen ein harmloses Schema haben. + +import { marked, type Tokens } from "marked"; + +/// Schemata, die im Panel etwas anzuzeigen haben. Alles andere — allen voran +/// `javascript:`, aber auch `data:` (SVG mit Skript) und `file:` — fliegt raus. +const OK_SCHEME = /^(https?:|mailto:|#|\/|\.{0,2}\/)/i; + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/// Leerer Link statt gefährlichem Ziel — der Text bleibt sichtbar, der Klick +/// tut nichts. +function safeHref(href: string): string { + const h = href.trim(); + // Steuerzeichen entfernen: `java\nscript:` ist sonst ein Umgehungsweg. + const clean = h.replace(/[\u0000-\u001f\u007f]/g, ""); + return OK_SCHEME.test(clean) ? clean : ""; +} + +const renderer = new marked.Renderer(); + +// Block- und Inline-HTML laufen beide hier durch: als Text anzeigen, nicht als +// Markup interpretieren. +renderer.html = ({ raw }: Tokens.HTML | Tokens.Tag) => escapeHtml(raw); + +renderer.link = function ({ href, title, tokens }: Tokens.Link) { + const text = this.parser.parseInline(tokens); + const safe = safeHref(href); + const t = title ? ` title="${escapeHtml(title)}"` : ""; + return safe ? `${text}` : `${text}`; +}; + +renderer.image = ({ href, title, text }: Tokens.Image) => { + const safe = safeHref(href); + if (!safe) return escapeHtml(text); + const t = title ? ` title="${escapeHtml(title)}"` : ""; + return `${escapeHtml(text)}`; +}; + +/// Markdown zu HTML, sicher genug für innerHTML. +export function renderMarkdown(src: string): string { + return marked.parse(src, { async: false, renderer }); +} diff --git a/src/panel-view.ts b/src/panel-view.ts index 4971e12..e968597 100644 --- a/src/panel-view.ts +++ b/src/panel-view.ts @@ -1,4 +1,4 @@ -import { marked } from "marked"; +import { renderMarkdown } from "./markdown"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { flash } from "./commands-view"; @@ -123,7 +123,7 @@ export function initPanelView(opts: { function draw() { if (rendered) { opts.content.className = "md"; - opts.content.innerHTML = marked.parse(rawText, { async: false }); + opts.content.innerHTML = renderMarkdown(rawText); if (opts.onWikiLink) linkWikiRefs(opts.content, opts.onWikiLink); } else { opts.content.className = "raw"; diff --git a/src/panel-wiring.ts b/src/panel-wiring.ts index ba6d04d..e5489a7 100644 --- a/src/panel-wiring.ts +++ b/src/panel-wiring.ts @@ -75,8 +75,9 @@ export async function wirePanel( onCommit: (text) => invoke("panel_set", { project, text }), onWikiLink: openWiki, }); - const cmdView = initCommandsView(document.getElementById("commands-content")!, (line, entry) => - invoke("commands_delete", { project, line, entry }), + const cmdView = initCommandsView( + document.getElementById("commands-content")!, + (line, entry, cmd) => invoke("commands_delete", { project, line, entry, cmd }), ); // Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und // archivierbar); der Sprung ins Wiki geht von dort aus. diff --git a/src/wiki-view.ts b/src/wiki-view.ts index 645c73b..4a026c4 100644 --- a/src/wiki-view.ts +++ b/src/wiki-view.ts @@ -5,7 +5,7 @@ /// Fremdtext und gehen nie durch innerHTML; nur der Markdown-Rumpf läuft wie /// im Entwurf durch marked. -import { marked } from "marked"; +import { renderMarkdown } from "./markdown"; import { linkWikiRefs } from "./panel-view"; interface DocEntry { @@ -195,7 +195,7 @@ export function initWikiView( } const body = document.createElement("div"); body.className = "wiki-body"; - body.innerHTML = marked.parse(d.markdown, { async: false }); + body.innerHTML = renderMarkdown(d.markdown); linkWikiRefs(body, onLink); container.append(body); if (d.backlinks.length) {