Core: tag names fold every letter, as the server does (#5385)

SQLite's lower() folds ASCII only, so a device could hold "Café" and
"CAFÉ" as two tags while the server held one. derive::fold (to_lowercase)
is now the one comparison. It is registered as the deterministic SQL function
fold() on every connection (schema::migrate), and find_or_create, the rename
clash, the pull clash and the unique index all use it. derive's push_unique
used eq_ignore_ascii_case and now folds the same way.

v14 merges pairs a device already holds before rebuilding the index. The
older row survives, as in rename_label. Memberships move with via_tag kept,
affected notes go dirty, and the merged-away row is a pending delete. It is
written out rather than calling store::merge_labels so the migration doesn't
depend on store code that later versions may change.

rusqlite gains its "functions" feature (operator-approved, 2026-10-08).
grammar.json gains "#café and #CAFÉ" -> ["café"], which all three
implementations run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 17:01:42 -04:00
co-authored by Claude Opus 5.5
parent 921c0a47bd
commit 0ecff7eeb9
6 changed files with 210 additions and 10 deletions
+12 -3
View File
@@ -205,10 +205,10 @@ pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
// A tag that ALSO appears in prose stays derived: the prose copy still backs it,
// so deleting that copy should still detach the label.
let inline_lower: Vec<String> = inline.iter().map(|n| n.to_lowercase()).collect();
let inline_folded: Vec<String> = inline.iter().map(|n| fold(n)).collect();
let standalone = standalone
.into_iter()
.filter(|n| !inline_lower.contains(&n.to_lowercase()))
.filter(|n| !inline_folded.contains(&fold(n)))
.collect();
(standalone, inline, lifted)
}
@@ -217,8 +217,17 @@ fn is_tag_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '-'
}
/// How two tag names compare: equal once every letter is lowercased, accented ones
/// included, so "Café" and "CAFÉ" are one tag. The server's `labeling.named` folds
/// the same way (Postgres `lower`, Python `str.lower`), and the store's `fold` SQL
/// function is this (#5385).
pub(crate) fn fold(name: &str) -> String {
name.to_lowercase()
}
fn push_unique(out: &mut Vec<String>, candidate: &str) {
if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) {
let folded = fold(candidate);
if !out.iter().any(|x| fold(x) == folded) {
out.push(candidate.to_string());
}
}