feat(series): flat series sequence + cosmetic chapter dividers (#789 Phase 1)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m13s

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:
2026-06-11 21:30:01 -04:00
parent 3610ba495f
commit 59746d213d
18 changed files with 914 additions and 1141 deletions
+39 -64
View File
@@ -1,9 +1,11 @@
"""FC-6.2 — promote a post to a series + add a post as a chapter + browse list."""
"""FC-6.x — promote a post to a series + add a post's pages + browse list."""
import pytest
from sqlalchemy import select
from backend.app.models import Artist, ImageRecord, Post, TagKind
from backend.app.services.series_service import SeriesService
from backend.app.models.series_page import SeriesPage
from backend.app.services.series_service import SeriesError, SeriesService
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
@@ -53,12 +55,10 @@ async def test_promote_post_to_series(db):
assert out["name"] == "My Comic pages 1-3"
data = await svc.list_pages(out["series_tag_id"])
assert len(data["chapters"]) == 1
ch = data["chapters"][0]
assert ch["stated_page_start"] == 1
assert ch["stated_page_end"] == 3
assert [p["image_id"] for p in ch["pages"]] == imgs # capture order
assert [p["stated_page"] for p in ch["pages"]] == [1, 2, 3]
# one flat run in capture order, no dividers, stated pages parsed 1..3
assert [p["image_id"] for p in data["pages"]] == imgs
assert [p["stated_page"] for p in data["pages"]] == [1, 2, 3]
assert data["dividers"] == []
@pytest.mark.asyncio
@@ -66,31 +66,35 @@ async def test_promote_requires_images(db):
svc = SeriesService(db)
artist = await _artist(db, "Empty Artist")
post = await _post(db, artist, "No images", "pp2")
from backend.app.services.series_service import SeriesError
with pytest.raises(SeriesError):
await svc.promote_post_to_series(post.id)
@pytest.mark.asyncio
async def test_add_post_as_chapter_slots_by_stated_page(db):
async def test_add_post_appends_pages_to_the_run(db):
svc = SeriesService(db)
artist = await _artist(db, "Story Artist")
sid = (await TagService(db).find_or_create("Story", TagKind.series)).id
# An existing chapter stating pages 9-12.
await svc.create_chapter(sid, stated_page_start=9, stated_page_end=12)
# A post stating pages 1-4 should slot BEFORE the 9-12 chapter.
post = await _post(db, artist, "Story pages 1-4", "pp3")
await _post_images(db, post, artist, 4)
out = await svc.add_post_as_chapter(sid, post.id)
assert out["added"] == 4
# Seed the series with two existing pages.
seed_post = await _post(db, artist, "seed", "pp3a")
seed = await _post_images(db, seed_post, artist, 2)
await svc.add_images(sid, seed)
# A later post's pages append to the END (operator orders afterward).
post = await _post(db, artist, "Story pages 9-11", "pp3b")
imgs = await _post_images(db, post, artist, 3)
out = await svc.add_post(sid, post.id)
assert out["added"] == 3
data = await svc.list_pages(sid)
chapters = data["chapters"]
assert chapters[0]["stated_page_start"] == 1 # new one is first
assert chapters[1]["stated_page_start"] == 9
# gap 5-8 flagged between them
assert data["gaps"][0]["start"] == 5
assert data["gaps"][0]["end"] == 8
rows = (
await db.execute(
select(SeriesPage.image_id, SeriesPage.stated_page)
.where(SeriesPage.series_tag_id == sid)
.order_by(SeriesPage.page_number)
)
).all()
assert [r.image_id for r in rows] == seed + imgs
stated = {r.image_id: r.stated_page for r in rows}
assert [stated[i] for i in imgs] == [9, 10, 11]
@pytest.mark.asyncio
@@ -104,61 +108,32 @@ async def test_list_series_cards(db):
rows = await svc.list_series()
card = next(r for r in rows if r["id"] == out["series_tag_id"])
assert card["name"] == "Card Comic pages 1-2"
assert card["chapter_count"] == 1
assert card["chapter_count"] == 0 # a flat promote has no dividers
assert card["page_count"] == 2
assert card["artist_name"] == "Card Artist"
assert card["cover_thumbnail_url"]
assert card["has_gap"] is False
# artist filter keeps it; a different artist id drops it.
assert any(r["id"] == out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id))
assert all(r["id"] != out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id + 99999))
# --- FC-6.4: stated_part, part_gaps, source_post label ---------------------
assert any(
r["id"] == out["series_tag_id"]
for r in await svc.list_series(artist_id=artist.id)
)
assert all(
r["id"] != out["series_tag_id"]
for r in await svc.list_series(artist_id=artist.id + 99999)
)
@pytest.mark.asyncio
async def test_update_chapter_sets_and_clears_stated_part(db):
svc = SeriesService(db)
sid = (await TagService(db).find_or_create("Part Series", TagKind.series)).id
ch = await svc.create_chapter(sid)
# Set the installment to Part 2 (chapter_number stays its positional value).
await svc.update_chapter(sid, ch["id"], stated_part=2, set_part=True)
data = await svc.list_pages(sid)
assert data["chapters"][0]["stated_part"] == 2
assert data["chapters"][0]["chapter_number"] == 1
# Clearing it writes NULL back.
await svc.update_chapter(sid, ch["id"], stated_part=None, set_part=True)
data = await svc.list_pages(sid)
assert data["chapters"][0]["stated_part"] is None
@pytest.mark.asyncio
async def test_part_gaps_flagged_from_stated_part(db):
svc = SeriesService(db)
sid = (await TagService(db).find_or_create("Gappy Series", TagKind.series)).id
c1 = await svc.create_chapter(sid)
c3 = await svc.create_chapter(sid)
await svc.update_chapter(sid, c1["id"], stated_part=1, set_part=True)
await svc.update_chapter(sid, c3["id"], stated_part=3, set_part=True)
data = await svc.list_pages(sid)
assert len(data["part_gaps"]) == 1
gap = data["part_gaps"][0]
assert gap["after_chapter_id"] == c1["id"]
assert gap["start"] == 2
assert gap["end"] == 2
@pytest.mark.asyncio
async def test_source_post_label_when_pages_share_one_post(db):
async def test_source_post_label_is_per_page(db):
svc = SeriesService(db)
artist = await _artist(db, "Src Artist")
post = await _post(db, artist, "Source Comic pages 1-2", "pp5")
await _post_images(db, post, artist, 2)
out = await svc.promote_post_to_series(post.id)
data = await svc.list_pages(out["series_tag_id"])
sp = data["chapters"][0]["source_post"]
sp = data["pages"][0]["source_post"]
assert sp is not None
assert sp["id"] == post.id
assert sp["title"] == "Source Comic pages 1-2"