Aufräumrunde zur Bugfix-Runde: Suche entzerrt, Flatten-Fix vervollständigt

- archive_search: match_expr in search() aufgelöst. sanitize_query lief zweimal
  pro Anfrage, und die Leer-Entscheidung stand an zwei Stellen mit leicht
  abweichender Bedingung.
- Der Tag-Filter geht jetzt durch dieselbe Quoting-Regel wie der Volltext. Ein
  leerer Tag ergab tags:"" und damit einen Syntaxfehler — erreichbar durch das
  angetippte #, den ersten Tastendruck jeder Tag-Suche.
- sanitize_query von 26 auf 12 Zeilen: split('"') und split_whitespace statt
  handgeschriebenem Tokenizer. Gleiches Ergebnis fuer alle Testeingaben.
- TerminalConfig bekommt ebenfalls serde flatten. Der Block wird als Ganzes
  mitgeschrieben, ein terminal.fontSize waere also weiterhin verloren gegangen;
  set_terminal_config_in traegt die unbekannten Keys jetzt herueber.
- markdown.test.ts enthielt ein literales NUL-Byte, wodurch git die Datei als
  binaer behandelte. Der Test erzeugt das Zeichen jetzt selbst.
- Kleineres: commands_delete-Bedingung als direkter Vergleich, eine
  Verschachtelungsebene in pool.rs, zwei redundante Kommentarbloecke.
  commands_delete benennt sich in der Doku als Provisorium.
