Bugfix-Runde Panel/Archiv: Datenverlust, Suche, Löschen, Markdown
- 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.
This commit is contained in:
@@ -145,18 +145,23 @@ pub(crate) fn archive_panel_content(
|
|||||||
dir_override: Option<&str>,
|
dir_override: Option<&str>,
|
||||||
meta: &ArchiveMeta,
|
meta: &ArchiveMeta,
|
||||||
) -> Result<PathBuf, String> {
|
) -> Result<PathBuf, String> {
|
||||||
if let Some(d) = dir_override {
|
// Erst prüfen, dann konfigurieren: `set_project_archive_home` legt Ordner an
|
||||||
set_project_archive_home(project, d)?;
|
// 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
|
||||||
let home = require_archive_home(project)?;
|
// Meldung „nicht archiviert“ — und ein dauerhaft umgestelltes Archiv-Home.
|
||||||
let dir = match &meta.folder {
|
|
||||||
Some(f) => home.join(check_folder(f)?),
|
|
||||||
None => home,
|
|
||||||
};
|
|
||||||
let text = fs::read_to_string(panel_file(project)).unwrap_or_default();
|
let text = fs::read_to_string(panel_file(project)).unwrap_or_default();
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
return Err("Panel ist leer — nichts zu archivieren".into());
|
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()))?;
|
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||||
|
|
||||||
let secs = SystemTime::now()
|
let secs = SystemTime::now()
|
||||||
@@ -165,12 +170,29 @@ pub(crate) fn archive_panel_content(
|
|||||||
.as_secs();
|
.as_secs();
|
||||||
let (stamp, iso) = utc_stamp(secs);
|
let (stamp, iso) = utc_stamp(secs);
|
||||||
let title = first_line(&text);
|
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());
|
let doc = format!("{}{}\n", frontmatter(&title, project, &iso, meta), text.trim_end());
|
||||||
fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
|
fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
Ok(path)
|
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).
|
/// Unterordner-Pfad: relativ, nur normale Komponenten (kein `..`, kein Root).
|
||||||
fn check_folder(folder: &str) -> Result<&std::path::Path, String> {
|
fn check_folder(folder: &str) -> Result<&std::path::Path, String> {
|
||||||
let p = std::path::Path::new(folder);
|
let p = std::path::Path::new(folder);
|
||||||
@@ -347,6 +369,25 @@ mod tests {
|
|||||||
assert_eq!(iso, "2026-07-11T11:14:15Z");
|
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]
|
#[test]
|
||||||
fn civil_from_days_referenz() {
|
fn civil_from_days_referenz() {
|
||||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ pub(crate) fn search(
|
|||||||
tag: Option<&str>,
|
tag: Option<&str>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<Hit>, String> {
|
) -> Result<Vec<Hit>, 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 conn = build_index(home)?;
|
||||||
let expr = match_expr(query, tag)?;
|
let expr = match_expr(query, tag)?;
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
@@ -46,16 +52,64 @@ pub(crate) fn search(
|
|||||||
|
|
||||||
/// MATCH-Ausdruck aus Query und optionalem Tag-Filter.
|
/// MATCH-Ausdruck aus Query und optionalem Tag-Filter.
|
||||||
fn match_expr(query: &str, tag: Option<&str>) -> Result<String, String> {
|
fn match_expr(query: &str, tag: Option<&str>) -> Result<String, String> {
|
||||||
let q = query.trim();
|
let q = sanitize_query(query);
|
||||||
let t = tag.map(|t| format!("tags:\"{}\"", t.replace('"', "")));
|
let t = tag.map(|t| format!("tags:\"{}\"", t.replace('"', "")));
|
||||||
match (q.is_empty(), t) {
|
match (q.is_empty(), t) {
|
||||||
(false, Some(t)) => Ok(format!("({q}) AND {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, Some(t)) => Ok(t),
|
||||||
(true, None) => Err("leere Suchanfrage".into()),
|
(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<String> = 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<String>, 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.
|
/// Baut den FTS5-Index in-memory aus dem Archiv-Baum.
|
||||||
fn build_index(home: &Path) -> Result<Connection, String> {
|
fn build_index(home: &Path) -> Result<Connection, String> {
|
||||||
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||||
@@ -136,4 +190,33 @@ mod tests {
|
|||||||
let home = archiv();
|
let home = archiv();
|
||||||
assert!(search(&home, " ", None, 10).is_err());
|
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}“");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,10 +257,19 @@ fn register_mcp_server(pool_dir: &std::path::Path) {
|
|||||||
"alwaysLoad": true,
|
"alwaysLoad": true,
|
||||||
});
|
});
|
||||||
let cfg = pool_dir.join(".claude.json");
|
let cfg = pool_dir.join(".claude.json");
|
||||||
let mut v = fs::read_to_string(&cfg)
|
// Nur eine fehlende Datei rechtfertigt ein frisches Objekt. Ist sie da, aber
|
||||||
.ok()
|
// unlesbar oder kaputtes JSON (abgebrochener Schreibvorgang, volle Platte),
|
||||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
// wird sie in Ruhe gelassen: Sie ist claudes Live-State mit Projekt-Zustand
|
||||||
.unwrap_or_else(|| serde_json::json!({}));
|
// 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::<serde_json::Value>(&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(obj) = v.as_object_mut() else { return };
|
||||||
let Some(servers) = obj
|
let Some(servers) = obj
|
||||||
.entry("mcpServers")
|
.entry("mcpServers")
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ pub(crate) struct ProjectConfig {
|
|||||||
skip_serializing_if = "Option::is_none"
|
skip_serializing_if = "Option::is_none"
|
||||||
)]
|
)]
|
||||||
pub(crate) archive_home: Option<String>,
|
pub(crate) archive_home: Option<String>,
|
||||||
|
/// 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<String, serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||||
@@ -226,7 +232,11 @@ pub(crate) fn create_project_full_in(
|
|||||||
|
|
||||||
reg.insert(name.to_string(), dir.clone());
|
reg.insert(name.to_string(), dir.clone());
|
||||||
save_registry(paths, ®)?;
|
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() {
|
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
|
||||||
write_project_config_in(paths, name, &cfg)?;
|
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::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths};
|
||||||
use crate::domain::todo::TODO_FILE;
|
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]
|
#[test]
|
||||||
fn projekt_wizard_scaffold() {
|
fn projekt_wizard_scaffold() {
|
||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
|
|||||||
@@ -249,8 +249,19 @@ pub fn commands_read(project: String) -> String {
|
|||||||
/// Panel). `line` ist der Index der nicht-leeren JSONL-Zeile, `entry` der
|
/// 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
|
/// Index im commands-Array des Records; ein leer gewordener Record fällt mit
|
||||||
/// weg. Der Watcher meldet den neuen Stand als `commands-update`.
|
/// 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]
|
#[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 path = commands_file(&project);
|
||||||
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
let mut records: Vec<serde_json::Value> = text
|
let mut records: Vec<serde_json::Value> = text
|
||||||
@@ -258,9 +269,14 @@ pub fn commands_delete(project: String, line: usize, entry: usize) -> Result<(),
|
|||||||
.filter(|l| !l.trim().is_empty())
|
.filter(|l| !l.trim().is_empty())
|
||||||
.map(|l| serde_json::from_str(l).map_err(|e| e.to_string()))
|
.map(|l| serde_json::from_str(l).map_err(|e| e.to_string()))
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
let cmds = records[line]["commands"]
|
let cmds = records
|
||||||
|
.get_mut(line)
|
||||||
|
.ok_or("Befehl bereits entfernt")?["commands"]
|
||||||
.as_array_mut()
|
.as_array_mut()
|
||||||
.ok_or("Record ohne commands")?;
|
.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);
|
cmds.remove(entry);
|
||||||
if cmds.is_empty() {
|
if cmds.is_empty() {
|
||||||
records.remove(line);
|
records.remove(line);
|
||||||
|
|||||||
@@ -40,6 +40,26 @@ describe("initArchiveForm", () => {
|
|||||||
expect(root.hidden).toBe(true);
|
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<HTMLElement>(".archive-form-submit")!.click();
|
||||||
|
|
||||||
|
form.toggle();
|
||||||
|
expect(inputs.map((i) => i.value)).toEqual(["", "", ""]);
|
||||||
|
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||||
|
expect(onSubmit).toHaveBeenLastCalledWith({
|
||||||
|
folder: undefined,
|
||||||
|
description: undefined,
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("Enter schickt ab, Escape schließt ohne Abschicken", () => {
|
it("Enter schickt ab, Escape schließt ohne Abschicken", () => {
|
||||||
const { form, onSubmit, root } = setup();
|
const { form, onSubmit, root } = setup();
|
||||||
form.toggle();
|
form.toggle();
|
||||||
|
|||||||
@@ -40,8 +40,15 @@ export function initArchiveForm(
|
|||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter(Boolean),
|
.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 = () => {
|
const close = () => {
|
||||||
form.hidden = true;
|
form.hidden = true;
|
||||||
|
folder.value = "";
|
||||||
|
desc.value = "";
|
||||||
|
tags.value = "";
|
||||||
};
|
};
|
||||||
const open = () => {
|
const open = () => {
|
||||||
const r = anchor.getBoundingClientRect();
|
const r = anchor.getBoundingClientRect();
|
||||||
|
|||||||
@@ -94,12 +94,14 @@ describe("initCommandsView", () => {
|
|||||||
expect(view.empty()).toBe(true);
|
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 onDelete = vi.fn();
|
||||||
const view = initCommandsView(document.getElementById("c")!, onDelete);
|
const view = initCommandsView(document.getElementById("c")!, onDelete);
|
||||||
view.set(jsonl);
|
view.set(jsonl);
|
||||||
const dels = document.querySelectorAll<HTMLElement>(".cmd-del");
|
const dels = document.querySelectorAll<HTMLElement>(".cmd-del");
|
||||||
dels[1].click(); // zweite Kachel im Record 1
|
dels[1].click(); // zweite Kachel im Record 1
|
||||||
expect(onDelete).toHaveBeenCalledWith(1, 1);
|
expect(onDelete).toHaveBeenCalledWith(1, 1, "pwd");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function deleteBtn(onClick: () => void): HTMLButtonElement {
|
|||||||
|
|
||||||
export function initCommandsView(
|
export function initCommandsView(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
onDelete: (line: number, entry: number) => void,
|
onDelete: (line: number, entry: number, cmd: string) => void,
|
||||||
): CommandsView {
|
): CommandsView {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ export function initCommandsView(
|
|||||||
note.textContent = entry.note;
|
note.textContent = entry.note;
|
||||||
body.append(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);
|
block.append(tile);
|
||||||
});
|
});
|
||||||
container.append(block);
|
container.append(block);
|
||||||
|
|||||||
Binary file not shown.
@@ -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, ">")
|
||||||
|
.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 ? `<a href="${escapeHtml(safe)}"${t}>${text}</a>` : `<a${t}>${text}</a>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderer.image = ({ href, title, text }: Tokens.Image) => {
|
||||||
|
const safe = safeHref(href);
|
||||||
|
if (!safe) return escapeHtml(text);
|
||||||
|
const t = title ? ` title="${escapeHtml(title)}"` : "";
|
||||||
|
return `<img src="${escapeHtml(safe)}" alt="${escapeHtml(text)}"${t}>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Markdown zu HTML, sicher genug für innerHTML.
|
||||||
|
export function renderMarkdown(src: string): string {
|
||||||
|
return marked.parse(src, { async: false, renderer });
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { marked } from "marked";
|
import { renderMarkdown } from "./markdown";
|
||||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
import { flash } from "./commands-view";
|
import { flash } from "./commands-view";
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ export function initPanelView(opts: {
|
|||||||
function draw() {
|
function draw() {
|
||||||
if (rendered) {
|
if (rendered) {
|
||||||
opts.content.className = "md";
|
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);
|
if (opts.onWikiLink) linkWikiRefs(opts.content, opts.onWikiLink);
|
||||||
} else {
|
} else {
|
||||||
opts.content.className = "raw";
|
opts.content.className = "raw";
|
||||||
|
|||||||
+3
-2
@@ -75,8 +75,9 @@ export async function wirePanel(
|
|||||||
onCommit: (text) => invoke("panel_set", { project, text }),
|
onCommit: (text) => invoke("panel_set", { project, text }),
|
||||||
onWikiLink: openWiki,
|
onWikiLink: openWiki,
|
||||||
});
|
});
|
||||||
const cmdView = initCommandsView(document.getElementById("commands-content")!, (line, entry) =>
|
const cmdView = initCommandsView(
|
||||||
invoke("commands_delete", { project, line, entry }),
|
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
|
// Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und
|
||||||
// archivierbar); der Sprung ins Wiki geht von dort aus.
|
// archivierbar); der Sprung ins Wiki geht von dort aus.
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
/// Fremdtext und gehen nie durch innerHTML; nur der Markdown-Rumpf läuft wie
|
/// Fremdtext und gehen nie durch innerHTML; nur der Markdown-Rumpf läuft wie
|
||||||
/// im Entwurf durch marked.
|
/// im Entwurf durch marked.
|
||||||
|
|
||||||
import { marked } from "marked";
|
import { renderMarkdown } from "./markdown";
|
||||||
import { linkWikiRefs } from "./panel-view";
|
import { linkWikiRefs } from "./panel-view";
|
||||||
|
|
||||||
interface DocEntry {
|
interface DocEntry {
|
||||||
@@ -195,7 +195,7 @@ export function initWikiView(
|
|||||||
}
|
}
|
||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
body.className = "wiki-body";
|
body.className = "wiki-body";
|
||||||
body.innerHTML = marked.parse(d.markdown, { async: false });
|
body.innerHTML = renderMarkdown(d.markdown);
|
||||||
linkWikiRefs(body, onLink);
|
linkWikiRefs(body, onLink);
|
||||||
container.append(body);
|
container.append(body);
|
||||||
if (d.backlinks.length) {
|
if (d.backlinks.length) {
|
||||||
|
|||||||
Reference in New Issue
Block a user