"""Typed models for the labels domain. A `Label` is a row in the labels table — a user-defined (or auto-derived) tag. A `Group` is a *resolved* set of book_ids; the unit of comparison. Groups are always built by a Resolver, never instantiated by hand from the UI. """ from __future__ import annotations from dataclasses import dataclass from typing import Literal LabelKind = Literal["manual", "derived"] @dataclass(frozen=True) class Label: """One row of the labels table.""" label_id: str display_ar: str display_en: str | None description: str | None color: str | None kind: LabelKind created_at: str | None = None @property def display(self) -> str: """Best-effort display string. Arabic preferred per project default.""" return self.display_ar or self.display_en or self.label_id @property def is_derived(self) -> bool: return self.kind == "derived" @dataclass(frozen=True) class LabelWithCount: """Label augmented with how many books currently carry it. Used in pickers.""" label: Label book_count: int @dataclass(frozen=True) class GroupMember: """One member of a Group — a whole book, or one work (page range) inside it. Compare's per-work scoping axis. ``work_id`` set means this member is scoped to that work's page range within ``book_id`` (``page_start``/ ``page_end`` mirror the work's own range, echoed here so downstream strategy code never needs a repo round-trip). ``display`` optionally overrides the member's label — set to the work's ``display_title`` when ``work_id`` is set. A plain whole-book member is ``GroupMember(book_id=b)`` with everything else left at its default. """ book_id: str work_id: str | None = None page_start: int | None = None page_end: int | None = None display: str | None = None @dataclass(frozen=True) class Group: """A resolved set of books — the unit of comparison. `group_id` is a stable handle the UI / cache / saved-comparison-set can reference: 'label:first_millennium', 'book:deskolia_v3', 'manual:my_three_picks', 'union:label:antiochene+label:cappadocian', etc. `members` is the per-work scoping axis (optional). Every existing resolver (label/manual/book/filter/union/intersection) only ever sets `book_ids` and leaves `members` at its default `()` — `effective_members` derives one whole-book member per book_id for them, so their behavior is unchanged. Only the Compare wire path (`_group_from_wire`) populates `members` explicitly, when the caller picked specific works. """ group_id: str display_ar: str display_en: str | None book_ids: tuple[str, ...] members: tuple[GroupMember, ...] = () @property def display(self) -> str: return self.display_ar or self.display_en or self.group_id @property def effective_members(self) -> tuple[GroupMember, ...]: """`members` if set, else one whole-book `GroupMember` per `book_ids`.""" return self.members if self.members else tuple(GroupMember(book_id=b) for b in self.book_ids) @property def size(self) -> int: return len(self.effective_members) @property def is_empty(self) -> bool: return not self.effective_members