From d838b27518b4f79985c0e160bc9b6872aaf0107d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 31 Aug 2026 15:52:00 -0400 Subject: [PATCH] ffi: Kotlin could list and create a tag but never rename, recolour, delete or merge one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `core/src/local/store.rs` implements all seven label operations. The uniffi object exposed three of them, so Android could attach tags to a note and mint new ones, and could do nothing else with them ever. The four additions are pure passthrough, because reading the store showed both of the things #2963 said to check rather than assume are already handled there: * The note count exists. `Label` carries `count: Option` and `list_labels` computes it per row, excluding trashed notes — which is the number a delete confirmation should show. The single-label returns all end in `load_label` and leave it `None` on purpose, so a screen must read counts from the LIST and never from an operation's result. * Sync is free. `rename_label` and `set_label_color` set `dirty = 1`; `remove_label` records a pending delete; `merge_labels` records one for the source AND marks every note that carried it dirty before the delete cascades the membership rows away, because push sends `label_ids` per note. So no store change, no sync change, no count plumbing — the binding only. One divergence found and documented rather than fixed: renaming a tag onto an existing name is a 409 on the server (`labels.py:94`) and a silent duplicate in the local store. The desktop has always had this, calling the same `store::rename_label`; Android now inherits it. Deciding which side is right belongs with the screen (#2964), not with the binding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c --- android/ffi/src/lib.rs | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index 76c21b9..96cff38 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -333,6 +333,63 @@ impl ThoughtSync { .map_err(CoreError::store) } + /// Rename a label. Every note carrying it follows, because notes reference it + /// by id and never by name. + /// + /// Renaming onto a name that already exists does NOT merge, and does not fail + /// either — the core's find-or-create matching only runs on the create path, so + /// this leaves two labels whose names differ by case at most. + /// + /// The SERVER disagrees: `labels.py` answers that PATCH with 409 "a label with + /// that name already exists". So the local stores (this and the desktop, which + /// calls the same `store::rename_label`) are more permissive than the REST path + /// the web uses, and a duplicate made offline will meet that 409 on sync. Not + /// introduced here — it predates Android having a rename at all — but a UI over + /// this has to decide what it shows, so it is written down rather than found. + /// Merging is the deliberate, irreversible operation and stays a separate button. + pub fn rename_label(&self, id: String, name: String) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::rename_label(&conn, &id, &name) + .map(Label::from) + .map_err(CoreError::store) + } + + /// Recolour a label. + /// + /// `color` is a palette KEY from the shared vocabulary (`NoteTint.kt` on this + /// side), not a hex value — the point of the shared palette is that a colour + /// picked on the phone resolves to the same swatch on the web and the desktop, + /// which a literal colour could not promise across themes. + pub fn set_label_color(&self, id: String, color: String) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::set_label_color(&conn, &id, &color) + .map(Label::from) + .map_err(CoreError::store) + } + + /// Delete a label. The notes that carried it are NOT deleted — they simply stop + /// carrying it, which is the thing a confirmation dialog has to say out loud. + /// + /// A `#tag` in a body will re-derive the label on the next edit of that note. + /// That is correct rather than a leak: the text mandates it, and deleting the + /// row cannot un-write the word. + pub fn remove_label(&self, id: String) -> Result<(), CoreError> { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::remove_label(&conn, &id).map_err(CoreError::store) + } + + /// Fold `source` into `target` and return the survivor. + /// + /// DIRECTIONAL and NOT reversible by repeating it: source stops existing. Any + /// UI over this has to name the survivor before it runs, because afterwards + /// there is nothing left to read the direction from. + pub fn merge_labels(&self, source_id: String, target_id: String) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::merge_labels(&conn, &source_id, &target_id) + .map(Label::from) + .map_err(CoreError::store) + } + // ─────────────────────────────── sync ──────────────────────────────── pub fn sync_status(&self) -> Result {