feat(series): flat sequence + cosmetic dividers + pending staging (#789) #97

Merged
bvandeusen merged 2 commits from dev into main 2026-06-11 22:07:57 -04:00
18 changed files with 914 additions and 1141 deletions
Showing only changes of commit 59746d213d - Show all commits
@@ -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",
)
+27 -86
View File
@@ -391,14 +391,9 @@ async def series_add(tag_id: int):
ids, err = _parse_bulk_ids(body, max_ids=500) ids, err = _parse_bulk_ids(body, max_ids=500)
if err: if err:
return err return err
chapter_id, cerr = _opt_int(body, "chapter_id")
if cerr:
return cerr
async with get_session() as session: async with get_session() as session:
try: try:
n = await SeriesService(session).add_images( n = await SeriesService(session).add_images(tag_id, ids)
tag_id, ids, chapter_id=chapter_id
)
except SeriesError as exc: except SeriesError as exc:
return _series_err(exc) return _series_err(exc)
await session.commit() await session.commit()
@@ -453,30 +448,30 @@ async def series_cover(tag_id: int):
return jsonify({"ok": True}) return jsonify({"ok": True})
# ---- chapters (FC-6.1) ---------------------------------------------------- # ---- chapter dividers (FC-6.x) -------------------------------------------
# A chapter is a cosmetic divider anchored to the page that begins it; it owns
# no pages. Page ordering is series-wide (the /reorder endpoint above), so there
# is no per-chapter reorder/merge/chapter-reorder — those are gone.
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"]) @tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
async def series_chapter_create(tag_id: int): async def series_chapter_create(tag_id: int):
body = await request.get_json() or {} body = await request.get_json() or {}
anchor, aerr = _opt_int(body, "anchor_image_id")
if aerr:
return aerr
if anchor is None:
return jsonify({"error": "anchor_image_id required"}), 400
title = body.get("title") title = body.get("title")
if title is not None and not isinstance(title, str): if title is not None and not isinstance(title, str):
return jsonify({"error": "title must be a string"}), 400 return jsonify({"error": "title must be a string"}), 400
is_placeholder = bool(body.get("is_placeholder", False)) part, perr = _opt_int(body, "stated_part")
start, serr = _opt_int(body, "stated_page_start") if perr:
if serr: return perr
return serr
end, eerr = _opt_int(body, "stated_page_end")
if eerr:
return eerr
async with get_session() as session: async with get_session() as session:
try: try:
ch = await SeriesService(session).create_chapter( ch = await SeriesService(session).create_divider(
tag_id, tag_id, anchor, title=title, stated_part=part,
title=title,
is_placeholder=is_placeholder,
stated_page_start=start,
stated_page_end=end,
) )
except SeriesError as exc: except SeriesError as exc:
return _series_err(exc) return _series_err(exc)
@@ -484,21 +479,6 @@ async def series_chapter_create(tag_id: int):
return jsonify(ch) return jsonify(ch)
@tags_bp.route("/series/<int:tag_id>/chapters/reorder", methods=["POST"])
async def series_chapter_reorder(tag_id: int):
body = await request.get_json()
ids, err = _parse_int_list(body, "chapter_ids")
if err:
return err
async with get_session() as session:
try:
await SeriesService(session).reorder_chapters(tag_id, ids)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route( @tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["PATCH"] "/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["PATCH"]
) )
@@ -514,19 +494,18 @@ async def series_chapter_update(tag_id: int, chapter_id: int):
if perr: if perr:
return perr return perr
kwargs.update(set_part=True, stated_part=part) kwargs.update(set_part=True, stated_part=part)
if "stated_page_start" in body: if "anchor_image_id" in body:
start, serr = _opt_int(body, "stated_page_start") anchor, aerr = _opt_int(body, "anchor_image_id")
if serr: if aerr:
return serr return aerr
kwargs.update(set_start=True, stated_page_start=start) if anchor is None:
if "stated_page_end" in body: return jsonify(
end, eerr = _opt_int(body, "stated_page_end") {"error": "anchor_image_id must be an integer"}
if eerr: ), 400
return eerr kwargs.update(set_anchor=True, anchor_image_id=anchor)
kwargs.update(set_end=True, stated_page_end=end)
async with get_session() as session: async with get_session() as session:
try: try:
await SeriesService(session).update_chapter( await SeriesService(session).update_divider(
tag_id, chapter_id, **kwargs tag_id, chapter_id, **kwargs
) )
except SeriesError as exc: except SeriesError as exc:
@@ -541,45 +520,7 @@ async def series_chapter_update(tag_id: int, chapter_id: int):
async def series_chapter_delete(tag_id: int, chapter_id: int): async def series_chapter_delete(tag_id: int, chapter_id: int):
async with get_session() as session: async with get_session() as session:
try: try:
await SeriesService(session).delete_chapter(tag_id, chapter_id) await SeriesService(session).delete_divider(tag_id, chapter_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>/merge", methods=["POST"]
)
async def series_chapter_merge(tag_id: int, chapter_id: int):
body = await request.get_json()
target, terr = _opt_int(body, "target_chapter_id")
if terr:
return terr
if target is None:
return jsonify({"error": "target_chapter_id required"}), 400
async with get_session() as session:
try:
moved = await SeriesService(session).merge_chapter(
tag_id, chapter_id, target
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"moved_count": moved})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>/reorder", methods=["POST"]
)
async def series_chapter_reorder_pages(tag_id: int, chapter_id: int):
body = await request.get_json()
ids, err = _parse_bulk_ids(body, max_ids=500)
if err:
return err
async with get_session() as session:
try:
await SeriesService(session).reorder_pages(tag_id, chapter_id, ids)
except SeriesError as exc: except SeriesError as exc:
return _series_err(exc) return _series_err(exc)
await session.commit() await session.commit()
@@ -635,7 +576,7 @@ async def series_add_post(tag_id: int):
return jsonify({"error": "post_id required"}), 400 return jsonify({"error": "post_id required"}), 400
async with get_session() as session: async with get_session() as session:
try: try:
out = await SeriesService(session).add_post_as_chapter(tag_id, post_id) out = await SeriesService(session).add_post(tag_id, post_id)
except SeriesError as exc: except SeriesError as exc:
return _series_err(exc) return _series_err(exc)
await session.commit() await session.commit()
+18 -25
View File
@@ -1,28 +1,22 @@
"""SeriesChapter — an ordered chapter/part within a series. """SeriesChapter — a cosmetic chapter DIVIDER within a series (FC-6.x reframe).
A series IS a Tag(kind='series'); a chapter groups ordered SeriesPages under it. A series is ONE flat, series-global ordered run of SeriesPages. A chapter is NOT
Reading order is (chapter.chapter_number, series_page.page_number): chapter_number a container — it owns no pages. It is a labeled divider anchored to the page that
sets the order of chapters, page_number orders pages within a chapter. BEGINS the chapter (anchor_page_id → series_page): "a new chapter starts here."
A page's chapter is derived at read time as the nearest preceding divider.
chapter_number is an ordering key only (not unique) — reorder rewrites 1..N Dividers never affect page ordering or the series-global page numbers; they stay
wholesale, mirroring series_page.page_number, so a reorder can't transiently pinned to their anchor page across reorders. anchor_page_id is UNIQUE — at most
collide on a unique index. one chapter begins at a given page — and FK-cascades, so removing the anchor page
from the series drops the divider (the chapter merges into the preceding run).
A chapter may be a placeholder (is_placeholder=True) — a reserved empty slot for title is the optional chapter name; stated_part is the optional operator-facing
a section the operator doesn't have yet; it holds no pages and shows as a gap in "Part N" label (shown instead of a derived ordinal when set).
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.
stated_part is the operator-facing "Part N" label (FC-6.4), separate from the
positional chapter_number: chapter_number is auto-managed ordering (rewritten
1..N on reorder/delete), while stated_part is the real installment number the
operator types — e.g. a series authored from a post that is Part 2 of a story.
Nullable when unset (the UI then falls back to showing chapter_number).
""" """
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text, func from sqlalchemy import DateTime, ForeignKey, Integer, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@@ -35,14 +29,13 @@ class SeriesChapter(Base):
series_tag_id: Mapped[int] = mapped_column( series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
) )
chapter_number: Mapped[int] = mapped_column(Integer, nullable=False) anchor_page_id: Mapped[int] = mapped_column(
stated_part: Mapped[int | None] = mapped_column(Integer, nullable=True) ForeignKey("series_page.id", ondelete="CASCADE"),
title: Mapped[str | None] = mapped_column(Text, nullable=True) nullable=False,
is_placeholder: Mapped[bool] = mapped_column( unique=True,
Boolean, nullable=False, server_default="false"
) )
stated_page_start: Mapped[int | None] = mapped_column(Integer, nullable=True) title: Mapped[str | None] = mapped_column(Text, nullable=True)
stated_page_end: Mapped[int | None] = mapped_column(Integer, nullable=True) stated_part: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
+10 -12
View File
@@ -1,12 +1,15 @@
"""SeriesPage — ordered image membership for a series-kind Tag. """SeriesPage — ordered image membership for a series-kind Tag.
A series IS a Tag with kind='series'; series_page gives it ordered pages, A series IS a Tag with kind='series'; series_page gives it a SINGLE flat,
grouped into chapters (FC-6). An image belongs to at most one series series-global ordered run of pages (FC-6.x divider reframe). An image belongs to
(UNIQUE image_id) ⇒ at most one chapter. Reading order is at most one series (UNIQUE image_id). Reading order is `page_number` alone — a
(chapter.chapter_number, series_page.page_number): page_number orders pages series-wide ordering key (not unique), rewritten 1..N wholesale on reorder so a
WITHIN a chapter and is an ordering key only (not unique) — reorder rewrites reorder can't transiently collide on an index.
1..N wholesale. stated_page carries the page number parsed from the source
post (FC-6.2), nullable when unknown. Chapters are cosmetic DIVIDERS anchored to a page (see SeriesChapter); they do
NOT own pages, so there is no chapter_id here — a page's chapter is derived at
read time as the nearest preceding divider. stated_page carries the printed page
number parsed from the source post, nullable when unknown.
""" """
from datetime import datetime from datetime import datetime
@@ -24,11 +27,6 @@ class SeriesPage(Base):
series_tag_id: Mapped[int] = mapped_column( series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
) )
chapter_id: Mapped[int] = mapped_column(
ForeignKey("series_chapter.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
image_id: Mapped[int] = mapped_column( image_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"), ForeignKey("image_record.id", ondelete="CASCADE"),
nullable=False, nullable=False,
+5 -4
View File
@@ -18,7 +18,6 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ImageRecord, Post, Tag, TagKind from ..models import ImageRecord, Post, Tag, TagKind
from ..models.series_chapter import SeriesChapter
from ..models.series_page import SeriesPage from ..models.series_page import SeriesPage
from ..models.series_suggestion import SeriesSuggestion from ..models.series_suggestion import SeriesSuggestion
from ..models.tag import image_tag from ..models.tag import image_tag
@@ -144,9 +143,11 @@ class SeriesMatchService:
return [t for t in rows if t] return [t for t in rows if t]
async def _series_max_stated_end(self, series_tag_id: int) -> int | None: async def _series_max_stated_end(self, series_tag_id: int) -> int | None:
# Highest printed page across the series' pages (FC-6.x: stated pages
# live on series_page now; chapters are cosmetic dividers).
return await self.session.scalar( return await self.session.scalar(
select(func.max(SeriesChapter.stated_page_end)).where( select(func.max(SeriesPage.stated_page)).where(
SeriesChapter.series_tag_id == series_tag_id SeriesPage.series_tag_id == series_tag_id
) )
) )
@@ -288,7 +289,7 @@ class SeriesMatchService:
raise SeriesError(f"suggestion {suggestion_id} not found") raise SeriesError(f"suggestion {suggestion_id} not found")
if s.status != "pending": if s.status != "pending":
raise SeriesError(f"suggestion {suggestion_id} is already {s.status}") raise SeriesError(f"suggestion {suggestion_id} is already {s.status}")
out = await SeriesService(self.session).add_post_as_chapter( out = await SeriesService(self.session).add_post(
s.series_tag_id, s.post_id s.series_tag_id, s.post_id
) )
s.status = "added" s.status = "added"
+236 -409
View File
@@ -1,14 +1,19 @@
"""Series = a series-kind Tag + ordered chapters, each holding ordered pages. """Series = a series-kind Tag + ONE flat, series-global ordered run of pages,
with optional cosmetic chapter DIVIDERS over it (FC-6.x reframe, #789).
Reading order is (series_chapter.chapter_number, series_page.page_number). A Reading order is series_page.page_number alone (rewritten 1..N wholesale on any
chapter may be a placeholder (no pages) reserving a slot. An image lives in at reorder; page_number is not unique, so a reorder never transiently collides). A
most one series ⇒ one chapter (series_page.image_id is UNIQUE). chapter is NOT a container — it is a labeled divider anchored to the page that
begins it (series_chapter.anchor_page_id); a page's chapter is derived as the
nearest preceding divider. An image lives in at most one series
(series_page.image_id is UNIQUE).
All mutations are Core/set-based and run in the request transaction. All mutations are Core/set-based and run in the request transaction.
""" """
from sqlalchemy import and_, func, select, update from sqlalchemy import and_, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, ImageRecord, Post, Tag, TagKind from ..models import Artist, ImageRecord, Post, Tag, TagKind
@@ -51,12 +56,56 @@ class SeriesService:
out.append(int(x)) out.append(int(x))
return out return out
async def _require_chapter(self, series_tag_id: int, chapter_id: int): async def _page_order(self, series_tag_id: int) -> list[int]:
"""The series' image_ids in reading order (series-global page_number)."""
rows = (
await self.session.execute(
select(SeriesPage.image_id)
.where(SeriesPage.series_tag_id == series_tag_id)
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
)
).scalars().all()
return list(rows)
async def _page_id_for(self, series_tag_id: int, image_id: int) -> int:
"""series_page.id for an image in the series (raises if not a member)."""
pid = await self.session.scalar(
select(SeriesPage.id).where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == image_id,
)
)
)
if pid is None:
raise SeriesError(f"image {image_id} is not in this series")
return pid
async def _renumber(self, series_tag_id: int) -> None:
"""Compact page_number to 1..N preserving current order (set-based)."""
await self.session.execute(
text(
"""
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (
ORDER BY page_number, id
) AS rn
FROM series_page WHERE series_tag_id = :sid
)
UPDATE series_page sp SET page_number = ordered.rn
FROM ordered
WHERE sp.id = ordered.id AND sp.page_number <> ordered.rn
"""
),
{"sid": series_tag_id},
)
async def _require_divider(self, series_tag_id: int, divider_id: int):
row = ( row = (
await self.session.execute( await self.session.execute(
select(SeriesChapter).where( select(SeriesChapter).where(
and_( and_(
SeriesChapter.id == chapter_id, SeriesChapter.id == divider_id,
SeriesChapter.series_tag_id == series_tag_id, SeriesChapter.series_tag_id == series_tag_id,
) )
) )
@@ -64,94 +113,32 @@ class SeriesService:
).scalar_one_or_none() ).scalar_one_or_none()
if row is None: if row is None:
raise SeriesError( raise SeriesError(
f"chapter {chapter_id} is not in series {series_tag_id}" f"chapter {divider_id} is not in series {series_tag_id}"
) )
return row return row
async def _ensure_default_chapter(self, series_tag_id: int) -> int:
"""Lowest-numbered chapter of the series, creating a first chapter if
the series has none (a fresh series, or the legacy single-chapter path)."""
existing = await self.session.scalar(
select(SeriesChapter.id)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(SeriesChapter.chapter_number.asc())
.limit(1)
)
if existing is not None:
return existing
res = await self.session.execute(
pg_insert(SeriesChapter)
.values(series_tag_id=series_tag_id, chapter_number=1)
.returning(SeriesChapter.id)
)
return res.scalar_one()
async def _chapter_page_order(self, chapter_id: int) -> list[int]:
rows = (
await self.session.execute(
select(SeriesPage.image_id)
.where(SeriesPage.chapter_id == chapter_id)
.order_by(SeriesPage.page_number.asc())
)
).scalars().all()
return list(rows)
async def _chapter_order(self, series_tag_id: int) -> list[int]:
rows = (
await self.session.execute(
select(SeriesChapter.id)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(SeriesChapter.chapter_number.asc())
)
).scalars().all()
return list(rows)
# ---- read ------------------------------------------------------------- # ---- read -------------------------------------------------------------
@staticmethod @staticmethod
def _gaps(chapters: list[dict]) -> list[dict]: def _part_gaps(dividers: list[dict]) -> list[dict]:
"""Missing-page gaps between consecutive chapters with stated ranges.""" """Missing-Part hints between consecutive dividers whose stated_part
numbers jump by more than 1 (e.g. Part 1 then Part 3). Only dividers
that carry a stated_part participate."""
out: list[dict] = [] out: list[dict] = []
prev = None prev = None
for ch in chapters: for d in dividers:
start = ch["stated_page_start"] cur = d["stated_part"]
if (
prev is not None
and prev["stated_page_end"] is not None
and start is not None
and start > prev["stated_page_end"] + 1
):
out.append(
{
"after_chapter_id": prev["id"],
"start": prev["stated_page_end"] + 1,
"end": start - 1,
}
)
prev = ch
return out
@staticmethod
def _part_gaps(chapters: list[dict]) -> list[dict]:
"""Missing-Part gaps between consecutive chapters whose stated_part
numbers jump by more than 1 (e.g. a series with Part 1 and Part 3, or one
authored straight from a Part 2 post). Mirrors _gaps but on stated_part —
only chapters that actually carry a stated_part participate."""
out: list[dict] = []
prev = None
for ch in chapters:
cur = ch["stated_part"]
if cur is None: if cur is None:
continue continue
if prev is not None and cur > prev["stated_part"] + 1: if prev is not None and cur > prev["stated_part"] + 1:
out.append( out.append(
{ {
"after_chapter_id": prev["id"], "after_divider_id": prev["id"],
"start": prev["stated_part"] + 1, "start": prev["stated_part"] + 1,
"end": cur - 1, "end": cur - 1,
} }
) )
prev = ch prev = d
return out return out
async def list_pages(self, series_tag_id: int) -> dict: async def list_pages(self, series_tag_id: int) -> dict:
@@ -159,13 +146,6 @@ class SeriesService:
rows = ( rows = (
await self.session.execute( await self.session.execute(
select( select(
SeriesChapter.id.label("chapter_id"),
SeriesChapter.chapter_number,
SeriesChapter.stated_part,
SeriesChapter.title,
SeriesChapter.is_placeholder,
SeriesChapter.stated_page_start,
SeriesChapter.stated_page_end,
SeriesPage.image_id, SeriesPage.image_id,
SeriesPage.page_number, SeriesPage.page_number,
SeriesPage.stated_page, SeriesPage.stated_page,
@@ -176,71 +156,62 @@ class SeriesService:
ImageRecord.primary_post_id, ImageRecord.primary_post_id,
Post.post_title, Post.post_title,
) )
.select_from(SeriesChapter) .select_from(SeriesPage)
.outerjoin(SeriesPage, SeriesPage.chapter_id == SeriesChapter.id) .join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.outerjoin(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.outerjoin(Post, Post.id == ImageRecord.primary_post_id) .outerjoin(Post, Post.id == ImageRecord.primary_post_id)
.where(SeriesChapter.series_tag_id == series_tag_id) .where(SeriesPage.series_tag_id == series_tag_id)
.order_by( .order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
SeriesChapter.chapter_number.asc(),
SeriesPage.page_number.asc(),
)
) )
).all() ).all()
pages = [
chapters: list[dict] = [] {
flat: list[dict] = []
by_id: dict[int, dict] = {}
# chapter_id -> {post_id: title} seen across its pages, so we can label a
# chapter with its source post when all its pages come from one post.
posts_seen: dict[int, dict[int, str | None]] = {}
for r in rows:
ch = by_id.get(r.chapter_id)
if ch is None:
ch = {
"id": r.chapter_id,
"chapter_number": r.chapter_number,
"stated_part": r.stated_part,
"title": r.title,
"is_placeholder": r.is_placeholder,
"stated_page_start": r.stated_page_start,
"stated_page_end": r.stated_page_end,
"source_post": None,
"pages": [],
}
by_id[r.chapter_id] = ch
posts_seen[r.chapter_id] = {}
chapters.append(ch)
if r.image_id is None:
continue # placeholder / empty chapter
if r.primary_post_id is not None:
posts_seen[r.chapter_id][r.primary_post_id] = r.post_title
page = {
"image_id": r.image_id, "image_id": r.image_id,
"chapter_id": r.chapter_id,
"page_number": r.page_number, "page_number": r.page_number,
"stated_page": r.stated_page, "stated_page": r.stated_page,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime), "thumbnail_url": thumbnail_url(
r.thumbnail_path, r.sha256, r.mime
),
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}", "image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
"source_post": (
{"id": r.primary_post_id, "title": r.post_title}
if r.primary_post_id is not None
else None
),
} }
ch["pages"].append(page) for r in rows
flat.append(page) ]
# A chapter's source_post is set only when every page shares one post — # Dividers, ordered by the global position of their anchor page.
# the common case (a series authored from a post). Mixed chapters stay drows = (
# null rather than guessing. await self.session.execute(
for ch in chapters: select(
seen = posts_seen.get(ch["id"], {}) SeriesChapter.id,
if len(seen) == 1: SeriesChapter.title,
pid, title = next(iter(seen.items())) SeriesChapter.stated_part,
ch["source_post"] = {"id": pid, "title": title} SeriesPage.image_id.label("anchor_image_id"),
SeriesPage.page_number,
)
.join(SeriesPage, SeriesPage.id == SeriesChapter.anchor_page_id)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(SeriesPage.page_number.asc())
)
).all()
dividers = [
{
"id": r.id,
"anchor_image_id": r.anchor_image_id,
"page_number": r.page_number,
"title": r.title,
"stated_part": r.stated_part,
}
for r in drows
]
return { return {
"series": {"id": tag.id, "name": tag.name}, "series": {"id": tag.id, "name": tag.name},
"chapters": chapters, "pages": pages,
"pages": flat, # back-compat: flat reading order across chapters "dividers": dividers,
"gaps": self._gaps(chapters), "part_gaps": self._part_gaps(dividers),
"part_gaps": self._part_gaps(chapters),
} }
# ---- pages ------------------------------------------------------------ # ---- pages ------------------------------------------------------------
@@ -249,19 +220,13 @@ class SeriesService:
self, self,
series_tag_id: int, series_tag_id: int,
image_ids: list[int], image_ids: list[int],
chapter_id: int | None = None,
stated_pages: dict[int, int] | None = None, stated_pages: dict[int, int] | None = None,
) -> int: ) -> int:
"""Append images as pages of a chapter (the series' default chapter when """Append images as pages at the END of the series' flat run. Images
chapter_id is None). Images already in THIS series are left untouched; already in THIS series are left untouched; images in another series are
images in another series are moved here (image_id is UNIQUE).""" moved here (image_id is UNIQUE)."""
await self._require_series(series_tag_id) await self._require_series(series_tag_id)
ids = self._clean_ids(image_ids) ids = self._clean_ids(image_ids)
if chapter_id is None:
chapter_id = await self._ensure_default_chapter(series_tag_id)
else:
await self._require_chapter(series_tag_id, chapter_id)
existing = dict( existing = dict(
( (
await self.session.execute( await self.session.execute(
@@ -273,15 +238,16 @@ class SeriesService:
to_add = [i for i in ids if existing.get(i) != series_tag_id] to_add = [i for i in ids if existing.get(i) != series_tag_id]
if not to_add: if not to_add:
return 0 return 0
# Move: drop any prior membership for these images (UNIQUE image_id). # Move: drop any prior membership for these images (UNIQUE image_id). A
# dropped page that anchored a divider in another series cascades it away.
await self.session.execute( await self.session.execute(
SeriesPage.__table__.delete().where(SeriesPage.image_id.in_(to_add)) SeriesPage.__table__.delete().where(SeriesPage.image_id.in_(to_add))
) )
max_pn = ( max_pn = (
await self.session.scalar( await self.session.scalar(
select(func.coalesce(func.max(SeriesPage.page_number), 0)).where( select(
SeriesPage.chapter_id == chapter_id func.coalesce(func.max(SeriesPage.page_number), 0)
) ).where(SeriesPage.series_tag_id == series_tag_id)
) )
) or 0 ) or 0
sp = stated_pages or {} sp = stated_pages or {}
@@ -290,7 +256,6 @@ class SeriesService:
[ [
{ {
"series_tag_id": series_tag_id, "series_tag_id": series_tag_id,
"chapter_id": chapter_id,
"image_id": iid, "image_id": iid,
"page_number": max_pn + offset, "page_number": max_pn + offset,
"stated_page": sp.get(iid), "stated_page": sp.get(iid),
@@ -314,220 +279,134 @@ class SeriesService:
) )
) )
) )
# Dividers anchored to a removed page cascade away (chapter merges into
# the preceding run); compact the remaining pages back to 1..N.
await self._renumber(series_tag_id)
return res.rowcount or 0 return res.rowcount or 0
async def reorder_pages( async def reorder(
self, series_tag_id: int, chapter_id: int, ordered_image_ids: list[int] self, series_tag_id: int, ordered_image_ids: list[int]
) -> None: ) -> None:
"""Series-wide reorder. ordered_image_ids must be exactly the series'
pages; page_number is rewritten 1..N in that order."""
await self._require_series(series_tag_id) await self._require_series(series_tag_id)
await self._require_chapter(series_tag_id, chapter_id)
ordered = self._clean_ids(ordered_image_ids) ordered = self._clean_ids(ordered_image_ids)
current = set(await self._chapter_page_order(chapter_id)) current = set(await self._page_order(series_tag_id))
if set(ordered) != current or len(ordered) != len(current): if set(ordered) != current or len(ordered) != len(current):
raise SeriesError( raise SeriesError(
"ordered image_ids must exactly match the chapter's pages" "ordered image_ids must exactly match the series' pages"
) )
for idx, iid in enumerate(ordered, start=1): for idx, iid in enumerate(ordered, start=1):
await self.session.execute( await self.session.execute(
update(SeriesPage) update(SeriesPage)
.where( .where(
and_( and_(
SeriesPage.chapter_id == chapter_id, SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == iid, SeriesPage.image_id == iid,
) )
) )
.values(page_number=idx) .values(page_number=idx)
) )
async def reorder( async def set_cover(self, series_tag_id: int, image_id: int) -> None:
self, series_tag_id: int, ordered_image_ids: list[int] """Cover = the first page. Move the image to the front of the series."""
) -> None:
"""Legacy series-wide reorder — reorders the default chapter. Valid only
for single-chapter series (the only shape the pre-chapter UI produced);
multi-chapter series must use reorder_pages with an explicit chapter."""
await self._require_series(series_tag_id) await self._require_series(series_tag_id)
chapters = await self._chapter_order(series_tag_id) pages = await self._page_order(series_tag_id)
if len(chapters) > 1: if image_id not in pages:
raise SeriesError( raise SeriesError(f"image {image_id} is not in this series")
"series has multiple chapters; reorder a specific chapter" if pages and pages[0] != image_id:
await self.reorder(
series_tag_id,
[image_id] + [p for p in pages if p != image_id],
) )
chapter_id = (
chapters[0]
if chapters
else await self._ensure_default_chapter(series_tag_id)
)
await self.reorder_pages(series_tag_id, chapter_id, ordered_image_ids)
# ---- chapters --------------------------------------------------------- # ---- chapter dividers -------------------------------------------------
async def create_chapter( async def create_divider(
self, self,
series_tag_id: int, series_tag_id: int,
*, anchor_image_id: int,
title: str | None = None,
is_placeholder: bool = False,
stated_page_start: int | None = None,
stated_page_end: int | None = None,
) -> dict:
await self._require_series(series_tag_id)
max_cn = (
await self.session.scalar(
select(
func.coalesce(func.max(SeriesChapter.chapter_number), 0)
).where(SeriesChapter.series_tag_id == series_tag_id)
)
) or 0
res = await self.session.execute(
pg_insert(SeriesChapter)
.values(
series_tag_id=series_tag_id,
chapter_number=max_cn + 1,
title=title,
is_placeholder=is_placeholder,
stated_page_start=stated_page_start,
stated_page_end=stated_page_end,
)
.returning(SeriesChapter.id, SeriesChapter.chapter_number)
)
row = res.one()
return {"id": row.id, "chapter_number": row.chapter_number}
async def update_chapter(
self,
series_tag_id: int,
chapter_id: int,
*, *,
title: str | None = None, title: str | None = None,
stated_part: int | None = None, stated_part: int | None = None,
stated_page_start: int | None = None, ) -> dict:
stated_page_end: int | None = None, """Place a chapter divider before the page for anchor_image_id ("a new
chapter starts here"). One divider per page (UNIQUE anchor)."""
await self._require_series(series_tag_id)
page_id = await self._page_id_for(series_tag_id, anchor_image_id)
try:
async with self.session.begin_nested():
res = await self.session.execute(
pg_insert(SeriesChapter)
.values(
series_tag_id=series_tag_id,
anchor_page_id=page_id,
title=title,
stated_part=stated_part,
)
.returning(SeriesChapter.id)
)
divider_id = res.scalar_one()
except IntegrityError as exc:
raise SeriesError(
"a chapter divider already starts at that page"
) from exc
return {"id": divider_id, "anchor_image_id": anchor_image_id}
async def update_divider(
self,
series_tag_id: int,
divider_id: int,
*,
title: str | None = None,
stated_part: int | None = None,
anchor_image_id: int | None = None,
set_title: bool = False, set_title: bool = False,
set_part: bool = False, set_part: bool = False,
set_start: bool = False, set_anchor: bool = False,
set_end: bool = False,
) -> None: ) -> None:
"""Partial chapter edit. The set_* flags say which fields to write (so """Partial divider edit. set_* flags say which fields to write (so None
None can be written explicitly, e.g. clearing a stated page or part).""" can be written explicitly, e.g. clearing a stated_part). set_anchor moves
the divider to sit before a different page."""
await self._require_series(series_tag_id) await self._require_series(series_tag_id)
await self._require_chapter(series_tag_id, chapter_id) await self._require_divider(series_tag_id, divider_id)
values: dict = {} values: dict = {}
if set_title: if set_title:
values["title"] = title values["title"] = title
if set_part: if set_part:
values["stated_part"] = stated_part values["stated_part"] = stated_part
if set_start: if set_anchor:
values["stated_page_start"] = stated_page_start if anchor_image_id is None:
if set_end: raise SeriesError("anchor_image_id required to move a divider")
values["stated_page_end"] = stated_page_end values["anchor_page_id"] = await self._page_id_for(
series_tag_id, anchor_image_id
)
if not values: if not values:
return return
await self.session.execute( try:
update(SeriesChapter) async with self.session.begin_nested():
.where(SeriesChapter.id == chapter_id) await self.session.execute(
.values(**values) update(SeriesChapter)
) .where(SeriesChapter.id == divider_id)
.values(**values)
async def _renumber_chapters(self, series_tag_id: int) -> None: )
for idx, cid in enumerate( except IntegrityError as exc:
await self._chapter_order(series_tag_id), start=1
):
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.id == cid)
.values(chapter_number=idx)
)
async def reorder_chapters(
self, series_tag_id: int, ordered_chapter_ids: list[int]
) -> None:
await self._require_series(series_tag_id)
ordered = self._clean_ids(ordered_chapter_ids)
current = set(await self._chapter_order(series_tag_id))
if set(ordered) != current or len(ordered) != len(current):
raise SeriesError( raise SeriesError(
"ordered chapter_ids must exactly match the series' chapters" "a chapter divider already starts at that page"
) ) from exc
for idx, cid in enumerate(ordered, start=1):
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.id == cid)
.values(chapter_number=idx)
)
async def delete_chapter(self, series_tag_id: int, chapter_id: int) -> None: async def delete_divider(
self, series_tag_id: int, divider_id: int
) -> None:
"""Remove a divider — the chapter merges into the run above it. Pages
are untouched (dividers own nothing)."""
await self._require_series(series_tag_id) await self._require_series(series_tag_id)
await self._require_chapter(series_tag_id, chapter_id) await self._require_divider(series_tag_id, divider_id)
# Pages cascade-delete with the chapter (FK ondelete=CASCADE).
await self.session.execute( await self.session.execute(
SeriesChapter.__table__.delete().where( SeriesChapter.__table__.delete().where(
SeriesChapter.id == chapter_id SeriesChapter.id == divider_id
) )
) )
await self._renumber_chapters(series_tag_id)
async def merge_chapter(
self, series_tag_id: int, source_chapter_id: int, target_chapter_id: int
) -> int:
"""Move source chapter's pages onto the end of the target chapter, then
delete the now-empty source chapter. Returns the number of pages moved."""
await self._require_series(series_tag_id)
if source_chapter_id == target_chapter_id:
raise SeriesError("cannot merge a chapter into itself")
await self._require_chapter(series_tag_id, source_chapter_id)
await self._require_chapter(series_tag_id, target_chapter_id)
moving = await self._chapter_page_order(source_chapter_id)
max_pn = (
await self.session.scalar(
select(func.coalesce(func.max(SeriesPage.page_number), 0)).where(
SeriesPage.chapter_id == target_chapter_id
)
)
) or 0
for offset, iid in enumerate(moving, start=1):
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.chapter_id == source_chapter_id,
SeriesPage.image_id == iid,
)
)
.values(chapter_id=target_chapter_id, page_number=max_pn + offset)
)
await self.session.execute(
SeriesChapter.__table__.delete().where(
SeriesChapter.id == source_chapter_id
)
)
await self._renumber_chapters(series_tag_id)
return len(moving)
async def set_cover(self, series_tag_id: int, image_id: int) -> None:
"""Cover = the (chapter_number=1, page_number=1) image. Bring the image's
chapter to the front and the image to the front of its chapter."""
await self._require_series(series_tag_id)
row = (
await self.session.execute(
select(SeriesPage.chapter_id)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == image_id,
)
)
)
).scalar_one_or_none()
if row is None:
raise SeriesError(f"image {image_id} is not in this series")
chapter_id = row
chapters = await self._chapter_order(series_tag_id)
if chapters and chapters[0] != chapter_id:
new_chapters = [chapter_id] + [c for c in chapters if c != chapter_id]
await self.reorder_chapters(series_tag_id, new_chapters)
pages = await self._chapter_page_order(chapter_id)
if pages and pages[0] != image_id:
new_pages = [image_id] + [p for p in pages if p != image_id]
await self.reorder_pages(series_tag_id, chapter_id, new_pages)
# ---- post → series (FC-6.2) ------------------------------------------ # ---- post → series (FC-6.2) ------------------------------------------
@@ -553,8 +432,8 @@ class SeriesService:
async def promote_post_to_series(self, post_id: int) -> dict: async def promote_post_to_series(self, post_id: int) -> dict:
"""Case 1: a self-contained multi-image post becomes its own series. """Case 1: a self-contained multi-image post becomes its own series.
Creates a series tag named after the post, one chapter, the post's Creates a series tag named after the post and the post's images as its
images as its pages (stated pages parsed from the post when present).""" flat pages (stated pages parsed from the post when present)."""
from .tag_service import TagService from .tag_service import TagService
post = await self.session.get(Post, post_id) post = await self.session.get(Post, post_id)
@@ -566,25 +445,19 @@ class SeriesService:
name = (post.post_title or f"Series from post {post_id}").strip()[:200] name = (post.post_title or f"Series from post {post_id}").strip()[:200]
tag = await TagService(self.session).find_or_create(name, TagKind.series) tag = await TagService(self.session).find_or_create(name, TagKind.series)
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}") rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
start, end = rng if rng else (None, None) start = rng[0] if rng else None
ch = await self.create_chapter(
tag.id, stated_page_start=start, stated_page_end=end,
)
added = await self.add_images( added = await self.add_images(
tag.id, image_ids, chapter_id=ch["id"], tag.id, image_ids, stated_pages=self._stated_map(image_ids, start),
stated_pages=self._stated_map(image_ids, start),
) )
return { return {
"series_tag_id": tag.id, "name": tag.name, "series_tag_id": tag.id, "name": tag.name, "added": added,
"chapter_id": ch["id"], "added": added,
} }
async def add_post_as_chapter( async def add_post(self, series_tag_id: int, post_id: int) -> dict:
self, series_tag_id: int, post_id: int """Case 2: append a post's images to the end of an existing series' flat
) -> dict: run (stated pages parsed from the post when present). No chapter wrapping
"""Case 2: append a post as the next chapter of an existing series. The — the operator orders/labels afterward. (Phase 2 will stage these as
chapter is titled after the post and slotted by parsed page number when pending instead of appending directly.)"""
present (else appended at the end)."""
await self._require_series(series_tag_id) await self._require_series(series_tag_id)
post = await self.session.get(Post, post_id) post = await self.session.get(Post, post_id)
if post is None: if post is None:
@@ -593,89 +466,55 @@ class SeriesService:
if not image_ids: if not image_ids:
raise SeriesError("post has no images to add") raise SeriesError("post has no images to add")
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}") rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
start, end = rng if rng else (None, None) start = rng[0] if rng else None
ch = await self.create_chapter(
series_tag_id, title=post.post_title or None,
stated_page_start=start, stated_page_end=end,
)
added = await self.add_images( added = await self.add_images(
series_tag_id, image_ids, chapter_id=ch["id"], series_tag_id, image_ids,
stated_pages=self._stated_map(image_ids, start), stated_pages=self._stated_map(image_ids, start),
) )
if start is not None: return {"series_tag_id": series_tag_id, "added": added}
await self._place_chapter_by_stated(series_tag_id, ch["id"], start)
return {
"series_tag_id": series_tag_id, "chapter_id": ch["id"],
"added": added,
}
async def _place_chapter_by_stated(
self, series_tag_id: int, chapter_id: int, start: int
) -> None:
"""Move `chapter_id` to sit before the first chapter whose stated start
is higher — so a post stating pages 5-8 lands ahead of the 9-12 chapter."""
rows = (
await self.session.execute(
select(SeriesChapter.id, SeriesChapter.stated_page_start)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(SeriesChapter.chapter_number.asc())
)
).all()
current = [r.id for r in rows]
others = [r for r in rows if r.id != chapter_id]
insert_at = len(others)
for idx, r in enumerate(others):
if r.stated_page_start is not None and r.stated_page_start > start:
insert_at = idx
break
new_order = [r.id for r in others]
new_order.insert(insert_at, chapter_id)
if new_order != current:
await self.reorder_chapters(series_tag_id, new_order)
# ---- browse list (FC-6.2) -------------------------------------------- # ---- browse list (FC-6.2) --------------------------------------------
async def list_series( async def list_series(
self, *, sort: str = "recent", artist_id: int | None = None self, *, sort: str = "recent", artist_id: int | None = None
) -> list[dict]: ) -> list[dict]:
"""Series cards for the browse view: cover thumb, name, artist, chapter """Series cards for the browse view: cover thumb, name, artist, divider
+ page counts, a gap flag, and last-updated. sort ∈ recent|name|size.""" + page counts, a missing-Part flag, and last-updated. sort ∈
# Page counts + most-recent activity per series. recent|name|size."""
# Page counts + most-recent page activity per series.
page_rows = ( page_rows = (
await self.session.execute( await self.session.execute(
select( select(
SeriesPage.series_tag_id, SeriesPage.series_tag_id,
func.count().label("pages"), func.count().label("pages"),
func.max(SeriesPage.updated_at).label("updated_at"),
).group_by(SeriesPage.series_tag_id) ).group_by(SeriesPage.series_tag_id)
) )
).all() ).all()
page_count = {r.series_tag_id: r.pages for r in page_rows} page_count = {r.series_tag_id: r.pages for r in page_rows}
updated_by_series = {r.series_tag_id: r.updated_at for r in page_rows}
ch_rows = ( # Dividers per series (for count + missing-Part gap), ordered by the
# global position of their anchor page.
drows = (
await self.session.execute( await self.session.execute(
select( select(
SeriesChapter.series_tag_id, SeriesChapter.series_tag_id,
SeriesChapter.id, SeriesChapter.id,
SeriesChapter.chapter_number, SeriesChapter.stated_part,
SeriesChapter.stated_page_start, SeriesPage.page_number,
SeriesChapter.stated_page_end,
SeriesChapter.updated_at,
) )
.join(SeriesPage, SeriesPage.id == SeriesChapter.anchor_page_id)
.order_by( .order_by(
SeriesChapter.series_tag_id, SeriesChapter.series_tag_id, SeriesPage.page_number.asc()
SeriesChapter.chapter_number.asc(),
) )
) )
).all() ).all()
chapters_by_series: dict[int, list] = {} dividers_by_series: dict[int, list] = {}
updated_by_series: dict[int, object] = {} for r in drows:
for r in ch_rows: dividers_by_series.setdefault(r.series_tag_id, []).append(r)
chapters_by_series.setdefault(r.series_tag_id, []).append(r)
prev = updated_by_series.get(r.series_tag_id)
if prev is None or (r.updated_at and r.updated_at > prev):
updated_by_series[r.series_tag_id] = r.updated_at
# Cover = the (chapter_number, page_number)-minimum page per series. # Cover = the first page (min page_number) of each series.
cover_q = ( cover_q = (
select( select(
SeriesPage.series_tag_id, SeriesPage.series_tag_id,
@@ -687,13 +526,11 @@ class SeriesService:
.over( .over(
partition_by=SeriesPage.series_tag_id, partition_by=SeriesPage.series_tag_id,
order_by=( order_by=(
SeriesChapter.chapter_number.asc(), SeriesPage.page_number.asc(), SeriesPage.id.asc()
SeriesPage.page_number.asc(),
), ),
) )
.label("rn"), .label("rn"),
) )
.join(SeriesChapter, SeriesChapter.id == SeriesPage.chapter_id)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id) .join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.subquery() .subquery()
) )
@@ -737,20 +574,14 @@ class SeriesService:
cover is None or cover.artist_id != artist_id cover is None or cover.artist_id != artist_id
): ):
continue continue
chs = chapters_by_series.get(t.id, []) divs = dividers_by_series.get(t.id, [])
gap = bool( gap = bool(
self._gaps( self._part_gaps(
[ [{"id": d.id, "stated_part": d.stated_part} for d in divs]
{
"id": c.id,
"stated_page_start": c.stated_page_start,
"stated_page_end": c.stated_page_end,
}
for c in chs
]
) )
) )
artist = artist_by_id.get(cover.artist_id) if cover else None artist = artist_by_id.get(cover.artist_id) if cover else None
updated = updated_by_series.get(t.id)
out.append( out.append(
{ {
"id": t.id, "id": t.id,
@@ -764,14 +595,10 @@ class SeriesService:
), ),
"artist_name": artist.name if artist else None, "artist_name": artist.name if artist else None,
"artist_slug": artist.slug if artist else None, "artist_slug": artist.slug if artist else None,
"chapter_count": len(chs), "chapter_count": len(divs),
"page_count": page_count.get(t.id, 0), "page_count": page_count.get(t.id, 0),
"has_gap": gap, "has_gap": gap,
"updated_at": ( "updated_at": updated.isoformat() if updated else None,
updated_by_series.get(t.id).isoformat()
if updated_by_series.get(t.id)
else None
),
} }
) )
+16 -7
View File
@@ -518,21 +518,30 @@ class TagService:
from ..models.series_chapter import SeriesChapter from ..models.series_chapter import SeriesChapter
from ..models.series_page import SeriesPage from ..models.series_page import SeriesPage
# Move the chapters first so the pages' chapter_id FK stays valid: a # Move src's dividers onto tgt. Their anchor_page_id stays valid — the
# chapter left pointing at src would cascade-delete (with its pages) when # anchored pages move too — and anchor is globally UNIQUE so no collision.
# src is removed. chapter_number may now collide across the merged set —
# acceptable (it's an ordering key, not unique).
await self.session.execute( await self.session.execute(
update(SeriesChapter) update(SeriesChapter)
.where(SeriesChapter.series_tag_id == src) .where(SeriesChapter.series_tag_id == src)
.values(series_tag_id=tgt) .values(series_tag_id=tgt)
) )
# image_id is UNIQUE across series_page and src != tgt, so an # Append src's pages after tgt's so the merged series stays ONE clean
# image in src's series cannot already be in tgt's — no collision. # series-global run (image_id is UNIQUE, so the two page sets are
# disjoint). Offset src's page_number past tgt's current max.
tgt_max = (
await self.session.scalar(
select(
func.coalesce(func.max(SeriesPage.page_number), 0)
).where(SeriesPage.series_tag_id == tgt)
)
) or 0
await self.session.execute( await self.session.execute(
update(SeriesPage) update(SeriesPage)
.where(SeriesPage.series_tag_id == src) .where(SeriesPage.series_tag_id == src)
.values(series_tag_id=tgt) .values(
series_tag_id=tgt,
page_number=SeriesPage.page_number + tgt_max,
)
) )
async def _repoint_fandom_children( async def _repoint_fandom_children(
@@ -28,7 +28,8 @@
<v-card-title>Add to series</v-card-title> <v-card-title>Add to series</v-card-title>
<v-card-text> <v-card-text>
<p class="text-caption mb-2"> <p class="text-caption mb-2">
Appends this post as the next chapter of the chosen series. Adds this post's pages to the end of the chosen series, ready to drag
into place.
</p> </p>
<v-autocomplete <v-autocomplete
v-model="picked" v-model="picked"
+66 -98
View File
@@ -11,16 +11,17 @@ export function moveItem(arr, from, to) {
return next return next
} }
// FC-6.x: a series is ONE flat, series-global ordered run of pages, with
// optional cosmetic chapter DIVIDERS anchored to the page that begins them.
export const useSeriesManageStore = defineStore('seriesManage', () => { export const useSeriesManageStore = defineStore('seriesManage', () => {
const api = useApi() const api = useApi()
const tagId = ref(null) const tagId = ref(null)
const series = ref(null) const series = ref(null)
const chapters = ref([]) // [{id, chapter_number, stated_part, title, is_placeholder, stated_page_start/end, source_post, pages:[...]}] const pages = ref([]) // flat, ordered: [{image_id, page_number, stated_page, thumbnail_url, image_url, source_post}]
const gaps = ref([]) // missing-page gaps: [{after_chapter_id, start, end}] const dividers = ref([]) // [{id, anchor_image_id, page_number, title, stated_part}]
const partGaps = ref([]) // missing-Part gaps: [{after_chapter_id, start, end}] const partGaps = ref([]) // [{after_divider_id, start, end}]
const pageCount = ref(0) const pageCount = ref(0)
const targetChapterId = ref(null) // which chapter the picker adds into
const picker = ref([]) // gallery scroll results const picker = ref([]) // gallery scroll results
const pickerCursor = ref(null) const pickerCursor = ref(null)
const pickerSelection = ref([]) // image ids const pickerSelection = ref([]) // image ids
@@ -32,15 +33,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
try { try {
const body = await api.get(`/api/series/${id}/pages`) const body = await api.get(`/api/series/${id}/pages`)
series.value = body.series series.value = body.series
chapters.value = body.chapters || [] pages.value = body.pages || []
gaps.value = body.gaps || [] dividers.value = body.dividers || []
partGaps.value = body.part_gaps || [] partGaps.value = body.part_gaps || []
pageCount.value = (body.pages || []).length pageCount.value = pages.value.length
// Keep a valid add-target: the selected chapter, else the first one.
const ids = chapters.value.map(c => c.id)
if (!ids.includes(targetChapterId.value)) {
targetChapterId.value = ids.length ? ids[0] : null
}
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -50,79 +46,66 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
if (tagId.value != null) await load(tagId.value) if (tagId.value != null) await load(tagId.value)
} }
function gapAfter(chapterId) { // The divider anchored at a given page (shown as a chapter bar before it).
return gaps.value.find(g => g.after_chapter_id === chapterId) || null function dividerAt(imageId) {
return dividers.value.find(d => d.anchor_image_id === imageId) || null
} }
function partGapAfter(chapterId) { function partGapAfter(dividerId) {
return partGaps.value.find(g => g.after_chapter_id === chapterId) || null return partGaps.value.find(g => g.after_divider_id === dividerId) || null
} }
// ---- chapters ---- // ---- pages (series-wide) ----
async function createChapter({ title = null, isPlaceholder = false } = {}) { async function reorder(orderedImageIds) {
await api.post(`/api/series/${tagId.value}/chapters`, { await api.post(`/api/series/${tagId.value}/reorder`, {
body: { title, is_placeholder: isPlaceholder }
})
await refresh()
}
async function renameChapter(chapterId, title) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { title }
})
await refresh()
}
async function setChapterPart(chapterId, part) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { stated_part: part }
})
await refresh()
}
async function setChapterStated(chapterId, start, end) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { stated_page_start: start, stated_page_end: end }
})
await refresh()
}
async function reorderChapters(orderedChapterIds) {
await api.post(`/api/series/${tagId.value}/chapters/reorder`, {
body: { chapter_ids: orderedChapterIds }
})
await refresh()
}
async function moveChapter(chapterId, dir) {
const ids = chapters.value.map(c => c.id)
const from = ids.indexOf(chapterId)
const to = from + dir
if (from === -1 || to < 0 || to >= ids.length) return
await reorderChapters(moveItem(ids, from, to))
}
async function deleteChapter(chapterId) {
await api.delete(`/api/series/${tagId.value}/chapters/${chapterId}`)
await refresh()
}
async function mergeChapter(sourceId, targetId) {
await api.post(`/api/series/${tagId.value}/chapters/${sourceId}/merge`, {
body: { target_chapter_id: targetId }
})
await refresh()
toast({ text: 'Chapters merged', type: 'success' })
}
async function reorderPages(chapterId, orderedImageIds) {
await api.post(`/api/series/${tagId.value}/chapters/${chapterId}/reorder`, {
body: { image_ids: orderedImageIds } body: { image_ids: orderedImageIds }
}) })
await refresh() await refresh()
} }
// ---- picker / pages ---- async function remove(imageId) {
await api.post(`/api/series/${tagId.value}/pages/remove`, {
body: { image_ids: [imageId] }
})
await refresh()
}
async function setCover(imageId) {
await api.post(`/api/series/${tagId.value}/cover`, {
body: { image_id: imageId }
})
await refresh()
}
// ---- chapter dividers ----
async function createDivider(anchorImageId, { title = null, statedPart = null } = {}) {
await api.post(`/api/series/${tagId.value}/chapters`, {
body: { anchor_image_id: anchorImageId, title, stated_part: statedPart }
})
await refresh()
}
async function renameDivider(dividerId, title) {
await api.patch(`/api/series/${tagId.value}/chapters/${dividerId}`, {
body: { title }
})
await refresh()
}
async function setDividerPart(dividerId, part) {
await api.patch(`/api/series/${tagId.value}/chapters/${dividerId}`, {
body: { stated_part: part }
})
await refresh()
}
async function deleteDivider(dividerId) {
await api.delete(`/api/series/${tagId.value}/chapters/${dividerId}`)
await refresh()
toast({ text: 'Chapter divider removed', type: 'success' })
}
// ---- picker / add pages ----
async function loadPicker(reset = false) { async function loadPicker(reset = false) {
if (reset) { picker.value = []; pickerCursor.value = null } if (reset) { picker.value = []; pickerCursor.value = null }
const params = { limit: 50 } const params = { limit: 50 }
@@ -140,35 +123,20 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
async function addSelected() { async function addSelected() {
if (pickerSelection.value.length === 0) return if (pickerSelection.value.length === 0) return
if (targetChapterId.value == null) await createChapter()
await api.post(`/api/series/${tagId.value}/pages`, { await api.post(`/api/series/${tagId.value}/pages`, {
body: { image_ids: pickerSelection.value, chapter_id: targetChapterId.value } body: { image_ids: pickerSelection.value }
}) })
pickerSelection.value = [] pickerSelection.value = []
await refresh() await refresh()
toast({ text: 'Added to chapter', type: 'success' }) toast({ text: 'Added to series', type: 'success' })
}
async function remove(imageId) {
await api.post(`/api/series/${tagId.value}/pages/remove`, {
body: { image_ids: [imageId] }
})
await refresh()
}
async function setCover(imageId) {
await api.post(`/api/series/${tagId.value}/cover`, {
body: { image_id: imageId }
})
await refresh()
} }
return { return {
tagId, series, chapters, gaps, partGaps, pageCount, targetChapterId, tagId, series, pages, dividers, partGaps, pageCount,
picker, pickerCursor, pickerSelection, loading, picker, pickerCursor, pickerSelection, loading,
load, refresh, gapAfter, partGapAfter, load, refresh, dividerAt, partGapAfter,
createChapter, renameChapter, setChapterPart, setChapterStated, reorder, remove, setCover,
reorderChapters, moveChapter, deleteChapter, mergeChapter, reorderPages, createDivider, renameDivider, setDividerPart, deleteDivider,
loadPicker, togglePick, addSelected, remove, setCover loadPicker, togglePick, addSelected
} }
}) })
+16 -20
View File
@@ -42,27 +42,23 @@ export const useSeriesReaderStore = defineStore('seriesReader', () => {
await run(async () => { await run(async () => {
const body = await api.get(`/api/series/${tagId}/pages`) const body = await api.get(`/api/series/${tagId}/pages`)
series.value = body.series series.value = body.series
// page_number is now WITHIN a chapter, so it can't anchor scroll/jump // FC-6.x: page_number is already the series-global reading order, so it
// (two chapters both have a page 1). Decorate each page with a global // anchors scroll/jump directly (seq = page_number). Chapters are cosmetic
// `seq` (reading-order position) for anchors, plus chapter-divider info // dividers anchored to a page; mark the page each divider begins on so the
// so the reader can mark where each chapter begins. // reader can render a chapter heading there.
const chapters = body.chapters || [] const dividers = body.dividers || []
const labelById = {} const anchorIds = new Set(dividers.map(d => d.anchor_image_id))
for (const c of chapters) { const labelByAnchor = {}
labelById[c.id] = c.title || `Chapter ${c.chapter_number}` for (const d of dividers) {
labelByAnchor[d.anchor_image_id] =
d.title || (d.stated_part != null ? `Part ${d.stated_part}` : 'Chapter')
} }
let prevChapter = null pages.value = (body.pages || []).map((p) => ({
pages.value = (body.pages || []).map((p, i) => { ...p,
const isChapterStart = seq: p.page_number,
chapters.length > 1 && p.chapter_id !== prevChapter isChapterStart: anchorIds.has(p.image_id),
prevChapter = p.chapter_id chapterLabel: labelByAnchor[p.image_id] || 'Chapter'
return { }))
...p,
seq: i + 1,
isChapterStart,
chapterLabel: labelById[p.chapter_id] || null
}
})
}) })
} }
+148 -258
View File
@@ -9,9 +9,13 @@
@click="renameOpen = true" @click="renameOpen = true"
/> />
<span class="fc-series__count"> <span class="fc-series__count">
{{ store.chapters.length }} part(s) · {{ store.pageCount }} page(s) {{ store.dividers.length }} chapter(s) · {{ store.pageCount }} page(s)
</span> </span>
<v-spacer /> <v-spacer />
<v-btn
size="small" variant="tonal" prepend-icon="mdi-image-plus"
@click="pickerOpen = true"
>Add pages</v-btn>
<v-btn <v-btn
v-if="store.pageCount > 0" v-if="store.pageCount > 0"
size="small" variant="tonal" color="accent" size="small" variant="tonal" color="accent"
@@ -21,161 +25,103 @@
</div> </div>
<p class="fc-series__hint"> <p class="fc-series__hint">
Each part is one installment drag pages to set their order, and set the One continuous series. <strong>Drag any page</strong> to set its order
<strong>Part #</strong> to mark where it falls in the story. Use the numbers are the page's place in the whole series. Use a page's
<strong>Add pages</strong> to pull images from the gallery into the part <strong> Start a chapter here</strong> to drop a labeled divider before
you're working on. it. New pages from <strong>Add pages</strong> append to the end, ready to
be dragged into place.
</p> </p>
<!-- Parts, full width --> <!-- One continuous page run; chapter dividers render inline before their
<div class="fc-parts"> anchor page. -->
<template v-for="(ch, ci) in store.chapters" :key="ch.id"> <div v-if="store.pages.length" class="fc-run">
<section class="fc-part"> <template v-for="(p, pi) in store.pages" :key="p.image_id">
<header class="fc-part__head"> <!-- chapter divider bar, if one is anchored at this page -->
<label class="fc-part__partfield" :title="'Part number for this installment'"> <template v-if="store.dividerAt(p.image_id)">
<span class="fc-part__partlabel">Part</span> <div class="fc-divider">
<v-icon size="small" class="fc-divider__icon">mdi-format-section</v-icon>
<label class="fc-divider__partfield" title="Part number for this chapter">
<span class="fc-divider__partlabel">Part</span>
<input <input
class="fc-part__partnum" type="number" min="1" class="fc-divider__partnum" type="number" min="1"
:value="partDraft[ch.id] ?? ''" :value="partDraft[store.dividerAt(p.image_id).id] ?? ''"
:placeholder="String(ch.chapter_number)" placeholder="—"
@input="partDraft[ch.id] = $event.target.value" @input="partDraft[store.dividerAt(p.image_id).id] = $event.target.value"
@keydown.enter.prevent="commitPart(ch)" @keydown.enter.prevent="commitPart(store.dividerAt(p.image_id))"
@blur="commitPart(ch)" @blur="commitPart(store.dividerAt(p.image_id))"
> >
</label> </label>
<v-text-field <v-text-field
v-model="titleDraft[ch.id]" :model-value="titleDraft[store.dividerAt(p.image_id).id]"
:placeholder="`Untitled — Part ${ch.stated_part ?? ch.chapter_number}`" placeholder="Untitled chapter"
density="compact" variant="plain" hide-details density="compact" variant="plain" hide-details
class="fc-part__title" class="fc-divider__title"
@keydown.enter="commitTitle(ch)" @update:model-value="titleDraft[store.dividerAt(p.image_id).id] = $event"
@blur="commitTitle(ch)" @keydown.enter="commitTitle(store.dividerAt(p.image_id))"
@blur="commitTitle(store.dividerAt(p.image_id))"
/> />
<span
v-if="ch.source_post?.title" class="fc-part__src"
:title="ch.source_post.title"
>
<v-icon size="x-small">mdi-link-variant</v-icon>
{{ ch.source_post.title }}
</span>
<span v-if="ch.is_placeholder" class="fc-part__badge">placeholder</span>
<span v-else class="fc-part__pc">{{ ch.pages.length }} pg</span>
<v-spacer /> <v-spacer />
<v-btn <v-btn
v-if="!ch.is_placeholder" size="x-small" variant="text" icon="mdi-close"
size="small" variant="tonal" color="accent" title="Remove this chapter divider"
prepend-icon="mdi-image-plus" @click="store.deleteDivider(store.dividerAt(p.image_id).id)"
@click="openPicker(ch.id)" />
>Add pages</v-btn>
<KebabMenu size="small" label="Part actions">
<v-list-item
prepend-icon="mdi-chevron-up" title="Move up"
:disabled="ci === 0"
@click="store.moveChapter(ch.id, -1)"
/>
<v-list-item
prepend-icon="mdi-chevron-down" title="Move down"
:disabled="ci === store.chapters.length - 1"
@click="store.moveChapter(ch.id, 1)"
/>
<v-list-item
prepend-icon="mdi-arrow-collapse-up" title="Merge into previous"
:disabled="ci === 0"
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
/>
<v-divider />
<v-list-item
prepend-icon="mdi-delete-outline" title="Delete part"
base-color="error"
@click="confirmDelete(ch)"
/>
</KebabMenu>
</header>
<div class="fc-part__statedrow">
<span class="fc-part__statedlabel">Printed pages</span>
<input
class="fc-part__statedin" type="number" min="0"
:value="ch.stated_page_start ?? ''" placeholder="start"
@change="onStated(ch, 'start', $event)"
>
<span class="fc-part__dash"></span>
<input
class="fc-part__statedin" type="number" min="0"
:value="ch.stated_page_end ?? ''" placeholder="end"
@change="onStated(ch, 'end', $event)"
>
<span class="fc-part__statedhelp">
optional — the page numbers printed in this installment
</span>
</div> </div>
<div
<div v-if="ch.is_placeholder" class="fc-part__reserved"> v-if="store.partGapAfter(store.dividerAt(p.image_id).id)"
Reserved slot — a part you don't have yet. class="fc-gap"
>
<v-icon size="x-small">mdi-alert-outline</v-icon>
Missing
<template v-if="gapText(p.image_id).single">
Part {{ gapText(p.image_id).start }}
</template>
<template v-else>
Parts {{ gapText(p.image_id).start }}{{ gapText(p.image_id).end }}
</template>
</div> </div>
<div v-else-if="ch.pages.length === 0" class="fc-part__empty"> </template>
No pages yet.
<!-- the page -->
<div
class="fc-page" draggable="true"
:class="{ 'fc-page--dragging': drag === pi }"
@dragstart="drag = pi"
@dragend="drag = null"
@dragover.prevent
@drop="onDrop(pi)"
>
<span class="fc-page__pn">{{ p.page_number }}</span>
<img :src="p.thumbnail_url" alt="" loading="lazy" />
<span
v-if="p.source_post?.title" class="fc-page__src"
:title="`From: ${p.source_post.title}`"
>
<v-icon size="x-small">mdi-link-variant</v-icon>
{{ p.source_post.title }}
</span>
<div class="fc-page__actions">
<v-btn <v-btn
size="small" variant="text" color="accent" size="x-small" variant="flat" icon="mdi-image-frame"
prepend-icon="mdi-image-plus" @click="openPicker(ch.id)" title="Make series cover" @click="store.setCover(p.image_id)"
>Add pages</v-btn> />
<v-btn
v-if="!store.dividerAt(p.image_id)"
size="x-small" variant="flat" icon="mdi-format-section"
title="Start a chapter here" @click="store.createDivider(p.image_id)"
/>
<v-btn
size="x-small" variant="flat" icon="mdi-close"
title="Remove from series" @click="store.remove(p.image_id)"
/>
</div> </div>
<div v-else class="fc-part__pages">
<div
v-for="(p, pi) in ch.pages" :key="p.image_id"
class="fc-page" draggable="true"
:class="{ 'fc-page--dragging': drag && drag.chapterId === ch.id && drag.idx === pi }"
@dragstart="drag = { chapterId: ch.id, idx: pi }"
@dragend="drag = null"
@dragover.prevent
@drop="onPageDrop(ch, pi)"
>
<span class="fc-page__pn">{{ p.page_number }}</span>
<img :src="p.thumbnail_url" alt="" loading="lazy" />
<div class="fc-page__actions">
<v-btn size="x-small" variant="flat" icon="mdi-image-frame"
title="Make series cover" @click="store.setCover(p.image_id)" />
<v-btn size="x-small" variant="flat" icon="mdi-close"
title="Remove from series" @click="store.remove(p.image_id)" />
</div>
</div>
</div>
</section>
<div v-if="store.partGapAfter(ch.id)" class="fc-gap">
<v-icon size="x-small">mdi-alert-outline</v-icon>
Missing
<template v-if="store.partGapAfter(ch.id).start === store.partGapAfter(ch.id).end">
Part {{ store.partGapAfter(ch.id).start }}
</template>
<template v-else>
Parts {{ store.partGapAfter(ch.id).start }}{{ store.partGapAfter(ch.id).end }}
</template>
</div>
<div v-if="store.gapAfter(ch.id)" class="fc-gap">
<v-icon size="x-small">mdi-alert-outline</v-icon>
Gap: printed pages {{ store.gapAfter(ch.id).start }}{{ store.gapAfter(ch.id).end }} missing
</div> </div>
</template> </template>
</div>
<div v-if="store.chapters.length === 0" class="fc-series__empty"> <div v-else class="fc-series__empty">
No parts yet add one, then add pages from the gallery. No pages yet use <strong>Add pages</strong> to pull images from the
</div> gallery into this series.
<div class="fc-parts__add">
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus"
@click="store.createChapter()">Add part</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-bookmark-outline"
@click="store.createChapter({ isPlaceholder: true })">
Add placeholder
</v-btn>
</div>
</div> </div>
<!-- Picker slide-over --> <!-- Picker slide-over -->
@@ -189,19 +135,13 @@
<v-btn size="x-small" variant="text" icon="mdi-close" <v-btn size="x-small" variant="text" icon="mdi-close"
@click="pickerOpen = false" /> @click="pickerOpen = false" />
</div> </div>
<v-select
v-model="store.targetChapterId"
:items="chapterItems" item-title="label" item-value="id"
density="compact" variant="outlined" hide-details
label="Add to part" class="mt-2"
/>
<div class="fc-picker__bar"> <div class="fc-picker__bar">
<span>{{ store.pickerSelection.length }} selected</span> <span>{{ store.pickerSelection.length }} selected</span>
<v-btn <v-btn
size="small" color="accent" variant="flat" size="small" color="accent" variant="flat"
:disabled="store.pickerSelection.length === 0" :disabled="store.pickerSelection.length === 0"
@click="store.addSelected()" @click="store.addSelected()"
>Add to part</v-btn> >Add to series</v-btn>
</div> </div>
</div> </div>
<div class="fc-picker__grid"> <div class="fc-picker__grid">
@@ -232,19 +172,18 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue' import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js' import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js' import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
import KebabMenu from '../components/common/KebabMenu.vue'
import TagRenameDialog from '../components/modal/TagRenameDialog.vue' import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
const route = useRoute() const route = useRoute()
const store = useSeriesManageStore() const store = useSeriesManageStore()
const sentinel = ref(null) const sentinel = ref(null)
const drag = ref(null) // { chapterId, idx } const drag = ref(null) // index in the flat pages run being dragged
const titleDraft = reactive({}) // chapterId -> draft title const titleDraft = reactive({}) // dividerId -> draft title
const partDraft = reactive({}) // chapterId -> draft stated_part (string) const partDraft = reactive({}) // dividerId -> draft stated_part (string)
const pickerOpen = ref(false) const pickerOpen = ref(false)
const renameOpen = ref(false) const renameOpen = ref(false)
@@ -255,73 +194,47 @@ function onRenamed(updated) {
if (store.series && updated?.name) store.series.name = updated.name if (store.series && updated?.name) store.series.name = updated.name
} }
// Keep local drafts in sync with loaded chapters. Edits commit on blur/Enter, // Keep local divider drafts in sync with loaded dividers; edits commit on
// which refreshes and resets the draft to the saved value. // blur/Enter, which refreshes and resets the draft to the saved value.
watch(() => store.chapters, (chs) => { watch(() => store.dividers, (divs) => {
for (const k of Object.keys(titleDraft)) delete titleDraft[k] for (const k of Object.keys(titleDraft)) delete titleDraft[k]
for (const k of Object.keys(partDraft)) delete partDraft[k] for (const k of Object.keys(partDraft)) delete partDraft[k]
for (const c of chs) { for (const d of divs) {
titleDraft[c.id] = c.title || '' titleDraft[d.id] = d.title || ''
partDraft[c.id] = c.stated_part == null ? '' : String(c.stated_part) partDraft[d.id] = d.stated_part == null ? '' : String(d.stated_part)
} }
}, { immediate: true }) }, { immediate: true })
const chapterItems = computed(() => function commitTitle(d) {
store.chapters.map(c => ({ const v = (titleDraft[d.id] || '').trim()
id: c.id, if (v === (d.title || '')) return
label: `Part ${c.stated_part ?? c.chapter_number}` + store.renameDivider(d.id, v || null)
(c.title ? `${c.title}` : '') +
(c.is_placeholder ? ' (placeholder)' : ` · ${c.pages.length} pg`),
}))
)
function commitTitle(ch) {
const v = (titleDraft[ch.id] || '').trim()
if (v === (ch.title || '')) return
store.renameChapter(ch.id, v || null)
} }
function commitPart(ch) { function commitPart(d) {
const raw = (partDraft[ch.id] ?? '').trim() const raw = (partDraft[d.id] ?? '').trim()
const next = raw === '' ? null : parseInt(raw, 10) const next = raw === '' ? null : parseInt(raw, 10)
if (raw !== '' && (Number.isNaN(next) || next < 1)) { if (raw !== '' && (Number.isNaN(next) || next < 1)) {
partDraft[ch.id] = ch.stated_part == null ? '' : String(ch.stated_part) partDraft[d.id] = d.stated_part == null ? '' : String(d.stated_part)
return return
} }
if (next === (ch.stated_part ?? null)) return if (next === (d.stated_part ?? null)) return
store.setChapterPart(ch.id, next) store.setDividerPart(d.id, next)
} }
function onStated(ch, which, ev) { function gapText(anchorImageId) {
const raw = ev.target.value const d = store.dividerAt(anchorImageId)
const n = raw === '' ? null : parseInt(raw, 10) const g = d ? store.partGapAfter(d.id) : null
const start = which === 'start' ? n : ch.stated_page_start return { start: g?.start, end: g?.end, single: g && g.start === g.end }
const end = which === 'end' ? n : ch.stated_page_end
store.setChapterStated(ch.id, start, end)
} }
function onPageDrop(chapter, toIdx) { function onDrop(toIdx) {
if (!drag.value || drag.value.chapterId !== chapter.id) { drag.value = null; return } if (drag.value === null || drag.value === toIdx) { drag.value = null; return }
if (drag.value.idx === toIdx) { drag.value = null; return }
const ordered = moveItem( const ordered = moveItem(
chapter.pages.map(p => p.image_id), drag.value.idx, toIdx store.pages.map(p => p.image_id), drag.value, toIdx
) )
drag.value = null drag.value = null
store.reorderPages(chapter.id, ordered) store.reorder(ordered)
}
function confirmDelete(ch) {
const label = `Part ${ch.stated_part ?? ch.chapter_number}`
const n = ch.pages.length
const msg = n
? `Delete ${label} and remove its ${n} page(s) from the series?`
: `Delete ${label}?`
if (window.confirm(msg)) store.deleteChapter(ch.id)
}
function openPicker(chapterId) {
store.targetChapterId = chapterId
pickerOpen.value = true
} }
useInfiniteScroll(sentinel, () => store.loadPicker()) useInfiniteScroll(sentinel, () => store.loadPicker())
@@ -342,76 +255,46 @@ onMounted(async () => {
} }
.fc-series__hint { .fc-series__hint {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant)); font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 18px; max-width: 760px; margin-bottom: 18px; max-width: 820px;
} }
/* Parts — full width, stacked */ /* One continuous page run with inline divider bars. */
.fc-parts { display: flex; flex-direction: column; gap: 14px; max-width: 1100px; } .fc-run {
.fc-part { display: grid; gap: 10px; max-width: 1100px;
border: 1px solid rgb(var(--v-theme-surface-light)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
border-radius: 10px; padding: 12px 14px;
background: rgb(var(--v-theme-surface));
} }
.fc-part__head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.fc-part__partfield { .fc-divider {
grid-column: 1 / -1;
display: flex; align-items: center; gap: 12px;
margin-top: 6px; padding: 6px 12px;
border-left: 3px solid rgb(var(--v-theme-accent));
background: rgb(var(--v-theme-accent), 0.10);
border-radius: 0 8px 8px 0;
}
.fc-divider__icon { color: rgb(var(--v-theme-accent)); }
.fc-divider__partfield {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
background: rgb(var(--v-theme-accent), 0.12); background: rgb(var(--v-theme-accent), 0.14);
border: 1px solid rgb(var(--v-theme-accent), 0.35); border: 1px solid rgb(var(--v-theme-accent), 0.35);
border-radius: 8px; padding: 4px 8px; border-radius: 8px; padding: 2px 8px;
} }
.fc-part__partlabel { .fc-divider__partlabel {
font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-accent)); color: rgb(var(--v-theme-accent));
} }
.fc-part__partnum { .fc-divider__partnum {
width: 46px; text-align: center; font-size: 18px; font-weight: 600; width: 42px; text-align: center; font-size: 16px; font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
background: transparent; border: none; color: rgb(var(--v-theme-on-surface)); background: transparent; border: none; color: rgb(var(--v-theme-on-surface));
} }
.fc-part__partnum:focus { outline: none; } .fc-divider__partnum:focus { outline: none; }
.fc-part__partnum::placeholder { color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.6; } .fc-divider__partnum::placeholder {
color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.6;
}
.fc-divider__title { flex: 1 1 200px; min-width: 120px; font-size: 15px; }
.fc-part__title { flex: 1 1 180px; min-width: 120px; font-size: 15px; } /* Big page tiles */
.fc-part__src {
display: inline-flex; align-items: center; gap: 4px; max-width: 240px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-part__badge {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-part__pc {
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-part__statedrow {
display: flex; align-items: center; gap: 6px; margin: 8px 0 2px;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-part__statedlabel { text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; }
.fc-part__statedin {
width: 56px; text-align: center; font-size: 12px;
background: rgb(var(--v-theme-surface-light));
border: 1px solid transparent; border-radius: 4px; padding: 2px 4px;
color: rgb(var(--v-theme-on-surface));
}
.fc-part__statedin:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
.fc-part__dash { opacity: 0.6; }
.fc-part__statedhelp { font-size: 11px; opacity: 0.7; }
.fc-part__reserved, .fc-part__empty {
padding: 18px; text-align: center; font-size: 13px;
color: rgb(var(--v-theme-on-surface-variant));
}
/* Big page grid */
.fc-part__pages {
display: grid; gap: 10px; margin-top: 10px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.fc-page { .fc-page {
position: relative; border-radius: 8px; overflow: hidden; cursor: grab; position: relative; border-radius: 8px; overflow: hidden; cursor: grab;
background: rgb(var(--v-theme-surface-light)); background: rgb(var(--v-theme-surface-light));
@@ -429,6 +312,12 @@ onMounted(async () => {
font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
background: rgb(var(--v-theme-accent)); color: rgb(var(--v-theme-on-accent, 0 0 0)); background: rgb(var(--v-theme-accent)); color: rgb(var(--v-theme-on-accent, 0 0 0));
} }
.fc-page__src {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
display: flex; align-items: center; gap: 4px; padding: 3px 6px;
font-size: 11px; color: #fff; background: rgba(0, 0, 0, 0.55);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-page__actions { .fc-page__actions {
position: absolute; top: 4px; right: 4px; z-index: 2; position: absolute; top: 4px; right: 4px; z-index: 2;
display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s; display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s;
@@ -439,12 +328,13 @@ onMounted(async () => {
} }
.fc-gap { .fc-gap {
grid-column: 1 / -1;
display: flex; align-items: center; gap: 6px; padding: 2px 10px; display: flex; align-items: center; gap: 6px; padding: 2px 10px;
font-size: 12px; color: rgb(var(--v-theme-warning, var(--v-theme-accent))); font-size: 12px; color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
} }
.fc-parts__add { display: flex; gap: 8px; margin-top: 4px; }
.fc-series__empty { .fc-series__empty {
padding: 40px; text-align: center; color: rgb(var(--v-theme-on-surface-variant)); padding: 40px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
max-width: 1100px;
} }
/* Picker slide-over */ /* Picker slide-over */
+34 -29
View File
@@ -13,17 +13,14 @@ function stubFetch(handler) {
}) })
} }
// FC-6.x: a flat page run + cosmetic chapter dividers.
const SERIES_BODY = { const SERIES_BODY = {
series: { id: 7, name: 'V' }, series: { id: 7, name: 'V' },
chapters: [ pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't', source_post: null }],
{ dividers: [
id: 1, chapter_number: 1, title: null, is_placeholder: false, { id: 1, anchor_image_id: 1, page_number: 1, title: null, stated_part: null }
stated_page_start: null, stated_page_end: null,
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't' }]
}
], ],
gaps: [], part_gaps: []
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't' }]
} }
describe('seriesManage', () => { describe('seriesManage', () => {
@@ -35,17 +32,17 @@ describe('seriesManage', () => {
expect(moveItem([1, 2, 3], 0, 2)).toEqual([2, 3, 1]) expect(moveItem([1, 2, 3], 0, 2)).toEqual([2, 3, 1])
}) })
it('load fetches chapters + series and picks a default target', async () => { it('load fetches the flat pages + dividers + series', async () => {
const s = useSeriesManageStore() const s = useSeriesManageStore()
stubFetch(() => ({ status: 200, body: SERIES_BODY })) stubFetch(() => ({ status: 200, body: SERIES_BODY }))
await s.load(7) await s.load(7)
expect(s.series).toEqual({ id: 7, name: 'V' }) expect(s.series).toEqual({ id: 7, name: 'V' })
expect(s.chapters.map(c => c.id)).toEqual([1]) expect(s.pages.map(p => p.image_id)).toEqual([1])
expect(s.dividers.map(d => d.id)).toEqual([1])
expect(s.pageCount).toBe(1) expect(s.pageCount).toBe(1)
expect(s.targetChapterId).toBe(1)
}) })
it('reorderPages posts ordered ids to the chapter reorder route', async () => { it('reorder posts ordered ids to the series reorder route', async () => {
const s = useSeriesManageStore() const s = useSeriesManageStore()
s.tagId = 7 s.tagId = 7
const calls = [] const calls = []
@@ -54,28 +51,29 @@ describe('seriesManage', () => {
if (url.includes('/reorder')) return { status: 200, body: { ok: true } } if (url.includes('/reorder')) return { status: 200, body: { ok: true } }
return { status: 200, body: SERIES_BODY } return { status: 200, body: SERIES_BODY }
}) })
await s.reorderPages(3, [30, 10, 20]) await s.reorder([30, 10, 20])
const r = calls.find(c => c.url.includes('/chapters/3/reorder')) const r = calls.find(c => c.url.includes('/reorder'))
expect(r.url).toContain('/api/series/7/chapters/3/reorder') expect(r.url).toContain('/api/series/7/reorder')
expect(r.body).toEqual({ image_ids: [30, 10, 20] }) expect(r.body).toEqual({ image_ids: [30, 10, 20] })
}) })
it('moveChapter reorders via the chapter id list', async () => { it('createDivider posts the anchor image + labels', async () => {
const s = useSeriesManageStore() const s = useSeriesManageStore()
s.tagId = 7 s.tagId = 7
s.chapters = [{ id: 1 }, { id: 2 }, { id: 3 }]
const calls = [] const calls = []
stubFetch((url, init) => { stubFetch((url, init) => {
calls.push({ url, body: init.body ? JSON.parse(init.body) : null }) calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : null })
if (url.includes('/chapters/reorder')) return { status: 200, body: { ok: true } } if (init.method === 'POST' && url.endsWith('/chapters'))
return { status: 200, body: { id: 2, anchor_image_id: 5 } }
return { status: 200, body: SERIES_BODY } return { status: 200, body: SERIES_BODY }
}) })
await s.moveChapter(2, -1) await s.createDivider(5, { title: 'X', statedPart: 2 })
const r = calls.find(c => c.url.includes('/chapters/reorder')) const c = calls.find(x => x.method === 'POST' && x.url.endsWith('/chapters'))
expect(r.body).toEqual({ chapter_ids: [2, 1, 3] }) expect(c.url).toContain('/api/series/7/chapters')
expect(c.body).toEqual({ anchor_image_id: 5, title: 'X', stated_part: 2 })
}) })
it('setChapterPart patches stated_part on the chapter', async () => { it('setDividerPart patches stated_part on the divider', async () => {
const s = useSeriesManageStore() const s = useSeriesManageStore()
s.tagId = 7 s.tagId = 7
const calls = [] const calls = []
@@ -84,7 +82,7 @@ describe('seriesManage', () => {
if (init.method === 'PATCH') return { status: 200, body: { ok: true } } if (init.method === 'PATCH') return { status: 200, body: { ok: true } }
return { status: 200, body: SERIES_BODY } return { status: 200, body: SERIES_BODY }
}) })
await s.setChapterPart(1, 2) await s.setDividerPart(1, 2)
const p = calls.find(c => c.method === 'PATCH') const p = calls.find(c => c.method === 'PATCH')
expect(p.url).toContain('/api/series/7/chapters/1') expect(p.url).toContain('/api/series/7/chapters/1')
expect(p.body).toEqual({ stated_part: 2 }) expect(p.body).toEqual({ stated_part: 2 })
@@ -94,18 +92,25 @@ describe('seriesManage', () => {
const s = useSeriesManageStore() const s = useSeriesManageStore()
stubFetch(() => ({ stubFetch(() => ({
status: 200, status: 200,
body: { ...SERIES_BODY, part_gaps: [{ after_chapter_id: 1, start: 2, end: 2 }] } body: { ...SERIES_BODY, part_gaps: [{ after_divider_id: 1, start: 2, end: 2 }] }
})) }))
await s.load(7) await s.load(7)
expect(s.partGaps).toHaveLength(1) expect(s.partGaps).toHaveLength(1)
expect(s.partGapAfter(1)).toEqual({ after_chapter_id: 1, start: 2, end: 2 }) expect(s.partGapAfter(1)).toEqual({ after_divider_id: 1, start: 2, end: 2 })
expect(s.partGapAfter(99)).toBeNull() expect(s.partGapAfter(99)).toBeNull()
}) })
it('addSelected posts selection + target chapter then clears', async () => { it('dividerAt finds a divider by its anchor image', async () => {
const s = useSeriesManageStore()
stubFetch(() => ({ status: 200, body: SERIES_BODY }))
await s.load(7)
expect(s.dividerAt(1)?.id).toBe(1)
expect(s.dividerAt(99)).toBeNull()
})
it('addSelected posts the selection (no chapter) then clears', async () => {
const s = useSeriesManageStore() const s = useSeriesManageStore()
s.tagId = 7 s.tagId = 7
s.targetChapterId = 5
s.pickerSelection = [9, 10] s.pickerSelection = [9, 10]
const calls = [] const calls = []
stubFetch((url, init) => { stubFetch((url, init) => {
@@ -116,7 +121,7 @@ describe('seriesManage', () => {
}) })
await s.addSelected() await s.addSelected()
const add = calls.find(c => c.url.endsWith('/api/series/7/pages')) const add = calls.find(c => c.url.endsWith('/api/series/7/pages'))
expect(add.body).toEqual({ image_ids: [9, 10], chapter_id: 5 }) expect(add.body).toEqual({ image_ids: [9, 10] })
expect(s.pickerSelection).toEqual([]) expect(s.pickerSelection).toEqual([])
}) })
}) })
+19 -10
View File
@@ -178,14 +178,21 @@ def test_find_unused_tags_excludes_fandom_referenced_by_character(db_sync):
assert "NobodysFandom" in names # genuinely orphaned → still swept assert "NobodysFandom" in names # genuinely orphaned → still swept
def test_find_unused_tags_excludes_series_with_only_chapters(db_sync): def test_find_unused_tags_excludes_series_with_a_divider(db_sync):
# An all-placeholder series has chapters but no pages yet — not unused. # A series with a chapter divider (which anchors to a page) is in use.
series = _make_tag(db_sync, name="EmptySeries", kind=TagKind.series) a = _make_artist(db_sync)
db_sync.add(SeriesChapter(series_tag_id=series.id, chapter_number=1)) series = _make_tag(db_sync, name="DivSeries", kind=TagKind.series)
img = _make_image(
db_sync, artist=a, path="/tmp/fc_divser.jpg", sha256="e" * 64,
)
page = SeriesPage(series_tag_id=series.id, image_id=img.id, page_number=1)
db_sync.add(page)
db_sync.flush()
db_sync.add(SeriesChapter(series_tag_id=series.id, anchor_page_id=page.id))
db_sync.commit() db_sync.commit()
names = [t.name for t in cleanup_service.find_unused_tags(db_sync)] names = [t.name for t in cleanup_service.find_unused_tags(db_sync)]
assert "EmptySeries" not in names assert "DivSeries" not in names
# --- unlink_image_files --------------------------------------------- # --- unlink_image_files ---------------------------------------------
@@ -391,7 +398,12 @@ def test_prune_unused_tags_commit_spares_fandom_and_chaptered_series(
image_record_id=img.id, tag_id=char.id, image_record_id=img.id, tag_id=char.id,
)) ))
series = _make_tag(db_sync, name="EmptySeries", kind=TagKind.series) series = _make_tag(db_sync, name="EmptySeries", kind=TagKind.series)
db_sync.add(SeriesChapter(series_tag_id=series.id, chapter_number=1)) simg = _make_image(
db_sync, artist=a, path=str(tmp_path / "s.jpg"), sha256="e" * 64,
)
db_sync.add(SeriesPage(
series_tag_id=series.id, image_id=simg.id, page_number=1
))
_make_tag(db_sync, name="GenuinelyUnused") _make_tag(db_sync, name="GenuinelyUnused")
db_sync.commit() db_sync.commit()
@@ -480,11 +492,8 @@ def test_reset_content_tagging_deletes_content_keeps_fandom_series(db_sync, tmp_
{"image_record_id": img.id, "tag_id": c.id}, {"image_record_id": img.id, "tag_id": c.id},
{"image_record_id": img.id, "tag_id": s.id}, # series membership {"image_record_id": img.id, "tag_id": s.id}, # series membership
])) ]))
ch = SeriesChapter(series_tag_id=s.id, chapter_number=1)
db_sync.add(ch)
db_sync.flush()
db_sync.add(SeriesPage( db_sync.add(SeriesPage(
series_tag_id=s.id, chapter_id=ch.id, image_id=img.id, page_number=1 series_tag_id=s.id, image_id=img.id, page_number=1
)) ))
db_sync.commit() db_sync.commit()
+78 -95
View File
@@ -1,4 +1,4 @@
"""FC-6.1 — chapter layer over series_page.""" """FC-6.x cosmetic chapter dividers over the flat series page run."""
import pytest import pytest
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -30,155 +30,138 @@ async def _series(db, name):
return (await TagService(db).find_or_create(name, TagKind.series)).id return (await TagService(db).find_or_create(name, TagKind.series)).id
async def _chapter_numbers(db, sid): async def _order(db, sid):
rows = (
await db.execute(
select(SeriesChapter.id, SeriesChapter.chapter_number)
.where(SeriesChapter.series_tag_id == sid)
.order_by(SeriesChapter.chapter_number)
)
).all()
return [(r.id, r.chapter_number) for r in rows]
async def _page_order(db, chapter_id):
rows = ( rows = (
await db.execute( await db.execute(
select(SeriesPage.image_id) select(SeriesPage.image_id)
.where(SeriesPage.chapter_id == chapter_id) .where(SeriesPage.series_tag_id == sid)
.order_by(SeriesPage.page_number) .order_by(SeriesPage.page_number)
) )
).scalars().all() ).scalars().all()
return list(rows) return list(rows)
async def _dividers(db, sid):
"""(anchor_image_id, title, stated_part) ordered by anchor page position."""
rows = (
await db.execute(
select(
SeriesChapter.id,
SeriesPage.image_id,
SeriesChapter.title,
SeriesChapter.stated_part,
)
.join(SeriesPage, SeriesPage.id == SeriesChapter.anchor_page_id)
.where(SeriesChapter.series_tag_id == sid)
.order_by(SeriesPage.page_number)
)
).all()
return rows
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_images_creates_default_chapter(db): async def test_create_divider_anchors_before_a_page(db):
svc = SeriesService(db) svc = SeriesService(db)
sid = await _series(db, "C1") sid = await _series(db, "C1")
i1, i2 = await _img(db), await _img(db) i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2]) await svc.add_images(sid, [i1, i2, i3])
chapters = await _chapter_numbers(db, sid) await svc.create_divider(sid, i2, title="Two", stated_part=2)
assert len(chapters) == 1 divs = await _dividers(db, sid)
assert chapters[0][1] == 1 assert len(divs) == 1
assert await _page_order(db, chapters[0][0]) == [i1, i2] assert divs[0].image_id == i2
assert divs[0].title == "Two"
assert divs[0].stated_part == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_and_target_specific_chapter(db): async def test_create_divider_rejects_image_not_in_series(db):
svc = SeriesService(db) svc = SeriesService(db)
sid = await _series(db, "C2") sid = await _series(db, "C2")
i1, i2 = await _img(db), await _img(db) i1 = await _img(db)
await svc.add_images(sid, [i1]) # default chapter 1 await svc.add_images(sid, [i1])
ch2 = await svc.create_chapter(sid, title="Two") with pytest.raises(SeriesError):
assert ch2["chapter_number"] == 2 await svc.create_divider(sid, 999999)
await svc.add_images(sid, [i2], chapter_id=ch2["id"])
assert await _page_order(db, ch2["id"]) == [i2]
# i1 stayed in chapter 1.
chapters = await _chapter_numbers(db, sid)
assert len(chapters) == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reorder_chapters(db): async def test_one_divider_per_page(db):
svc = SeriesService(db) svc = SeriesService(db)
sid = await _series(db, "C3") sid = await _series(db, "C3")
c1 = (await svc.create_chapter(sid))["id"] i1, i2 = await _img(db), await _img(db)
c2 = (await svc.create_chapter(sid))["id"] await svc.add_images(sid, [i1, i2])
c3 = (await svc.create_chapter(sid))["id"] await svc.create_divider(sid, i2)
await svc.reorder_chapters(sid, [c3, c1, c2])
assert await _chapter_numbers(db, sid) == [(c3, 1), (c1, 2), (c2, 3)]
with pytest.raises(SeriesError): with pytest.raises(SeriesError):
await svc.reorder_chapters(sid, [c1, c2]) await svc.create_divider(sid, i2)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reorder_pages_within_chapter(db): async def test_update_divider_sets_part_title_and_moves_anchor(db):
svc = SeriesService(db) svc = SeriesService(db)
sid = await _series(db, "C4") sid = await _series(db, "C4")
i1, i2, i3 = await _img(db), await _img(db), await _img(db) i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2, i3]) await svc.add_images(sid, [i1, i2, i3])
cid = (await _chapter_numbers(db, sid))[0][0] d = await svc.create_divider(sid, i2)
await svc.reorder_pages(sid, cid, [i3, i1, i2]) await svc.update_divider(
assert await _page_order(db, cid) == [i3, i1, i2] sid, d["id"], set_title=True, title="X", set_part=True, stated_part=3
)
await svc.update_divider(
sid, d["id"], set_anchor=True, anchor_image_id=i3
)
divs = await _dividers(db, sid)
assert divs[0].image_id == i3
assert divs[0].title == "X"
assert divs[0].stated_part == 3
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_legacy_reorder_rejects_multi_chapter(db): async def test_delete_divider_leaves_pages(db):
svc = SeriesService(db) svc = SeriesService(db)
sid = await _series(db, "C5") sid = await _series(db, "C5")
i1 = await _img(db) i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1]) await svc.add_images(sid, [i1, i2, i3])
await svc.create_chapter(sid) # now 2 chapters d = await svc.create_divider(sid, i2)
with pytest.raises(SeriesError): await svc.delete_divider(sid, d["id"])
await svc.reorder(sid, [i1]) assert await _order(db, sid) == [i1, i2, i3]
assert len(await _dividers(db, sid)) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_merge_chapter_moves_pages_and_renumbers(db): async def test_removing_anchor_page_cascades_its_divider(db):
svc = SeriesService(db) svc = SeriesService(db)
sid = await _series(db, "C6") sid = await _series(db, "C6")
i1, i2, i3 = await _img(db), await _img(db), await _img(db) i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1]) # chapter 1 await svc.add_images(sid, [i1, i2, i3])
c2 = (await svc.create_chapter(sid))["id"] await svc.create_divider(sid, i2)
await svc.add_images(sid, [i2, i3], chapter_id=c2) await svc.remove_images(sid, [i2])
c1 = (await _chapter_numbers(db, sid))[0][0] # the divider anchored at i2 cascaded away; pages compacted to 1..N.
moved = await svc.merge_chapter(sid, c2, c1) assert len(await _dividers(db, sid)) == 0
assert moved == 2 assert await _order(db, sid) == [i1, i3]
assert await _page_order(db, c1) == [i1, i2, i3]
# source chapter gone; remaining renumbered 1..N.
assert await _chapter_numbers(db, sid) == [(c1, 1)]
@pytest.mark.asyncio
async def test_delete_chapter_cascades_pages_and_renumbers(db):
svc = SeriesService(db)
sid = await _series(db, "C7")
i1, i2 = await _img(db), await _img(db)
await svc.add_images(sid, [i1])
c2 = (await svc.create_chapter(sid))["id"]
await svc.add_images(sid, [i2], chapter_id=c2)
await svc.delete_chapter(sid, c2)
assert [c for _, c in await _chapter_numbers(db, sid)] == [1]
# i2's page cascaded away with its chapter.
cnt = await db.scalar( cnt = await db.scalar(
select(func.count()).select_from(SeriesPage) select(func.count()).select_from(SeriesChapter)
.where(SeriesPage.image_id == i2) .where(SeriesChapter.series_tag_id == sid)
) )
assert cnt == 0 assert cnt == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_cover_brings_chapter_and_page_to_front(db): async def test_list_pages_returns_dividers_and_part_gaps(db):
svc = SeriesService(db) svc = SeriesService(db)
sid = await _series(db, "C8") sid = await _series(db, "C7")
i1, i2, i3 = await _img(db), await _img(db), await _img(db) i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1]) # chapter 1 await svc.add_images(sid, [i1, i2, i3])
c2 = (await svc.create_chapter(sid))["id"] await svc.create_divider(sid, i1, stated_part=1)
await svc.add_images(sid, [i2, i3], chapter_id=c2) await svc.create_divider(sid, i3, stated_part=3)
await svc.set_cover(sid, i3)
chapters = await _chapter_numbers(db, sid)
assert chapters[0][0] == c2 # i3's chapter is now first
assert (await _page_order(db, c2))[0] == i3 # i3 first in its chapter
@pytest.mark.asyncio
async def test_list_pages_flags_gaps_from_stated_ranges(db):
svc = SeriesService(db)
sid = await _series(db, "C9")
await svc.create_chapter(sid, stated_page_start=1, stated_page_end=4)
await svc.create_chapter(sid, stated_page_start=9, stated_page_end=12)
out = await svc.list_pages(sid) out = await svc.list_pages(sid)
assert len(out["chapters"]) == 2 assert len(out["dividers"]) == 2
assert len(out["gaps"]) == 1 assert len(out["part_gaps"]) == 1
assert out["gaps"][0]["start"] == 5 assert out["part_gaps"][0]["start"] == 2
assert out["gaps"][0]["end"] == 8 assert out["part_gaps"][0]["end"] == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_with_stated_pages_records_them(db): async def test_add_with_stated_pages_records_them(db):
svc = SeriesService(db) svc = SeriesService(db)
sid = await _series(db, "C10") sid = await _series(db, "C8")
i1, i2 = await _img(db), await _img(db) i1, i2 = await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2], stated_pages={i1: 9, i2: 10}) await svc.add_images(sid, [i1, i2], stated_pages={i1: 9, i2: 10})
rows = dict( rows = dict(
+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 import pytest
from sqlalchemy import select
from backend.app.models import Artist, ImageRecord, Post, TagKind 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 from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@@ -53,12 +55,10 @@ async def test_promote_post_to_series(db):
assert out["name"] == "My Comic pages 1-3" assert out["name"] == "My Comic pages 1-3"
data = await svc.list_pages(out["series_tag_id"]) data = await svc.list_pages(out["series_tag_id"])
assert len(data["chapters"]) == 1 # one flat run in capture order, no dividers, stated pages parsed 1..3
ch = data["chapters"][0] assert [p["image_id"] for p in data["pages"]] == imgs
assert ch["stated_page_start"] == 1 assert [p["stated_page"] for p in data["pages"]] == [1, 2, 3]
assert ch["stated_page_end"] == 3 assert data["dividers"] == []
assert [p["image_id"] for p in ch["pages"]] == imgs # capture order
assert [p["stated_page"] for p in ch["pages"]] == [1, 2, 3]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -66,31 +66,35 @@ async def test_promote_requires_images(db):
svc = SeriesService(db) svc = SeriesService(db)
artist = await _artist(db, "Empty Artist") artist = await _artist(db, "Empty Artist")
post = await _post(db, artist, "No images", "pp2") post = await _post(db, artist, "No images", "pp2")
from backend.app.services.series_service import SeriesError
with pytest.raises(SeriesError): with pytest.raises(SeriesError):
await svc.promote_post_to_series(post.id) await svc.promote_post_to_series(post.id)
@pytest.mark.asyncio @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) svc = SeriesService(db)
artist = await _artist(db, "Story Artist") artist = await _artist(db, "Story Artist")
sid = (await TagService(db).find_or_create("Story", TagKind.series)).id sid = (await TagService(db).find_or_create("Story", TagKind.series)).id
# An existing chapter stating pages 9-12. # Seed the series with two existing pages.
await svc.create_chapter(sid, stated_page_start=9, stated_page_end=12) seed_post = await _post(db, artist, "seed", "pp3a")
# A post stating pages 1-4 should slot BEFORE the 9-12 chapter. seed = await _post_images(db, seed_post, artist, 2)
post = await _post(db, artist, "Story pages 1-4", "pp3") await svc.add_images(sid, seed)
await _post_images(db, post, artist, 4) # A later post's pages append to the END (operator orders afterward).
out = await svc.add_post_as_chapter(sid, post.id) post = await _post(db, artist, "Story pages 9-11", "pp3b")
assert out["added"] == 4 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) rows = (
chapters = data["chapters"] await db.execute(
assert chapters[0]["stated_page_start"] == 1 # new one is first select(SeriesPage.image_id, SeriesPage.stated_page)
assert chapters[1]["stated_page_start"] == 9 .where(SeriesPage.series_tag_id == sid)
# gap 5-8 flagged between them .order_by(SeriesPage.page_number)
assert data["gaps"][0]["start"] == 5 )
assert data["gaps"][0]["end"] == 8 ).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 @pytest.mark.asyncio
@@ -104,61 +108,32 @@ async def test_list_series_cards(db):
rows = await svc.list_series() rows = await svc.list_series()
card = next(r for r in rows if r["id"] == out["series_tag_id"]) card = next(r for r in rows if r["id"] == out["series_tag_id"])
assert card["name"] == "Card Comic pages 1-2" 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["page_count"] == 2
assert card["artist_name"] == "Card Artist" assert card["artist_name"] == "Card Artist"
assert card["cover_thumbnail_url"] assert card["cover_thumbnail_url"]
assert card["has_gap"] is False assert card["has_gap"] is False
# artist filter keeps it; a different artist id drops it. # 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 any(
assert all(r["id"] != out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id + 99999)) r["id"] == out["series_tag_id"]
for r in await svc.list_series(artist_id=artist.id)
)
# --- FC-6.4: stated_part, part_gaps, source_post label --------------------- assert all(
r["id"] != out["series_tag_id"]
for r in await svc.list_series(artist_id=artist.id + 99999)
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_chapter_sets_and_clears_stated_part(db): async def test_source_post_label_is_per_page(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):
svc = SeriesService(db) svc = SeriesService(db)
artist = await _artist(db, "Src Artist") artist = await _artist(db, "Src Artist")
post = await _post(db, artist, "Source Comic pages 1-2", "pp5") post = await _post(db, artist, "Source Comic pages 1-2", "pp5")
await _post_images(db, post, artist, 2) await _post_images(db, post, artist, 2)
out = await svc.promote_post_to_series(post.id) out = await svc.promote_post_to_series(post.id)
data = await svc.list_pages(out["series_tag_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 is not None
assert sp["id"] == post.id assert sp["id"] == post.id
assert sp["title"] == "Source Comic pages 1-2" assert sp["title"] == "Source Comic pages 1-2"
+3 -2
View File
@@ -79,7 +79,7 @@ async def test_match_is_idempotent_under_unique(db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_accept_adds_chapter_and_marks_added(db): async def test_accept_appends_pages_and_marks_added(db):
artist = await _artist(db, "Accept Artist") artist = await _artist(db, "Accept Artist")
a = await _post_with_images(db, artist, "Saga pages 1-4", "a", 3) a = await _post_with_images(db, artist, "Saga pages 1-4", "a", 3)
sid = await _series_from(db, a) sid = await _series_from(db, a)
@@ -90,7 +90,8 @@ async def test_accept_adds_chapter_and_marks_added(db):
await svc.accept(sug["id"]) await svc.accept(sug["id"])
data = await SeriesService(db).list_pages(sid) data = await SeriesService(db).list_pages(sid)
assert len(data["chapters"]) == 2 # b became a 2nd chapter # b's pages were appended to the series' flat run (3 from a + 3 from b).
assert len(data["pages"]) == 6
status = await db.scalar( status = await db.scalar(
select(SeriesSuggestion.status).where(SeriesSuggestion.id == sug["id"]) select(SeriesSuggestion.status).where(SeriesSuggestion.id == sug["id"])
) )
+17 -15
View File
@@ -25,20 +25,10 @@ async def _img(db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def _chapter(db, series_tag_id):
ch = SeriesChapter(series_tag_id=series_tag_id, chapter_number=1)
db.add(ch)
await db.flush()
return ch.id
async def test_series_page_roundtrip_and_unique_image(db): async def test_series_page_roundtrip_and_unique_image(db):
s = await TagService(db).find_or_create("Vol1", TagKind.series) s = await TagService(db).find_or_create("Vol1", TagKind.series)
i1 = await _img(db) i1 = await _img(db)
ch = await _chapter(db, s.id) db.add(SeriesPage(series_tag_id=s.id, image_id=i1, page_number=1))
db.add(SeriesPage(
series_tag_id=s.id, chapter_id=ch, image_id=i1, page_number=1
))
await db.flush() await db.flush()
got = await db.scalar( got = await db.scalar(
select(SeriesPage.page_number).where(SeriesPage.image_id == i1) select(SeriesPage.page_number).where(SeriesPage.image_id == i1)
@@ -46,9 +36,21 @@ async def test_series_page_roundtrip_and_unique_image(db):
assert got == 1 assert got == 1
# UNIQUE image_id: same image can't be a page twice (any series). # UNIQUE image_id: same image can't be a page twice (any series).
s2 = await TagService(db).find_or_create("Vol2", TagKind.series) s2 = await TagService(db).find_or_create("Vol2", TagKind.series)
ch2 = await _chapter(db, s2.id) db.add(SeriesPage(series_tag_id=s2.id, image_id=i1, page_number=1))
db.add(SeriesPage( with pytest.raises(IntegrityError):
series_tag_id=s2.id, chapter_id=ch2, image_id=i1, page_number=1 await db.flush()
))
@pytest.mark.asyncio
async def test_chapter_divider_anchor_is_unique(db):
# A chapter divider anchors to the page that begins it; one divider per page.
s = await TagService(db).find_or_create("Vol3", TagKind.series)
i1 = await _img(db)
p = SeriesPage(series_tag_id=s.id, image_id=i1, page_number=1)
db.add(p)
await db.flush()
db.add(SeriesChapter(series_tag_id=s.id, anchor_page_id=p.id))
await db.flush()
db.add(SeriesChapter(series_tag_id=s.id, anchor_page_id=p.id))
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
await db.flush() await db.flush()
+5 -6
View File
@@ -409,19 +409,18 @@ async def test_merge_repoints_series_pages(db):
a = await svc.find_or_create("SerA", TagKind.series) a = await svc.find_or_create("SerA", TagKind.series)
b = await svc.find_or_create("SerB", TagKind.series) b = await svc.find_or_create("SerB", TagKind.series)
img = await _img(db) img = await _img(db)
cha = SeriesChapter(series_tag_id=a.id, chapter_number=1) p = SeriesPage(series_tag_id=a.id, image_id=img, page_number=1)
db.add(cha) db.add(p)
await db.flush() await db.flush()
db.add(SeriesPage( cha = SeriesChapter(series_tag_id=a.id, anchor_page_id=p.id)
series_tag_id=a.id, chapter_id=cha.id, image_id=img, page_number=1 db.add(cha)
))
await db.flush() await db.flush()
await svc.merge(a.id, b.id) await svc.merge(a.id, b.id)
owner = await db.scalar( owner = await db.scalar(
select(SeriesPage.series_tag_id).where(SeriesPage.image_id == img) select(SeriesPage.series_tag_id).where(SeriesPage.image_id == img)
) )
assert owner == b.id assert owner == b.id
# The chapter moved to the survivor too, so the page's FK stayed valid. # The chapter divider moved to the survivor too (its anchor page moved).
ch_owner = await db.scalar( ch_owner = await db.scalar(
select(SeriesChapter.series_tag_id).where(SeriesChapter.id == cha.id) select(SeriesChapter.series_tag_id).where(SeriesChapter.id == cha.id)
) )