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
+3 -3
View File
@@ -175,7 +175,7 @@ fn is_dirty(conn: &Connection, note_id: &str) -> rusqlite::Result<bool> {
fn upsert_label(conn: &Connection, label: &wire::Label) -> rusqlite::Result<()> {
// One label per name is enforced on both sides (locally a UNIQUE index on
// lower(name); on the server, per owner). A label created offline can therefore
// fold(name); on the server, per owner). A label created offline can therefore
// collide with one the server already had under a different id — "work" typed on
// this machine and "work" that already existed.
//
@@ -186,7 +186,7 @@ fn upsert_label(conn: &Connection, label: &wire::Label) -> rusqlite::Result<()>
// memberships onto it, then drop the husk.
let duplicates: Vec<String> = {
let mut stmt =
conn.prepare("SELECT id FROM labels WHERE lower(name) = lower(?1) AND id <> ?2")?;
conn.prepare("SELECT id FROM labels WHERE fold(name) = fold(?1) AND id <> ?2")?;
let rows = stmt.query_map(params![label.name, label.id], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
@@ -219,7 +219,7 @@ fn upsert_label(conn: &Connection, label: &wire::Label) -> rusqlite::Result<()>
for old in &duplicates {
// OR IGNORE guards a (note_id, label_id) collision. Today the unique index on
// lower(name) makes that unreachable — two same-name labels can't coexist
// fold(name) makes that unreachable — two same-name labels can't coexist
// locally — so this is belt-and-braces against that index changing, not a
// case we've seen. Anything it skips cascades away with the husk below, which
// is correct: those are duplicates of a membership that now exists.