feat(series): flat series sequence + cosmetic chapter dividers (#789 Phase 1)
Reframe a series from "ordered chapters that own pages" to ONE flat, series-global ordered run of pages with optional cosmetic chapter DIVIDERS over it. A chapter no longer wraps content — it's a labeled divider anchored to the page that begins it; a page's chapter is derived as the nearest preceding divider. This is what lets installments assembled from multiple sources sit in one continuous, correctly-numbered sequence (operator's Goblin Juice case). - migration 0047: flatten each series to a series-global page_number (preserving today's reading order); convert each existing chapter to a divider anchored at its first page (keeping title/stated_part); drop series_page.chapter_id; reshape series_chapter (anchor_page_id UNIQUE FK, drop chapter_number/is_placeholder/stated_page_start/end). Loss-safe for content; drops empty placeholder chapters + a redundant page-1 divider. - series_page: page_number is now the series-global order; no chapter_id. - series_chapter: anchored divider (anchor_page_id, title, stated_part). - series_service: flat list_pages (one run + derived dividers + per-page source_post + part_gaps), series-wide reorder/renumber, divider CRUD (create/update/move/delete); retired per-chapter reorder/merge/placement. - api/tags: drop chapter_id from add; /chapters endpoints are divider create/update/delete (removed chapter reorder/merge/page-reorder). - series_match_service: series "end" reads max(series_page.stated_page); accept appends via add_post. tag_service series-merge appends src's pages after tgt's max so the merged series stays one clean run. - frontend: seriesManage store + SeriesManageView → one continuous drag-reorder grid with inline divider bars + series-global page numbers; reader walks the flat run, headings from dividers; PostSeriesMenu copy. - tests reworked across the series suite for the divider model. Phase 2 (pending staging for add-from-post) is separate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""series chapters become cosmetic dividers; pages become one series-global run
|
||||
|
||||
FC-6.x reframe (#789). A series is now ONE flat, series-global ordered run of
|
||||
pages; chapters stop owning pages and become labeled dividers anchored to the
|
||||
page that begins them.
|
||||
|
||||
Migration (order matters — series_page.chapter_id cascades, so it must be
|
||||
dropped BEFORE any chapter row is deleted, or pages would cascade away):
|
||||
a. Renumber series_page.page_number to a series-global 1..N (ordered by the
|
||||
OLD (chapter_number, page_number)).
|
||||
b. Add series_chapter.anchor_page_id and populate it with each chapter's first
|
||||
page (lowest new page_number).
|
||||
c. Drop series_page.chapter_id (severs the cascade link).
|
||||
d. Prune chapters that shouldn't become dividers: empty/placeholder ones (no
|
||||
anchor) and the redundant unlabeled chapter that would sit at page 1.
|
||||
e. Reshape series_chapter into the divider: drop chapter_number,
|
||||
is_placeholder, stated_page_start/end; make anchor_page_id NOT NULL +
|
||||
UNIQUE + FK→series_page ON DELETE CASCADE.
|
||||
|
||||
Revision ID: 0047
|
||||
Revises: 0046
|
||||
Create Date: 2026-06-11
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0047"
|
||||
down_revision: Union[str, None] = "0046"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# a. series-global page numbering, preserving the old reading order.
|
||||
op.execute(
|
||||
"""
|
||||
WITH ordered AS (
|
||||
SELECT sp.id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY sp.series_tag_id
|
||||
ORDER BY sc.chapter_number, sp.page_number, sp.id
|
||||
) AS rn
|
||||
FROM series_page sp
|
||||
JOIN series_chapter sc ON sc.id = sp.chapter_id
|
||||
)
|
||||
UPDATE series_page sp
|
||||
SET page_number = ordered.rn
|
||||
FROM ordered
|
||||
WHERE sp.id = ordered.id
|
||||
"""
|
||||
)
|
||||
|
||||
# b. anchor each existing chapter at its first page (lowest new page_number).
|
||||
op.add_column(
|
||||
"series_chapter",
|
||||
sa.Column("anchor_page_id", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
WITH firsts AS (
|
||||
SELECT DISTINCT ON (sp.chapter_id)
|
||||
sp.chapter_id, sp.id AS page_id
|
||||
FROM series_page sp
|
||||
ORDER BY sp.chapter_id, sp.page_number, sp.id
|
||||
)
|
||||
UPDATE series_chapter sc
|
||||
SET anchor_page_id = firsts.page_id
|
||||
FROM firsts
|
||||
WHERE firsts.chapter_id = sc.id
|
||||
"""
|
||||
)
|
||||
|
||||
# c. sever the ownership link (drops the FK + index with the column) BEFORE
|
||||
# pruning chapters, so deleting a chapter can't cascade-delete its pages.
|
||||
op.drop_column("series_page", "chapter_id")
|
||||
|
||||
# d. prune chapters that don't become dividers: placeholders / empty ones
|
||||
# (no anchor), and the unlabeled chapter that would land redundantly at
|
||||
# page 1 (the series just starts — no divider needed there).
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM series_chapter sc
|
||||
USING (
|
||||
SELECT sc2.id
|
||||
FROM series_chapter sc2
|
||||
LEFT JOIN series_page sp ON sp.id = sc2.anchor_page_id
|
||||
WHERE sc2.anchor_page_id IS NULL
|
||||
OR (sp.page_number = 1
|
||||
AND sc2.title IS NULL
|
||||
AND sc2.stated_part IS NULL)
|
||||
) gone
|
||||
WHERE sc.id = gone.id
|
||||
"""
|
||||
)
|
||||
|
||||
# e. reshape into the divider model.
|
||||
op.drop_column("series_chapter", "chapter_number")
|
||||
op.drop_column("series_chapter", "is_placeholder")
|
||||
op.drop_column("series_chapter", "stated_page_start")
|
||||
op.drop_column("series_chapter", "stated_page_end")
|
||||
op.alter_column("series_chapter", "anchor_page_id", nullable=False)
|
||||
op.create_unique_constraint(
|
||||
"uq_series_chapter_anchor_page", "series_chapter", ["anchor_page_id"]
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_series_chapter_anchor_page",
|
||||
"series_chapter",
|
||||
"series_page",
|
||||
["anchor_page_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Lossy: dividers can't be reconstructed as owning chapters. Collapse back to
|
||||
# exactly one chapter per series that owns all its pages in order.
|
||||
op.add_column(
|
||||
"series_page", sa.Column("chapter_id", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_series_chapter_anchor_page", "series_chapter", type_="foreignkey"
|
||||
)
|
||||
op.drop_constraint(
|
||||
"uq_series_chapter_anchor_page", "series_chapter", type_="unique"
|
||||
)
|
||||
op.drop_column("series_chapter", "anchor_page_id")
|
||||
op.add_column(
|
||||
"series_chapter",
|
||||
sa.Column(
|
||||
"chapter_number", sa.Integer(), nullable=False, server_default="1"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"series_chapter",
|
||||
sa.Column(
|
||||
"is_placeholder", sa.Boolean(), nullable=False,
|
||||
server_default="false",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"series_chapter",
|
||||
sa.Column("stated_page_start", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"series_chapter",
|
||||
sa.Column("stated_page_end", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.execute("DELETE FROM series_chapter")
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO series_chapter (series_tag_id, chapter_number)
|
||||
SELECT DISTINCT series_tag_id, 1 FROM series_page
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE series_page sp
|
||||
SET chapter_id = sc.id
|
||||
FROM series_chapter sc
|
||||
WHERE sc.series_tag_id = sp.series_tag_id
|
||||
"""
|
||||
)
|
||||
op.alter_column("series_page", "chapter_id", nullable=False)
|
||||
op.create_foreign_key(
|
||||
"fk_series_page_chapter",
|
||||
"series_page",
|
||||
"series_chapter",
|
||||
["chapter_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
Reference in New Issue
Block a user