Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m37s
The three surfaces did not agree on what renaming a tag onto a name another
one already holds should do, and none of the three answers was good.
I described this wrongly first time and the correction matters. The local
store does NOT silently create a duplicate: `idx_labels_name` is unique on
`lower(name)`, so the bare UPDATE in `rename_label` failed, and the user got
a raw SQLite "UNIQUE constraint failed" as their error message. The server
meanwhile answered 409 "a tag with that name already exists" — and only on
an EXACT match, because its constraint is on the raw name while every
client's index is on `lower(name)`.
That last part is the sharper bug. The server would happily hold "Groceries"
beside "groceries"; no synced client can store both. Creating that pair on
the web armed a pull that fails later, on a phone, in a path with no UI.
Operator's call: a rename onto an existing name means merge — typing an
existing tag's name onto this one says they are the same thing.
* `store::rename_label` and the server's PATCH now implement one rule.
THE OLDER ROW SURVIVES and takes the new spelling. Age rather than "the
one that already held the name", so that renaming A→B and B→A land on
the same survivor; otherwise the outcome depends on which way round
someone typed it, and two devices tidying the same pair disagree about
which id still exists. Ties go to the incumbent, so it stays
deterministic.
* The core reuses `merge_labels` rather than reimplementing the move. That
is the only place that knows to mark every affected NOTE dirty before
the delete cascades the membership rows away, which is what makes a
merge reach the server at all.
* The server's rename and its `/merge` route now share one `_merge_into`
helper, for the same reason.
* Both server lookups became case-INSENSITIVE, matching every client. The
create path is included: it was the one actually minting the unstorable
pair, so fixing only the rename would have left the door open.
* The web asks before merging, naming both note counts. A merge cannot be
undone by repeating it and is now reachable by a typo in a text field —
the same reasoning as the delete confirmation in #2116. The confirmation
lives in the shared store, so the desktop gets it too; the FFI does not
ask, because that belongs to the surface with a person in front of it.
* The web store detects the merge from the LIST, not the response: the
survivor may be the row we asked to rename, so an unchanged id proves
nothing.
Tests: three integration tests over a real database (both rename directions
land on the older row; a case-varied create returns the existing tag) and
two through the Android FFI, which is the binding the phone will use.
Also fixes a straggler from 8c7553d — the delete confirmation still said
"the label".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
191 lines
8.0 KiB
Python
191 lines
8.0 KiB
Python
from __future__ import annotations
|
|
|
|
from quart import Blueprint, g, jsonify, request
|
|
from sqlalchemy import func, select
|
|
|
|
from .auth import login_required
|
|
from .colors import normalize_color
|
|
from .db import session_scope
|
|
from .models.label import Label, NoteLabel
|
|
from .responses import json_error, not_found, parse_uuid
|
|
from .serialize import serialize_label
|
|
|
|
bp = Blueprint("labels", __name__, url_prefix="/api/labels")
|
|
|
|
|
|
def _serialize_label(label: Label, count: int | None = None) -> dict:
|
|
data = serialize_label(label)
|
|
if count is not None:
|
|
data["count"] = count
|
|
return data
|
|
|
|
|
|
async def _label_note_count(db, label_id) -> int:
|
|
"""How many notes carry this label (distinct — note_labels PK is note+label)."""
|
|
return int(
|
|
await db.scalar(select(func.count()).select_from(NoteLabel).where(NoteLabel.label_id == label_id)) or 0
|
|
)
|
|
|
|
|
|
async def _merge_into(db, source: Label, target: Label) -> None:
|
|
"""Move every note tagged with `source` onto `target`, then delete `source`.
|
|
|
|
Shared by the explicit `/merge` route and by a rename that lands on a name
|
|
another tag already holds — those are the same operation, and having one body
|
|
is what stops them drifting into two answers for one question.
|
|
|
|
Note-body `#tags` are NOT rewritten, so a note whose body still literally
|
|
contains the source `#tag` will re-mint that tag on its next edit. Retiring a
|
|
tag means editing it out of the text; a known, documented nuance.
|
|
"""
|
|
# Notes already carrying the target: a note can't hold the same label twice
|
|
# (composite PK), so the source attachment there is just dropped as a dup.
|
|
target_notes = set(
|
|
(await db.scalars(select(NoteLabel.note_id).where(NoteLabel.label_id == target.id))).all()
|
|
)
|
|
source_rows = (await db.scalars(select(NoteLabel).where(NoteLabel.label_id == source.id))).all()
|
|
by_note = {r.note_id: r.via_tag for r in source_rows}
|
|
# Delete the source attachments first, then re-insert under the target — moving
|
|
# by delete+insert avoids mutating a composite primary-key column in place.
|
|
for row in source_rows:
|
|
await db.delete(row)
|
|
await db.flush()
|
|
for note_id, via_tag in by_note.items():
|
|
if note_id not in target_notes:
|
|
db.add(NoteLabel(note_id=note_id, label_id=target.id, via_tag=via_tag))
|
|
await db.delete(source)
|
|
await db.flush()
|
|
|
|
|
|
async def _get_owned_label(db, label_id: str) -> Label | None:
|
|
lid = parse_uuid(label_id)
|
|
if lid is None:
|
|
return None
|
|
return await db.scalar(select(Label).where(Label.id == lid, Label.owner_id == g.user_id))
|
|
|
|
|
|
@bp.get("")
|
|
@login_required
|
|
async def list_labels():
|
|
async with session_scope() as db:
|
|
labels = (await db.scalars(select(Label).where(Label.owner_id == g.user_id).order_by(Label.name))).all()
|
|
# One grouped query for all usage counts (0 for labels attached to nothing).
|
|
counts = dict(
|
|
(
|
|
await db.execute(
|
|
select(NoteLabel.label_id, func.count(NoteLabel.note_id))
|
|
.where(NoteLabel.label_id.in_([lb.id for lb in labels]))
|
|
.group_by(NoteLabel.label_id)
|
|
)
|
|
).all()
|
|
) if labels else {}
|
|
return jsonify({"labels": [_serialize_label(lb, int(counts.get(lb.id, 0))) for lb in labels]})
|
|
|
|
|
|
@bp.post("")
|
|
@login_required
|
|
async def create_label():
|
|
data = await request.get_json(silent=True) or {}
|
|
name = (data.get("name") or "").strip()
|
|
if not name:
|
|
return json_error("tag name is required", 400)
|
|
async with session_scope() as db:
|
|
# Idempotent: creating an existing tag just returns it. Case-INSENSITIVE,
|
|
# like the clients' `find_or_create_label` — a case-sensitive match here was
|
|
# the path that MINTED the "Groceries" beside "groceries" pair that no synced
|
|
# client can hold, since their `labels` index is unique on `lower(name)`.
|
|
existing = await db.scalar(
|
|
select(Label).where(Label.owner_id == g.user_id, func.lower(Label.name) == name.lower())
|
|
)
|
|
if existing is not None:
|
|
return jsonify(_serialize_label(existing)), 200
|
|
label = Label(owner_id=g.user_id, name=name, color=normalize_color(data.get("color")))
|
|
db.add(label)
|
|
await db.commit()
|
|
await db.refresh(label)
|
|
return jsonify(_serialize_label(label, 0)), 201
|
|
|
|
|
|
@bp.patch("/<label_id>")
|
|
@login_required
|
|
async def update_label(label_id: str):
|
|
data = await request.get_json(silent=True) or {}
|
|
has_name = "name" in data
|
|
has_color = "color" in data
|
|
if not has_name and not has_color:
|
|
return json_error("nothing to update", 400)
|
|
name = (data.get("name") or "").strip() if has_name else None
|
|
if has_name and not name:
|
|
return json_error("tag name is required", 400)
|
|
async with session_scope() as db:
|
|
label = await _get_owned_label(db, label_id)
|
|
if label is None:
|
|
return not_found()
|
|
if has_name:
|
|
# Case-INSENSITIVE, matching the clients' `find_or_create_label` and the
|
|
# local store's `lower(name)` unique index. The Postgres constraint here
|
|
# is on the raw name, so the database would happily hold "Groceries"
|
|
# beside "groceries" — but no synced client can store both, so letting
|
|
# one be made is letting a pull fail later on a phone.
|
|
clash = await db.scalar(
|
|
select(Label).where(
|
|
Label.owner_id == g.user_id,
|
|
func.lower(Label.name) == name.lower(),
|
|
Label.id != label.id,
|
|
)
|
|
)
|
|
if clash is not None:
|
|
# Renaming onto an existing tag MERGES the two rather than failing:
|
|
# typing an existing tag's name onto this one says they are the same
|
|
# thing. The OLDER row survives and takes the new spelling — age is
|
|
# the one property that does not depend on which of the two the
|
|
# caller happened to be renaming, so A→B and B→A agree. Ties go to
|
|
# the incumbent. Mirrors `store::rename_label` exactly.
|
|
if clash.created_at <= label.created_at:
|
|
survivor, doomed = clash, label
|
|
else:
|
|
survivor, doomed = label, clash
|
|
await _merge_into(db, doomed, survivor)
|
|
label = survivor
|
|
label.name = name
|
|
if has_color:
|
|
label.color = normalize_color(data.get("color"))
|
|
await db.commit()
|
|
await db.refresh(label)
|
|
return jsonify(_serialize_label(label))
|
|
|
|
|
|
@bp.delete("/<label_id>")
|
|
@login_required
|
|
async def delete_label(label_id: str):
|
|
async with session_scope() as db:
|
|
label = await _get_owned_label(db, label_id)
|
|
if label is None:
|
|
return not_found()
|
|
await db.delete(label) # note_labels rows cascade
|
|
await db.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@bp.post("/<label_id>/merge")
|
|
@login_required
|
|
async def merge_label(label_id: str):
|
|
"""Merge `label_id` (source) INTO the label given by body {"into": <id>}: move
|
|
every note tagged with the source onto the target, then delete the source. Both
|
|
must be owned by the caller. Note-body `#tags` are NOT rewritten, so a note whose
|
|
body still literally contains the source #tag will re-mint that label on its next
|
|
edit — retire a tag by editing it out of the text (a known, documented nuance)."""
|
|
data = await request.get_json(silent=True) or {}
|
|
into = data.get("into")
|
|
async with session_scope() as db:
|
|
source = await _get_owned_label(db, label_id)
|
|
target = await _get_owned_label(db, str(into)) if into is not None else None
|
|
if source is None or target is None:
|
|
return not_found()
|
|
if source.id == target.id:
|
|
return json_error("cannot merge a tag into itself", 400)
|
|
await _merge_into(db, source, target)
|
|
count = await _label_note_count(db, target.id)
|
|
await db.commit()
|
|
return jsonify(_serialize_label(target, count))
|