From 84f65c6cb42dbcd20ba0c2c360cfe51515a15dfb Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 10 Sep 2026 00:32:47 +0800 Subject: [PATCH] fix(desktop): filter memo lists by exact tag Sidecar memo.list ignored the tag parameter, so choosing a tag showed every note. Match json_each tag values case-insensitively and keep prefix-overlapping names out of the result. --- apps/web/src/lib/desktop-repository.test.mjs | 12 +++++++++ crates/desktop-sidecar/src/memo.rs | 28 +++++++++++++++----- packages/shared/src/desktop-rpc.test.mjs | 9 +++++++ packages/shared/src/desktop-rpc.ts | 1 + scripts/test-desktop-sidecar.mjs | 10 ++++++- 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/desktop-repository.test.mjs b/apps/web/src/lib/desktop-repository.test.mjs index b3568d83..2d5c8558 100644 --- a/apps/web/src/lib/desktop-repository.test.mjs +++ b/apps/web/src/lib/desktop-repository.test.mjs @@ -44,6 +44,18 @@ describe("desktop repository notebook filters", () => { }, }); }); + + test("forwards the selected tag to the sidecar list query", async () => { + await createDesktopRepository().listMemos({ tag: "AI-RSS" }); + + expect(lastRequest).toEqual({ + method: "memo.list", + params: { + notebookId: null, + tag: "AI-RSS", + }, + }); + }); }); describe("desktop repository memo saves", () => { diff --git a/crates/desktop-sidecar/src/memo.rs b/crates/desktop-sidecar/src/memo.rs index 6479f4bb..20c8b463 100644 --- a/crates/desktop-sidecar/src/memo.rs +++ b/crates/desktop-sidecar/src/memo.rs @@ -400,6 +400,12 @@ pub(crate) fn list_memos(database: &Connection, params: &Value) -> Result " AND m.is_pinned = 1", Some("tagged") => " AND json_array_length(m.tags_json) > 0", @@ -424,13 +430,20 @@ pub(crate) fn list_memos(database: &Connection, params: &Value) -> Result Result Result, _> = rows.collect(); - let count_query = format!("SELECT COUNT(*) FROM memos m JOIN memo_contents c ON c.memo_id = m.id WHERE m.is_deleted = ?1 AND (?2 = '' OR lower(COALESCE(m.title, '') || ' ' || m.excerpt || ' ' || c.content_text || ' ' || m.tags_json) LIKE '%' || lower(?2) || '%') AND (?3 IS NULL OR m.notebook_id = ?3) AND (?4 = '[]' OR m.notebook_id IN (SELECT value FROM json_each(?4))){}", filter); + let count_query = format!( + "SELECT COUNT(*) FROM memos m JOIN memo_contents c ON c.memo_id = m.id WHERE {where_clause}" + ); let total: i64 = database .query_row( &count_query, - rusqlite::params![trash as i64, q, notebook_id, notebook_ids_json], + rusqlite::params![trash as i64, q, notebook_id, notebook_ids_json, tag], |row| row.get(0), ) .map_err(|e| e.to_string())?; diff --git a/packages/shared/src/desktop-rpc.test.mjs b/packages/shared/src/desktop-rpc.test.mjs index 7ff68363..78975fc0 100644 --- a/packages/shared/src/desktop-rpc.test.mjs +++ b/packages/shared/src/desktop-rpc.test.mjs @@ -9,6 +9,10 @@ const rustRpcSource = readFileSync( new URL("../../../crates/desktop-sidecar/src/rpc.rs", import.meta.url), "utf8", ); +const rustMemoSource = readFileSync( + new URL("../../../crates/desktop-sidecar/src/memo.rs", import.meta.url), + "utf8", +); const electronRpcSource = readFileSync( new URL("../../../apps/desktop/src/main/rpc.mjs", import.meta.url), "utf8", @@ -27,6 +31,11 @@ describe("desktop sidecar RPC contract", () => { expect([...DESKTOP_RPC_METHODS].sort()).toEqual(rustMethods); }); + test("filters memo.list by an exact tag instead of ignoring the parameter", () => { + expect(rustMemoSource).toContain("json_each(m.tags_json) AS memo_tag"); + expect(rustMemoSource).toContain("LOWER(TRIM(CAST(memo_tag.value AS TEXT))) = LOWER(?5)"); + }); + test("keeps the native and Electron protocol guards aligned", () => { const rustVersion = Number(rustRpcSource.match(/const PROTOCOL_VERSION: i64 = (\d+);/)?.[1]); const electronVersion = Number( diff --git a/packages/shared/src/desktop-rpc.ts b/packages/shared/src/desktop-rpc.ts index ad09206a..d972aa71 100644 --- a/packages/shared/src/desktop-rpc.ts +++ b/packages/shared/src/desktop-rpc.ts @@ -57,6 +57,7 @@ export type DesktopMemoListParams = { notebookId?: string | null; notebookIds?: string[]; q?: string; + tag?: string; trash?: boolean; sort?: "updated-desc" | "created-desc" | "title-asc"; filter?: "all" | "pinned" | "tagged" | "untagged"; diff --git a/scripts/test-desktop-sidecar.mjs b/scripts/test-desktop-sidecar.mjs index 034bdff8..413c169c 100644 --- a/scripts/test-desktop-sidecar.mjs +++ b/scripts/test-desktop-sidecar.mjs @@ -117,6 +117,14 @@ assert.equal((await request("memo.get", { memoId: first.memo.id })).memo.id, fir const second = await request("memo.create", { notebookId: inbox.id, title: "Second memo", contentMarkdown: "another body", tags: [] }); const search = await request("memo.list", { q: "searchable", limit: 20 }); assert.deepEqual(search.memos.map((memo) => memo.id), [first.memo.id]); +await request("memo.create", { notebookId: inbox.id, title: "Local daily", contentMarkdown: "prefix overlap", tags: ["local-daily"] }); +const tagged = await request("memo.list", { tag: "local", limit: 20 }); +assert.deepEqual(tagged.memos.map((memo) => memo.id), [first.memo.id], "tag filter should match an exact tag, not a prefix"); +assert.equal(tagged.totalCount, 1, "tag filter total should count only exact matches"); +const taggedCase = await request("memo.list", { tag: "LOCAL", limit: 20 }); +assert.deepEqual(taggedCase.memos.map((memo) => memo.id), [first.memo.id], "tag filter should match tags case-insensitively"); +const missingTag = await request("memo.list", { tag: "missing-tag", limit: 20 }); +assert.equal(missingTag.totalCount, 0, "unknown tags should not leak untagged notes into the list"); const childNotebook = (await request("notebook.create", { name: "Inbox child", parentId: inbox.id })).notebook; const childMemo = await request("memo.create", { notebookId: childNotebook.id, title: "Nested memo", contentMarkdown: "nested body", tags: [] }); const subtree = await request("memo.list", { @@ -404,4 +412,4 @@ assert.equal((await request("sync.status")).conflict, 0); child.stdin.end(); await new Promise((resolve) => child.once("close", resolve)); -console.log(JSON.stringify({ ok: true, checked: ["memo.create", "memo.list.search", "memo.list.subtree", "memo.update", "memo.update.coalesce", "memo.revisions", "memo.restoreRevision", "memo.revision.cache", "tag.rename", "memo.moveBatch", "memo.pinBatch", "memo.deleteBatch", "memo.restore", "memo.emptyTrash", "memo.merge", "template.cache", "template.create.payload", "template.delete", "storage.backup", "storage.backups", "storage.restore", "sync.apply.merge-page-order", "sync.apply.deleted-notebook", "sync.outbox", "sync.outbox.retry", "sync.outbox.recoverMemoUpdate", "sync.outbox.discard"] })); +console.log(JSON.stringify({ ok: true, checked: ["memo.create", "memo.list.search", "memo.list.tag", "memo.list.subtree", "memo.update", "memo.update.coalesce", "memo.revisions", "memo.restoreRevision", "memo.revision.cache", "tag.rename", "memo.moveBatch", "memo.pinBatch", "memo.deleteBatch", "memo.restore", "memo.emptyTrash", "memo.merge", "template.cache", "template.create.payload", "template.delete", "storage.backup", "storage.backups", "storage.restore", "sync.apply.merge-page-order", "sync.apply.deleted-notebook", "sync.outbox", "sync.outbox.retry", "sync.outbox.recoverMemoUpdate", "sync.outbox.discard"] }));