Fix FTS search for path punctuation #12
@@ -3,6 +3,14 @@
|
|||||||
All notable changes to maCopy. Format loosely follows
|
All notable changes to maCopy. Format loosely follows
|
||||||
[Keep a Changelog](https://keepachangelog.com/).
|
[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
|
## [0.2.0] — 2026-07-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ maCopy/
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `npm run tauri dev` | Run in development mode with hot reload |
|
| `npm run tauri dev` | Run in development mode with hot reload |
|
||||||
| `npm run tauri build` | Build a release `.app` bundle |
|
| `npm run tauri build` | Build a release `.app` bundle |
|
||||||
| `npm test` | Run frontend tests (Vitest, ~60 tests) |
|
| `npm test` | Run frontend tests (Vitest, ~72 tests) |
|
||||||
| `npm run test:rust` | Run Rust backend tests (~52 tests) |
|
| `npm run test:rust` | Run Rust backend tests (~67 tests) |
|
||||||
| `npm run test:all` | Run all tests (frontend + backend) |
|
| `npm run test:all` | Run all tests (frontend + backend) |
|
||||||
| `npm run lint` | TypeScript type-check |
|
| `npm run lint` | TypeScript type-check |
|
||||||
| `npm run check` | Lint + all tests |
|
| `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
|
### 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
|
### Window behavior
|
||||||
|
|
||||||
@@ -173,9 +173,9 @@ The SQLite database is stored at:
|
|||||||
See **[docs/TESTING.md](docs/TESTING.md)** for the full map.
|
See **[docs/TESTING.md](docs/TESTING.md)** for the full map.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test # ~60 frontend tests
|
npm test # ~72 frontend tests
|
||||||
npm run test:rust # ~52 Rust tests
|
npm run test:rust # ~67 Rust tests
|
||||||
npm run check # lint + all tests (~112)
|
npm run check # lint + all tests (~139)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building for Release
|
## Building for Release
|
||||||
|
|||||||
+1
-1
@@ -84,9 +84,9 @@ saved history entry is never modified.
|
|||||||
| Paste multiple selected items (joined with newlines) | `Enter` |
|
| Paste multiple selected items (joined with newlines) | `Enter` |
|
||||||
| Delete | `Backspace`/`Delete` with items selected, or right-click → **Delete** |
|
| Delete | `Backspace`/`Delete` with items selected, or right-click → **Delete** |
|
||||||
| Pin (keep forever, always on top) | Right-click → **Pin** / **Unpin** |
|
| 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.
|
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
|
### 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 lint` | TypeScript `tsc --noEmit` |
|
||||||
| `npm run check` | Lint + all tests (CI-style gate) |
|
| `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
|
## What is covered
|
||||||
|
|
||||||
### Frontend (`src/**/*.test.tsx`, `transforms.test.ts`)
|
### 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
|
- ClipboardList: selection, multi-select modifiers, image stubs/thumbs (including truncated thumb previews), Alt=plain paste
|
||||||
- ContextMenu: Paste / Paste plain / transforms / Pin / Delete
|
- ContextMenu: Paste / Paste plain / transforms / Pin / Delete
|
||||||
- SettingsPanel: toggles, history limit, hotkey recorder, **Launch at login → autostart enable**
|
- 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)]`)
|
### 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)
|
- 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
|
- Sensitive redaction (card / password / banking) while `get_entry` stays full
|
||||||
- Hotkey parser (`cmd+\`` etc.)
|
- Hotkey parser (`cmd+\`` etc.)
|
||||||
- Image thumbnail / PNG helpers + `fit_rgba_under_bytes` against production `MAX_IMAGE_BYTES`
|
- 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>,
|
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 {
|
impl Database {
|
||||||
pub fn new() -> SqlResult<Self> {
|
pub fn new() -> SqlResult<Self> {
|
||||||
let db_path = Self::db_path();
|
let db_path = Self::db_path();
|
||||||
@@ -332,10 +352,11 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn search_entries(&self, query: &str, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
|
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 conn = self.conn.lock().unwrap();
|
||||||
let limit = limit.clamp(1, DISPLAY_LIMIT);
|
let limit = limit.clamp(1, DISPLAY_LIMIT);
|
||||||
// FTS5 match query — prefix search with *
|
|
||||||
let fts_query = format!("{}*", query.replace('"', "\"\""));
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT e.id, e.content, e.content_type, e.created_at, e.pinned, e.content_hash, e.thumbnail
|
"SELECT e.id, e.content, e.content_type, e.created_at, e.pinned, e.content_hash, e.thumbnail
|
||||||
FROM clipboard_entries e
|
FROM clipboard_entries e
|
||||||
@@ -643,6 +664,40 @@ mod tests {
|
|||||||
assert_eq!(db.search_entries("searchable", 100).unwrap().len(), 0);
|
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 ────────────────────────────────────────────────────────
|
// ── Trim ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[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 () => {
|
it("shows settings panel when settings are opened", async () => {
|
||||||
mockTauriCommands();
|
mockTauriCommands();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export default function App() {
|
|||||||
setEntries(data);
|
setEntries(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load entries:", 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