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:
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", () => {
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"] }));
|
||||
Reference in new issue
Block a user