Fix FTS search for path punctuation like /idobkin.

Sanitize MATCH tokens so slash/dot queries no longer error and leave a stale full history list; clear the UI on search failure.
This commit is contained in:
2026-08-11 09:51:43 -04:00
parent 5255c56259
commit 43d0582804
7 changed files with 164 additions and 11 deletions
+57 -2
View File
@@ -58,6 +58,26 @@ pub struct Database {
pub conn: Mutex<Connection>,
}
/// Turn free-form user input into a safe FTS5 MATCH expression.
///
/// FTS5 treats `/`, `.`, `"`, and boolean keywords (`AND`/`OR`/`NOT`) as
/// syntax — raw `query + "*"` blows up on paths like `/idobkin` and leaves
/// the UI stuck on the previous unfiltered list. We keep alphanumeric
/// (plus `_`) tokens, quote them, and apply a prefix `*` to each so typing
/// mid-phrase still works.
fn build_fts_query(raw: &str) -> Option<String> {
let tokens: Vec<String> = raw
.split(|c: char| !(c.is_alphanumeric() || c == '_'))
.filter(|t| !t.is_empty())
.map(|t| format!("\"{}\"*", t.replace('"', "\"\"")))
.collect();
if tokens.is_empty() {
None
} else {
Some(tokens.join(" "))
}
}
impl Database {
pub fn new() -> SqlResult<Self> {
let db_path = Self::db_path();
@@ -332,10 +352,11 @@ impl Database {
}
pub fn search_entries(&self, query: &str, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
let Some(fts_query) = build_fts_query(query) else {
return Ok(Vec::new());
};
let conn = self.conn.lock().unwrap();
let limit = limit.clamp(1, DISPLAY_LIMIT);
// FTS5 match query — prefix search with *
let fts_query = format!("{}*", query.replace('"', "\"\""));
let mut stmt = conn.prepare(
"SELECT e.id, e.content, e.content_type, e.created_at, e.pinned, e.content_hash, e.thumbnail
FROM clipboard_entries e
@@ -643,6 +664,40 @@ mod tests {
assert_eq!(db.search_entries("searchable", 100).unwrap().len(), 0);
}
#[test]
fn build_fts_query_strips_path_punctuation() {
assert_eq!(build_fts_query("/idobkin").as_deref(), Some("\"idobkin\"*"));
assert_eq!(
build_fts_query("github.com/idobkin").as_deref(),
Some("\"github\"* \"com\"* \"idobkin\"*")
);
assert_eq!(build_fts_query(" /// ").as_deref(), None);
assert_eq!(build_fts_query("AND OR").as_deref(), Some("\"AND\"* \"OR\"*"));
}
#[test]
fn search_accepts_leading_slash_and_dots() {
let db = test_db();
db.insert_entry("user /idobkin on github", "text", "h1", None)
.unwrap();
db.insert_entry("unrelated Synonyms", "text", "h2", None)
.unwrap();
db.insert_entry("visit https://github.com/idobkin/repo", "text", "h3", None)
.unwrap();
let slash = db.search_entries("/idobkin", 100).unwrap();
assert_eq!(slash.len(), 2);
assert!(slash.iter().all(|e| e.content.contains("idobkin")));
let dotted = db.search_entries("github.com", 100).unwrap();
assert_eq!(dotted.len(), 1);
assert!(dotted[0].content.contains("github.com"));
// Must not error (FTS syntax) — punctuation-only → empty.
assert!(db.search_entries("///", 100).unwrap().is_empty());
assert!(db.search_entries("AND", 100).unwrap().is_empty());
}
// ── Trim ────────────────────────────────────────────────────────
#[test]