This commit is contained in:
marcusH
2026-07-19 23:22:25 +02:00
parent b127269e89
commit e245600235
8 changed files with 119 additions and 80 deletions
+5 -1
View File
@@ -118,7 +118,11 @@ pub(crate) fn set_terminal_config(
icon: Option<String>,
title: Option<String>,
) -> Result<(), String> {
set_terminal_config_in(&Paths::real(), &project, TerminalConfig { theme, icon, title })
set_terminal_config_in(
&Paths::real(),
&project,
TerminalConfig { theme, icon, title, ..Default::default() },
)
}
/// Icon eines Projekts als data-URL für die Übersicht (PNG oder SVG).
+62 -57
View File
@@ -28,14 +28,21 @@ pub(crate) fn search(
tag: Option<&str>,
limit: usize,
) -> 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 q = sanitize_query(query);
let t = tag.and_then(|t| quote_phrase(t, false)).map(|t| format!("tags:{t}"));
let expr = match (q.is_empty(), t) {
(false, Some(t)) => format!("({q}) AND {t}"),
(false, None) => q,
(true, Some(t)) => t,
// Von vornherein leere Anfrage ist ein Bedienfehler. Eine Eingabe, die nur
// aus Satzzeichen bestand (`!?`, ein einzelnes `"`, das angetippte `#`),
// findet dagegen schlicht nichts — und spart sich den Index-Aufbau.
(true, None) if query.trim().is_empty() && tag.is_none() => {
return Err("leere Suchanfrage".into())
}
(true, None) => return Ok(Vec::new()),
};
let conn = build_index(home)?;
let expr = match_expr(query, tag)?;
let mut stmt = conn
.prepare(
"SELECT relpath, title, snippet(docs, 5, '**', '**', ' … ', 12) \
@@ -50,18 +57,6 @@ pub(crate) fn search(
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
/// MATCH-Ausdruck aus Query und optionalem Tag-Filter.
fn match_expr(query: &str, tag: Option<&str>) -> Result<String, String> {
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),
(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`
@@ -73,41 +68,34 @@ fn match_expr(query: &str, tag: Option<&str>) -> Result<String, String> {
/// 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;
// An `"` aufteilen: ungerade Segmente standen in Anführungszeichen und
// bleiben als Ganzes eine Phrase, gerade zerfallen in Wörter. Ein fehlendes
// schließendes Anführungszeichen (Tippzwischenstand) fällt damit von selbst
// richtig heraus — der Rest der Eingabe ist das letzte ungerade Segment.
for (i, teil) in query.split('"').enumerate() {
if i % 2 == 1 {
out.extend(quote_phrase(teil, false));
} else {
out.extend(
teil
.split_whitespace()
.filter_map(|w| quote_phrase(w.trim_end_matches('*'), w.ends_with('*'))),
);
}
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) {
/// `term` als gequotete FTS5-Phrase, sofern überhaupt etwas Indexierbares darin
/// steht. Reine Satzzeichen ergäben die leere Phrase `""` — für FTS5 ein
/// Syntaxfehler. Gilt auch für den Tag-Filter: Das angetippte `#` liefert über
/// `panel-wiring.ts` den leeren Tag.
fn quote_phrase(term: &str, prefix: bool) -> Option<String> {
if !term.chars().any(char::is_alphanumeric) {
return;
return None;
}
let quoted = term.replace('"', "");
out.push(if prefix { format!("\"{quoted}\"*") } else { format!("\"{quoted}\"") });
Some(if prefix { format!("\"{quoted}\"*") } else { format!("\"{quoted}\"") })
}
/// Baut den FTS5-Index in-memory aus dem Archiv-Baum.
@@ -193,14 +181,41 @@ mod tests {
/// Eingaben, die roh durchgereicht einen FTS5-Syntaxfehler warfen. Die
/// Bindestrich-Fälle sind die wichtigsten: Archiv-Dokumente heißen selbst so.
/// Die angefangenen Phrasen stehen für die Tippzwischenstände, die die
/// Live-Suche abschickt.
#[test]
fn sonderzeichen_werfen_keinen_syntaxfehler() {
let home = archiv();
for q in ["ai-control", "adr-log", "TODO: fix", "C++", "wiki (", "\"halbe Phrase", "!?"] {
let eingaben = [
"ai-control",
"adr-log",
"TODO: fix",
"C++",
"wiki (",
"\"",
"\"Strukturiertes",
"\"Strukturiertes Logging",
"!?",
];
for q in eingaben {
assert!(search(&home, q, None, 10).is_ok(), "Suche scheiterte an „{q}“");
}
}
/// Der Tag-Filter lief früher an der Quoting-Regel vorbei. `#` allein ist der
/// erste Tastendruck jeder Tag-Suche und kommt als leerer Tag an.
#[test]
fn leerer_tag_wirft_keinen_syntaxfehler() {
let home = archiv();
// Das angetippte `#`: kein Fehler-Toast, sondern schlicht kein Treffer.
assert_eq!(search(&home, "", Some(""), 10).unwrap().len(), 0);
assert_eq!(search(&home, "", Some("!?"), 10).unwrap().len(), 0);
// Mit Volltext daneben zählt nur dieser, der leere Tag engt nichts ein.
assert_eq!(search(&home, "tracing", Some(""), 10).unwrap().len(), 1);
// Fehler bleibt allein die komplett leere Anfrage ohne jeden Tag.
assert!(search(&home, "", None, 10).is_err());
}
#[test]
fn bindestrich_wort_findet_dokument() {
let home = archiv();
@@ -209,14 +224,4 @@ mod tests {
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}“");
}
}
}
+5 -5
View File
@@ -263,10 +263,10 @@ fn register_mcp_server(pool_dir: &std::path::Path) {
// 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,
},
Ok(s) => {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) else { return };
v
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
Err(_) => return,
};
@@ -820,7 +820,7 @@ mod tests {
set_terminal_config_in(
&p,
"proj",
TerminalConfig { theme: Some("dracula".into()), icon: None, title: None },
TerminalConfig { theme: Some("dracula".into()), ..Default::default() },
)
.unwrap();
delete_pool_in(&p, &store, &kunde).unwrap();
+35 -3
View File
@@ -55,11 +55,15 @@ pub struct TerminalConfig {
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Unbekannte Keys, wie bei `ProjectConfig` — der verlustbehaftete
/// Round-Trip gilt hier genauso, weil dieser Block mitgeschrieben wird.
#[serde(flatten)]
pub rest: serde_json::Map<String, serde_json::Value>,
}
impl TerminalConfig {
pub(crate) fn is_empty(&self) -> bool {
self.theme.is_none() && self.icon.is_none() && self.title.is_none()
self.theme.is_none() && self.icon.is_none() && self.title.is_none() && self.rest.is_empty()
}
}
@@ -477,6 +481,9 @@ pub(crate) fn set_terminal_config_in(
terminal.icon = Some(name);
}
}
// Die Terminal-Config kommt aus der Oberfläche und kennt nur die drei Felder;
// unbekannte Keys der bestehenden Datei würden sonst hier verloren gehen.
terminal.rest = std::mem::take(&mut cfg.terminal.rest);
cfg.terminal = terminal;
write_project_config_in(paths, project, &cfg)?;
crate::platform::write_terminal_desktop(paths, project, &cfg.terminal);
@@ -510,7 +517,8 @@ mod tests {
let cfg_path = project_config_path(&p, "proj").unwrap();
fs::write(
&cfg_path,
r#"{"archiveHome":"~/archiv","zukunftsfeld":{"a":1},"terminal":{"theme":"monokai"}}"#,
r#"{"archiveHome":"~/archiv","zukunftsfeld":{"a":1},
"terminal":{"theme":"monokai","fontSize":13}}"#,
)
.unwrap();
@@ -521,9 +529,33 @@ mod tests {
assert_eq!(v["archiveHome"], "~/archiv");
assert_eq!(v["zukunftsfeld"]["a"], 1);
assert_eq!(v["terminal"]["theme"], "monokai");
// Auch im verschachtelten terminal-Block, der als Ganzes mitgeschrieben wird.
assert_eq!(v["terminal"]["fontSize"], 13);
assert_eq!(v["pool"], serde_json::Value::String(pool));
}
/// Das Setzen der Terminal-Config kommt aus der Oberfläche und kennt nur
/// theme/icon/title — unbekannte Keys müssen trotzdem stehen bleiben.
#[test]
fn terminal_config_setzen_erhaelt_fremde_keys() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
let cfg_path = project_config_path(&p, "proj").unwrap();
fs::write(&cfg_path, r#"{"terminal":{"theme":"monokai","fontSize":13}}"#).unwrap();
set_terminal_config_in(
&p,
"proj",
TerminalConfig { theme: Some("dracula".into()), ..Default::default() },
)
.unwrap();
let v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&cfg_path).unwrap()).unwrap();
assert_eq!(v["terminal"]["theme"], "dracula");
assert_eq!(v["terminal"]["fontSize"], 13);
}
#[test]
fn projekt_wizard_scaffold() {
let p = tmp_paths();
@@ -537,8 +569,8 @@ mod tests {
true,
TerminalConfig {
theme: Some("dracula".into()),
icon: None,
title: Some("Neu".into()),
..Default::default()
},
true,
)
+11 -6
View File
@@ -250,11 +250,16 @@ pub fn commands_read(project: String) -> String {
/// 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.
/// `cmd` ist der angezeigte Befehlstext und wird vor dem Löschen abgeglichen:
/// 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.
///
/// Bewusst ein Provisorium, keine echte Identität: Zwei gleiche Befehle im
/// selben Record sind so nicht unterscheidbar. Sauber wäre eine stabile ID, die
/// beim Schreiben der JSONL vergeben wird, statt Positionen über die
/// Fenstergrenze zu reichen.
#[tauri::command]
pub fn commands_delete(
project: String,
@@ -274,7 +279,7 @@ pub fn commands_delete(
.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) {
if cmds.get(entry).and_then(|c| c["cmd"].as_str()) != Some(cmd.as_str()) {
return Err("Befehl bereits entfernt".into());
}
cmds.remove(entry);
+1 -4
View File
@@ -40,10 +40,7 @@ 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.
// Leeren, weil das Formular zum Fenster gehört und nicht zum Dokument.
const close = () => {
form.hidden = true;
folder.value = "";
Binary file not shown.
-4
View File
@@ -10,10 +10,6 @@
/// 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";