patristic-be / src /lib /labels /models.py
Mario33333's picture
deploy: main@3f5fd0f (works + reading + passage/multi-span notes; superset of f1c0d35)
bb36f39 verified
Raw
History Blame Contribute Delete
3.43 kB
"""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