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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user