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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user