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:
@@ -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).
|
||||
|
||||
@@ -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}“");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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.
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user