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.
This commit is contained in:
tianma-if committed 2026-09-10 00:32:47 +08:00
1 parent e953faad87
commit 84f65c6cb4
5 files changed
+53 -7

No files matched your search

@@ -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", () => {
+22 -6
View File
@@ -400,6 +400,12 @@ pub(crate) fn list_memos(database: &Connection, params: &Value) -> Result<Value,
None
};
let notebook_ids_json = Value::Array(notebook_ids).to_string();
let tag = params
.get("tag")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_owned();
let filter = match params.get("filter").and_then(Value::as_str) {
Some("pinned") => " 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<Value,
.and_then(Value::as_i64)
.unwrap_or(0)
.max(0);
let where_clause = format!(
"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)))
AND (?5 = '' OR EXISTS (
SELECT 1 FROM json_each(m.tags_json) AS memo_tag
WHERE LOWER(TRIM(CAST(memo_tag.value AS TEXT))) = LOWER(?5)
)){filter}"
);
let query = format!("SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, m.is_archived, m.is_deleted,
c.revision, m.created_at, m.updated_at, m.deleted_at
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))){}
ORDER BY {} LIMIT ?5 OFFSET ?6", filter, order);
WHERE {where_clause}
ORDER BY {order} LIMIT ?6 OFFSET ?7");
let mut statement = database.prepare(&query).map_err(|e| e.to_string())?;
let rows = statement
.query_map(
@@ -439,6 +452,7 @@ pub(crate) fn list_memos(database: &Connection, params: &Value) -> Result<Value,
q,
notebook_id,
notebook_ids_json,
tag,
limit,
offset
],
@@ -446,11 +460,13 @@ pub(crate) fn list_memos(database: &Connection, params: &Value) -> Result<Value,
)
.map_err(|e| e.to_string())?;
let memos: Result<Vec<_>, _> = 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())?;
+9
View File
@@ -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(
+1
View File
@@ -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";
+9 -1
View File
@@ -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"] }));