"""Shared note↔label membership reconciliation. The "set a note's MANUAL (picker) labels, leave the tag-sourced ones alone" logic was duplicated line-for-line between the labels-picker API (notes.set_note_labels) and sync push (sync._apply_note_manual_labels). Single home so both stay in lockstep. via_tag=True rows track the body #tags and are governed by _reconcile_tags — this function never touches them.""" from __future__ import annotations from sqlalchemy import select from .models.label import Label, NoteLabel from .models.note import Note async def resolve_owned_label_ids(db, label_ids, owner_id) -> set: """Of `label_ids` (an iterable of UUIDs), the subset actually owned by `owner_id`. Callers parse/validate the raw ids first; this just enforces ownership.""" ids = list(label_ids) if not ids: return set() return set( (await db.scalars(select(Label.id).where(Label.owner_id == owner_id, Label.id.in_(ids)))).all() ) async def reconcile_manual_labels(db, note: Note, owned_label_ids: set) -> None: """Make the note's MANUAL (via_tag=False) memberships exactly `owned_label_ids`: drop manual rows no longer wanted, add missing ones. Tag-sourced rows survive untouched. Caller commits.""" existing = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all() attached = {r.label_id for r in existing} for r in existing: if not r.via_tag and r.label_id not in owned_label_ids: await db.delete(r) for lid in owned_label_ids: if lid not in attached: db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=False))