Fix FTS search for path punctuation #12

Merged
ilia merged 2 commits from fix/fts-search-punctuation into main 2026-08-11 08:54:10 -05:00
7 changed files with 166 additions and 13 deletions
+8
View File
@@ -3,6 +3,14 @@
All notable changes to maCopy. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Fixed
- Search queries with path punctuation (`/idobkin`, `github.com`, …) no
longer hit FTS5 syntax errors and leave the picker stuck on the full
unfiltered history — tokens are sanitized before `MATCH`
- When search IPC fails, the list clears instead of showing stale results
## [0.2.0] — 2026-07-14
### Added
+6 -6
View File
@@ -122,8 +122,8 @@ maCopy/
|---|---|
| `npm run tauri dev` | Run in development mode with hot reload |
| `npm run tauri build` | Build a release `.app` bundle |
| `npm test` | Run frontend tests (Vitest, ~60 tests) |
| `npm run test:rust` | Run Rust backend tests (~52 tests) |
| `npm test` | Run frontend tests (Vitest, ~72 tests) |
| `npm run test:rust` | Run Rust backend tests (~67 tests) |
| `npm run test:all` | Run all tests (frontend + backend) |
| `npm run lint` | TypeScript type-check |
| `npm run check` | Lint + all tests |
@@ -154,7 +154,7 @@ When you select an entry, maCopy writes it to the system clipboard (text or imag
### SQLite + FTS5
The database uses a content-synced FTS5 virtual table with triggers that automatically keep the full-text index in sync with the `clipboard_entries` table. This enables instant prefix search as you type. Opened with `journal_mode=WAL` + `synchronous=NORMAL` so the UI can read while the background poller writes, without blocking on full fsyncs.
The database uses a content-synced FTS5 virtual table with triggers that automatically keep the full-text index in sync with the `clipboard_entries` table. This enables instant prefix search as you type. User queries are tokenized before `MATCH` so path punctuation (`/…`, `.`) does not trip FTS5 syntax. Opened with `journal_mode=WAL` + `synchronous=NORMAL` so the UI can read while the background poller writes, without blocking on full fsyncs.
### Window behavior
@@ -173,9 +173,9 @@ The SQLite database is stored at:
See **[docs/TESTING.md](docs/TESTING.md)** for the full map.
```bash
npm test # ~60 frontend tests
npm run test:rust # ~52 Rust tests
npm run check # lint + all tests (~112)
npm test # ~72 frontend tests
npm run test:rust # ~67 Rust tests
npm run check # lint + all tests (~139)
```
## Building for Release
+1 -1
View File
@@ -84,9 +84,9 @@ saved history entry is never modified.
| Paste multiple selected items (joined with newlines) | `Enter` |
| Delete | `Backspace`/`Delete` with items selected, or right-click → **Delete** |
| Pin (keep forever, always on top) | Right-click → **Pin** / **Unpin** |
| Search | Just start typing — instant full-text search (paths like `/idobkin` and `github.com` work) |
On macOS, **Control+click** opens the context menu (same as right-click). Use **Cmd+Click** for multi-select — Control+click is not multi-select.
| Search | Just start typing — instant full-text search |
### What you'll see in the list
+4 -4
View File
@@ -16,13 +16,13 @@ maCopy has an automated suite. There is no separate “live” Storybook/UI docs
| `npm run lint` | TypeScript `tsc --noEmit` |
| `npm run check` | Lint + all tests (CI-style gate) |
Current counts (approx.): **~60 frontend** + **~65 Rust** unit tests.
Current counts (approx.): **~72 frontend** + **~67 Rust** unit tests.
## What is covered
### Frontend (`src/**/*.test.tsx`, `transforms.test.ts`)
- App: load entries, paste invoke, delete via keyboard
- App: load entries, paste invoke, delete via keyboard, **search invoke + clear on search failure**
- ClipboardList: selection, multi-select modifiers, image stubs/thumbs (including truncated thumb previews), Alt=plain paste
- ContextMenu: Paste / Paste plain / transforms / Pin / Delete
- SettingsPanel: toggles, history limit, hotkey recorder, **Launch at login → autostart enable**
@@ -33,9 +33,9 @@ Tauri APIs are mocked in `src/test/setup.ts` (invoke, window, events, clipboard,
### Backend (`src-tauri/src/**` `#[cfg(test)]`)
- SQLite CRUD, FTS5, trim + pin preservation, settings
- SQLite CRUD, FTS5 (incl. `/path` / `github.com` punctuation), trim + pin preservation, settings
- List previews: truncate text, omit image blobs, return thumbnails (including stub `[image WxH]` rows)
- Search excludes image rows (text-only FTS)
- Search excludes image rows (text-only FTS); App clears the list if search IPC fails
- Sensitive redaction (card / password / banking) while `get_entry` stays full
- Hotkey parser (`cmd+\`` etc.)
- Image thumbnail / PNG helpers + `fit_rgba_under_bytes` against production `MAX_IMAGE_BYTES`
+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]
+85
View File
@@ -202,6 +202,91 @@ describe("App", () => {
});
});
it("invokes search_entries with the typed query after debounce", async () => {
const all = [
makeEntry({ content: "user /idobkin on github" }),
makeEntry({ content: "Synonyms" }),
];
const hits = [all[0]];
mockInvoke.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
switch (cmd) {
case "get_entries":
return all;
case "search_entries":
expect(args?.query).toBe("/idobkin");
return hits;
case "get_settings":
return makeSettings();
case "latest_entry_id":
return all[0]?.id ?? null;
default:
return undefined;
}
});
render(<App />);
await waitFor(() => {
expect(screen.getByText("Synonyms")).toBeInTheDocument();
});
fireEvent.change(screen.getByPlaceholderText("Search…"), {
target: { value: "/idobkin" },
});
await act(async () => {
vi.advanceTimersByTime(150);
});
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith("search_entries", {
query: "/idobkin",
limit: 150,
});
expect(screen.getByText("user /idobkin on github")).toBeInTheDocument();
expect(screen.queryByText("Synonyms")).not.toBeInTheDocument();
});
});
it("clears the list when search_entries fails instead of keeping stale results", async () => {
const all = [makeEntry({ content: "Synonyms" }), makeEntry({ content: "Magnet Math" })];
mockInvoke.mockImplementation(async (cmd: string) => {
switch (cmd) {
case "get_entries":
return all;
case "search_entries":
throw new Error("fts5: syntax error near \"/\"");
case "get_settings":
return makeSettings();
case "latest_entry_id":
return all[0]?.id ?? null;
default:
return undefined;
}
});
render(<App />);
await waitFor(() => {
expect(screen.getByText("Synonyms")).toBeInTheDocument();
});
fireEvent.change(screen.getByPlaceholderText("Search…"), {
target: { value: "/broken" },
});
await act(async () => {
vi.advanceTimersByTime(150);
});
await waitFor(() => {
expect(screen.getByText("No clipboard history")).toBeInTheDocument();
expect(screen.queryByText("Synonyms")).not.toBeInTheDocument();
});
});
it("shows settings panel when settings are opened", async () => {
mockTauriCommands();
render(<App />);
+5
View File
@@ -49,6 +49,11 @@ export default function App() {
setEntries(data);
} catch (e) {
console.error("Failed to load entries:", e);
// Don't leave a stale unfiltered list when search fails (e.g. bad FTS query).
if (queryRef.current.trim()) {
fingerprintRef.current = "";
setEntries([]);
}
}
}, []);