1804a2c622
Adds an ordered chapter layer to series. Reading order becomes (series_chapter.chapter_number, series_page.page_number); a chapter may be a placeholder reserving a slot, and carries an optional parsed stated-page range used to flag missing-page gaps. An image still lives in at most one series ⇒ one chapter (image_id stays UNIQUE). - models: series_chapter; series_page gains chapter_id (NOT NULL, cascade) + stated_page. Migration 0040 backfills every existing series into one auto-chapter holding its current flat pages — no data loss. - SeriesService: chapter CRUD (create/update/reorder/delete/merge), page→chapter assignment, reorder_pages, chapter-aware set_cover; list_pages now returns chapters[] + gaps[] alongside a back-compat flat pages[]. Legacy series-wide reorder operates on the single default chapter and rejects multi-chapter series. - API: chapter endpoints under /api/series/<tag>/chapters; POST pages accepts an optional chapter_id. - TagService.merge now repoints series_chapter too, so a merged series' chapters (and their pages) survive the source tag's deletion instead of cascading away. - Tests: new chapter suite; updated the 4 direct SeriesPage(...) constructions to supply chapter_id. Frontend (chapter-aware manage view + reader) lands next; until then the existing UI keeps working via the flat pages[] + single default chapter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
48 lines
1.9 KiB
Python
48 lines
1.9 KiB
Python
"""SeriesChapter — an ordered chapter/part within a series.
|
|
|
|
A series IS a Tag(kind='series'); a chapter groups ordered SeriesPages under it.
|
|
Reading order is (chapter.chapter_number, series_page.page_number): chapter_number
|
|
sets the order of chapters, page_number orders pages within a chapter.
|
|
|
|
chapter_number is an ordering key only (not unique) — reorder rewrites 1..N
|
|
wholesale, mirroring series_page.page_number, so a reorder can't transiently
|
|
collide on a unique index.
|
|
|
|
A chapter may be a placeholder (is_placeholder=True) — a reserved empty slot for
|
|
a section the operator doesn't have yet; it holds no pages and shows as a gap in
|
|
the reader. stated_page_start/end carry the page range parsed from the source
|
|
post (FC-6.2), used to flag missing-page gaps; both are nullable when unknown.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text, func
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from .base import Base
|
|
|
|
|
|
class SeriesChapter(Base):
|
|
__tablename__ = "series_chapter"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
series_tag_id: Mapped[int] = mapped_column(
|
|
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
chapter_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
is_placeholder: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, server_default="false"
|
|
)
|
|
stated_page_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
stated_page_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
nullable=False,
|
|
server_default=func.now(),
|
|
onupdate=func.now(),
|
|
)
|