Fix FTS search for path punctuation #12
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user