diff --git a/alembic/versions/0047_series_chapter_dividers.py b/alembic/versions/0047_series_chapter_dividers.py new file mode 100644 index 0000000..5afd074 --- /dev/null +++ b/alembic/versions/0047_series_chapter_dividers.py @@ -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", + ) diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 4fbe0a0..acc1b1b 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -391,14 +391,9 @@ async def series_add(tag_id: int): ids, err = _parse_bulk_ids(body, max_ids=500) if err: return err - chapter_id, cerr = _opt_int(body, "chapter_id") - if cerr: - return cerr async with get_session() as session: try: - n = await SeriesService(session).add_images( - tag_id, ids, chapter_id=chapter_id - ) + n = await SeriesService(session).add_images(tag_id, ids) except SeriesError as exc: return _series_err(exc) await session.commit() @@ -453,30 +448,30 @@ async def series_cover(tag_id: int): 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//chapters", methods=["POST"]) async def series_chapter_create(tag_id: int): 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") if title is not None and not isinstance(title, str): return jsonify({"error": "title must be a string"}), 400 - is_placeholder = bool(body.get("is_placeholder", False)) - start, serr = _opt_int(body, "stated_page_start") - if serr: - return serr - end, eerr = _opt_int(body, "stated_page_end") - if eerr: - return eerr + part, perr = _opt_int(body, "stated_part") + if perr: + return perr async with get_session() as session: try: - ch = await SeriesService(session).create_chapter( - tag_id, - title=title, - is_placeholder=is_placeholder, - stated_page_start=start, - stated_page_end=end, + ch = await SeriesService(session).create_divider( + tag_id, anchor, title=title, stated_part=part, ) except SeriesError as exc: return _series_err(exc) @@ -484,21 +479,6 @@ async def series_chapter_create(tag_id: int): return jsonify(ch) -@tags_bp.route("/series//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( "/series//chapters/", methods=["PATCH"] ) @@ -514,19 +494,18 @@ async def series_chapter_update(tag_id: int, chapter_id: int): if perr: return perr kwargs.update(set_part=True, stated_part=part) - if "stated_page_start" in body: - start, serr = _opt_int(body, "stated_page_start") - if serr: - return serr - kwargs.update(set_start=True, stated_page_start=start) - if "stated_page_end" in body: - end, eerr = _opt_int(body, "stated_page_end") - if eerr: - return eerr - kwargs.update(set_end=True, stated_page_end=end) + if "anchor_image_id" in body: + anchor, aerr = _opt_int(body, "anchor_image_id") + if aerr: + return aerr + if anchor is None: + return jsonify( + {"error": "anchor_image_id must be an integer"} + ), 400 + kwargs.update(set_anchor=True, anchor_image_id=anchor) async with get_session() as session: try: - await SeriesService(session).update_chapter( + await SeriesService(session).update_divider( tag_id, chapter_id, **kwargs ) 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 with get_session() as session: try: - await SeriesService(session).delete_chapter(tag_id, chapter_id) - except SeriesError as exc: - return _series_err(exc) - await session.commit() - return jsonify({"ok": True}) - - -@tags_bp.route( - "/series//chapters//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//chapters//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) + await SeriesService(session).delete_divider(tag_id, chapter_id) except SeriesError as exc: return _series_err(exc) await session.commit() @@ -635,7 +576,7 @@ async def series_add_post(tag_id: int): return jsonify({"error": "post_id required"}), 400 async with get_session() as session: 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: return _series_err(exc) await session.commit() diff --git a/backend/app/models/series_chapter.py b/backend/app/models/series_chapter.py index 9695272..ff7698a 100644 --- a/backend/app/models/series_chapter.py +++ b/backend/app/models/series_chapter.py @@ -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. -Reading order is (chapter.chapter_number, series_page.page_number): chapter_number -sets the order of chapters, page_number orders pages within a chapter. +A series is ONE flat, series-global ordered run of SeriesPages. A chapter is NOT +a container — it owns no pages. It is a labeled divider anchored to the page that +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 -wholesale, mirroring series_page.page_number, so a reorder can't transiently -collide on a unique index. +Dividers never affect page ordering or the series-global page numbers; they stay +pinned to their anchor page across reorders. anchor_page_id is UNIQUE — at most +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 -a section the operator doesn't have yet; it holds no pages and shows as a gap in -the reader. stated_page_start/end carry the page range parsed from the source -post (FC-6.2), used to flag missing-page gaps; both are nullable when unknown. - -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). +title is the optional chapter name; stated_part is the optional operator-facing +"Part N" label (shown instead of a derived ordinal when set). """ 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 .base import Base @@ -35,14 +29,13 @@ class SeriesChapter(Base): series_tag_id: Mapped[int] = mapped_column( ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True ) - chapter_number: Mapped[int] = mapped_column(Integer, nullable=False) - stated_part: Mapped[int | None] = mapped_column(Integer, nullable=True) - title: Mapped[str | None] = mapped_column(Text, nullable=True) - is_placeholder: Mapped[bool] = mapped_column( - Boolean, nullable=False, server_default="false" + anchor_page_id: Mapped[int] = mapped_column( + ForeignKey("series_page.id", ondelete="CASCADE"), + nullable=False, + unique=True, ) - stated_page_start: Mapped[int | None] = mapped_column(Integer, nullable=True) - stated_page_end: Mapped[int | None] = mapped_column(Integer, nullable=True) + title: Mapped[str | None] = mapped_column(Text, nullable=True) + stated_part: Mapped[int | None] = mapped_column(Integer, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) diff --git a/backend/app/models/series_page.py b/backend/app/models/series_page.py index 48384ef..41b9536 100644 --- a/backend/app/models/series_page.py +++ b/backend/app/models/series_page.py @@ -1,12 +1,15 @@ """SeriesPage — ordered image membership for a series-kind Tag. -A series IS a Tag with kind='series'; series_page gives it ordered pages, -grouped into chapters (FC-6). An image belongs to at most one series -(UNIQUE image_id) ⇒ at most one chapter. Reading order is -(chapter.chapter_number, series_page.page_number): page_number orders pages -WITHIN a chapter and is an ordering key only (not unique) — reorder rewrites -1..N wholesale. stated_page carries the page number parsed from the source -post (FC-6.2), nullable when unknown. +A series IS a Tag with kind='series'; series_page gives it a SINGLE flat, +series-global ordered run of pages (FC-6.x divider reframe). An image belongs to +at most one series (UNIQUE image_id). Reading order is `page_number` alone — a +series-wide ordering key (not unique), rewritten 1..N wholesale on reorder so a +reorder can't transiently collide on an index. + +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 @@ -24,11 +27,6 @@ class SeriesPage(Base): series_tag_id: Mapped[int] = mapped_column( 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( ForeignKey("image_record.id", ondelete="CASCADE"), nullable=False, diff --git a/backend/app/services/series_match_service.py b/backend/app/services/series_match_service.py index 364229f..eb4a7c9 100644 --- a/backend/app/services/series_match_service.py +++ b/backend/app/services/series_match_service.py @@ -18,7 +18,6 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from ..models import ImageRecord, Post, Tag, TagKind -from ..models.series_chapter import SeriesChapter from ..models.series_page import SeriesPage from ..models.series_suggestion import SeriesSuggestion from ..models.tag import image_tag @@ -144,9 +143,11 @@ class SeriesMatchService: return [t for t in rows if t] 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( - select(func.max(SeriesChapter.stated_page_end)).where( - SeriesChapter.series_tag_id == series_tag_id + select(func.max(SeriesPage.stated_page)).where( + SeriesPage.series_tag_id == series_tag_id ) ) @@ -288,7 +289,7 @@ class SeriesMatchService: raise SeriesError(f"suggestion {suggestion_id} not found") if s.status != "pending": 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.status = "added" diff --git a/backend/app/services/series_service.py b/backend/app/services/series_service.py index 9fbb634..5fb2bbe 100644 --- a/backend/app/services/series_service.py +++ b/backend/app/services/series_service.py @@ -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 -chapter may be a placeholder (no pages) reserving a slot. An image lives in at -most one series ⇒ one chapter (series_page.image_id is UNIQUE). +Reading order is series_page.page_number alone (rewritten 1..N wholesale on any +reorder; page_number is not unique, so a reorder never transiently collides). A +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. """ -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.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ..models import Artist, ImageRecord, Post, Tag, TagKind @@ -51,12 +56,56 @@ class SeriesService: out.append(int(x)) 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 = ( await self.session.execute( select(SeriesChapter).where( and_( - SeriesChapter.id == chapter_id, + SeriesChapter.id == divider_id, SeriesChapter.series_tag_id == series_tag_id, ) ) @@ -64,94 +113,32 @@ class SeriesService: ).scalar_one_or_none() if row is None: 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 - 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 ------------------------------------------------------------- @staticmethod - def _gaps(chapters: list[dict]) -> list[dict]: - """Missing-page gaps between consecutive chapters with stated ranges.""" + def _part_gaps(dividers: list[dict]) -> list[dict]: + """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] = [] prev = None - for ch in chapters: - start = ch["stated_page_start"] - 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"] + for d in dividers: + cur = d["stated_part"] if cur is None: continue if prev is not None and cur > prev["stated_part"] + 1: out.append( { - "after_chapter_id": prev["id"], + "after_divider_id": prev["id"], "start": prev["stated_part"] + 1, "end": cur - 1, } ) - prev = ch + prev = d return out async def list_pages(self, series_tag_id: int) -> dict: @@ -159,13 +146,6 @@ class SeriesService: rows = ( await self.session.execute( 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.page_number, SeriesPage.stated_page, @@ -176,71 +156,62 @@ class SeriesService: ImageRecord.primary_post_id, Post.post_title, ) - .select_from(SeriesChapter) - .outerjoin(SeriesPage, SeriesPage.chapter_id == SeriesChapter.id) - .outerjoin(ImageRecord, ImageRecord.id == SeriesPage.image_id) + .select_from(SeriesPage) + .join(ImageRecord, ImageRecord.id == SeriesPage.image_id) .outerjoin(Post, Post.id == ImageRecord.primary_post_id) - .where(SeriesChapter.series_tag_id == series_tag_id) - .order_by( - SeriesChapter.chapter_number.asc(), - SeriesPage.page_number.asc(), - ) + .where(SeriesPage.series_tag_id == series_tag_id) + .order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc()) ) ).all() - - 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 = { + pages = [ + { "image_id": r.image_id, - "chapter_id": r.chapter_id, "page_number": r.page_number, "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]}", + "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) - flat.append(page) + for r in rows + ] - # A chapter's source_post is set only when every page shares one post — - # the common case (a series authored from a post). Mixed chapters stay - # null rather than guessing. - for ch in chapters: - seen = posts_seen.get(ch["id"], {}) - if len(seen) == 1: - pid, title = next(iter(seen.items())) - ch["source_post"] = {"id": pid, "title": title} + # Dividers, ordered by the global position of their anchor page. + drows = ( + await self.session.execute( + select( + SeriesChapter.id, + SeriesChapter.title, + SeriesChapter.stated_part, + 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 { "series": {"id": tag.id, "name": tag.name}, - "chapters": chapters, - "pages": flat, # back-compat: flat reading order across chapters - "gaps": self._gaps(chapters), - "part_gaps": self._part_gaps(chapters), + "pages": pages, + "dividers": dividers, + "part_gaps": self._part_gaps(dividers), } # ---- pages ------------------------------------------------------------ @@ -249,19 +220,13 @@ class SeriesService: self, series_tag_id: int, image_ids: list[int], - chapter_id: int | None = None, stated_pages: dict[int, int] | None = None, ) -> int: - """Append images as pages of a chapter (the series' default chapter when - chapter_id is None). Images already in THIS series are left untouched; - images in another series are moved here (image_id is UNIQUE).""" + """Append images as pages at the END of the series' flat run. Images + already in THIS series are left untouched; images in another series are + moved here (image_id is UNIQUE).""" await self._require_series(series_tag_id) 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( ( await self.session.execute( @@ -273,15 +238,16 @@ class SeriesService: to_add = [i for i in ids if existing.get(i) != series_tag_id] if not to_add: 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( SeriesPage.__table__.delete().where(SeriesPage.image_id.in_(to_add)) ) max_pn = ( await self.session.scalar( - select(func.coalesce(func.max(SeriesPage.page_number), 0)).where( - SeriesPage.chapter_id == chapter_id - ) + select( + func.coalesce(func.max(SeriesPage.page_number), 0) + ).where(SeriesPage.series_tag_id == series_tag_id) ) ) or 0 sp = stated_pages or {} @@ -290,7 +256,6 @@ class SeriesService: [ { "series_tag_id": series_tag_id, - "chapter_id": chapter_id, "image_id": iid, "page_number": max_pn + offset, "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 - async def reorder_pages( - self, series_tag_id: int, chapter_id: int, ordered_image_ids: list[int] + async def reorder( + self, series_tag_id: int, ordered_image_ids: list[int] ) -> 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_chapter(series_tag_id, chapter_id) 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): 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): await self.session.execute( update(SeriesPage) .where( and_( - SeriesPage.chapter_id == chapter_id, + SeriesPage.series_tag_id == series_tag_id, SeriesPage.image_id == iid, ) ) .values(page_number=idx) ) - async def reorder( - self, series_tag_id: int, ordered_image_ids: list[int] - ) -> 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.""" + async def set_cover(self, series_tag_id: int, image_id: int) -> None: + """Cover = the first page. Move the image to the front of the series.""" await self._require_series(series_tag_id) - chapters = await self._chapter_order(series_tag_id) - if len(chapters) > 1: - raise SeriesError( - "series has multiple chapters; reorder a specific chapter" + pages = await self._page_order(series_tag_id) + if image_id not in pages: + raise SeriesError(f"image {image_id} is not in this series") + 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, series_tag_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, + anchor_image_id: int, *, title: str | None = None, stated_part: int | None = None, - stated_page_start: int | None = None, - stated_page_end: int | None = None, + ) -> dict: + """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_part: bool = False, - set_start: bool = False, - set_end: bool = False, + set_anchor: bool = False, ) -> None: - """Partial chapter edit. The set_* flags say which fields to write (so - None can be written explicitly, e.g. clearing a stated page or part).""" + """Partial divider edit. set_* flags say which fields to write (so None + 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_chapter(series_tag_id, chapter_id) + await self._require_divider(series_tag_id, divider_id) values: dict = {} if set_title: values["title"] = title if set_part: values["stated_part"] = stated_part - if set_start: - values["stated_page_start"] = stated_page_start - if set_end: - values["stated_page_end"] = stated_page_end + if set_anchor: + if anchor_image_id is None: + raise SeriesError("anchor_image_id required to move a divider") + values["anchor_page_id"] = await self._page_id_for( + series_tag_id, anchor_image_id + ) if not values: return - await self.session.execute( - update(SeriesChapter) - .where(SeriesChapter.id == chapter_id) - .values(**values) - ) - - async def _renumber_chapters(self, series_tag_id: int) -> None: - for idx, cid in enumerate( - 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): + try: + async with self.session.begin_nested(): + await self.session.execute( + update(SeriesChapter) + .where(SeriesChapter.id == divider_id) + .values(**values) + ) + except IntegrityError as exc: raise SeriesError( - "ordered chapter_ids must exactly match the series' chapters" - ) - for idx, cid in enumerate(ordered, start=1): - await self.session.execute( - update(SeriesChapter) - .where(SeriesChapter.id == cid) - .values(chapter_number=idx) - ) + "a chapter divider already starts at that page" + ) from exc - 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_chapter(series_tag_id, chapter_id) - # Pages cascade-delete with the chapter (FK ondelete=CASCADE). + await self._require_divider(series_tag_id, divider_id) await self.session.execute( 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) ------------------------------------------ @@ -553,8 +432,8 @@ class SeriesService: async def promote_post_to_series(self, post_id: int) -> dict: """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 - images as its pages (stated pages parsed from the post when present).""" + Creates a series tag named after the post and the post's images as its + flat pages (stated pages parsed from the post when present).""" from .tag_service import TagService 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] tag = await TagService(self.session).find_or_create(name, TagKind.series) rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}") - start, end = rng if rng else (None, None) - ch = await self.create_chapter( - tag.id, stated_page_start=start, stated_page_end=end, - ) + start = rng[0] if rng else None added = await self.add_images( - tag.id, image_ids, chapter_id=ch["id"], - stated_pages=self._stated_map(image_ids, start), + tag.id, image_ids, stated_pages=self._stated_map(image_ids, start), ) return { - "series_tag_id": tag.id, "name": tag.name, - "chapter_id": ch["id"], "added": added, + "series_tag_id": tag.id, "name": tag.name, "added": added, } - async def add_post_as_chapter( - self, series_tag_id: int, post_id: int - ) -> dict: - """Case 2: append a post as the next chapter of an existing series. The - chapter is titled after the post and slotted by parsed page number when - present (else appended at the end).""" + async def add_post(self, series_tag_id: int, post_id: int) -> dict: + """Case 2: append a post's images to the end of an existing series' flat + run (stated pages parsed from the post when present). No chapter wrapping + — the operator orders/labels afterward. (Phase 2 will stage these as + pending instead of appending directly.)""" await self._require_series(series_tag_id) post = await self.session.get(Post, post_id) if post is None: @@ -593,89 +466,55 @@ class SeriesService: if not image_ids: raise SeriesError("post has no images to add") rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}") - start, end = rng if rng else (None, None) - ch = await self.create_chapter( - series_tag_id, title=post.post_title or None, - stated_page_start=start, stated_page_end=end, - ) + start = rng[0] if rng else None 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), ) - if start is not None: - 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) + return {"series_tag_id": series_tag_id, "added": added} # ---- browse list (FC-6.2) -------------------------------------------- async def list_series( self, *, sort: str = "recent", artist_id: int | None = None ) -> list[dict]: - """Series cards for the browse view: cover thumb, name, artist, chapter - + page counts, a gap flag, and last-updated. sort ∈ recent|name|size.""" - # Page counts + most-recent activity per series. + """Series cards for the browse view: cover thumb, name, artist, divider + + page counts, a missing-Part flag, and last-updated. sort ∈ + recent|name|size.""" + # Page counts + most-recent page activity per series. page_rows = ( await self.session.execute( select( SeriesPage.series_tag_id, func.count().label("pages"), + func.max(SeriesPage.updated_at).label("updated_at"), ).group_by(SeriesPage.series_tag_id) ) ).all() 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( select( SeriesChapter.series_tag_id, SeriesChapter.id, - SeriesChapter.chapter_number, - SeriesChapter.stated_page_start, - SeriesChapter.stated_page_end, - SeriesChapter.updated_at, + SeriesChapter.stated_part, + SeriesPage.page_number, ) + .join(SeriesPage, SeriesPage.id == SeriesChapter.anchor_page_id) .order_by( - SeriesChapter.series_tag_id, - SeriesChapter.chapter_number.asc(), + SeriesChapter.series_tag_id, SeriesPage.page_number.asc() ) ) ).all() - chapters_by_series: dict[int, list] = {} - updated_by_series: dict[int, object] = {} - for r in ch_rows: - 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 + dividers_by_series: dict[int, list] = {} + for r in drows: + dividers_by_series.setdefault(r.series_tag_id, []).append(r) - # Cover = the (chapter_number, page_number)-minimum page per series. + # Cover = the first page (min page_number) of each series. cover_q = ( select( SeriesPage.series_tag_id, @@ -687,13 +526,11 @@ class SeriesService: .over( partition_by=SeriesPage.series_tag_id, order_by=( - SeriesChapter.chapter_number.asc(), - SeriesPage.page_number.asc(), + SeriesPage.page_number.asc(), SeriesPage.id.asc() ), ) .label("rn"), ) - .join(SeriesChapter, SeriesChapter.id == SeriesPage.chapter_id) .join(ImageRecord, ImageRecord.id == SeriesPage.image_id) .subquery() ) @@ -737,20 +574,14 @@ class SeriesService: cover is None or cover.artist_id != artist_id ): continue - chs = chapters_by_series.get(t.id, []) + divs = dividers_by_series.get(t.id, []) gap = bool( - self._gaps( - [ - { - "id": c.id, - "stated_page_start": c.stated_page_start, - "stated_page_end": c.stated_page_end, - } - for c in chs - ] + self._part_gaps( + [{"id": d.id, "stated_part": d.stated_part} for d in divs] ) ) artist = artist_by_id.get(cover.artist_id) if cover else None + updated = updated_by_series.get(t.id) out.append( { "id": t.id, @@ -764,14 +595,10 @@ class SeriesService: ), "artist_name": artist.name 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), "has_gap": gap, - "updated_at": ( - updated_by_series.get(t.id).isoformat() - if updated_by_series.get(t.id) - else None - ), + "updated_at": updated.isoformat() if updated else None, } ) diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 3de2115..93b3ce7 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -518,21 +518,30 @@ class TagService: from ..models.series_chapter import SeriesChapter from ..models.series_page import SeriesPage - # Move the chapters first so the pages' chapter_id FK stays valid: a - # chapter left pointing at src would cascade-delete (with its pages) when - # src is removed. chapter_number may now collide across the merged set — - # acceptable (it's an ordering key, not unique). + # Move src's dividers onto tgt. Their anchor_page_id stays valid — the + # anchored pages move too — and anchor is globally UNIQUE so no collision. await self.session.execute( update(SeriesChapter) .where(SeriesChapter.series_tag_id == src) .values(series_tag_id=tgt) ) - # image_id is UNIQUE across series_page and src != tgt, so an - # image in src's series cannot already be in tgt's — no collision. + # Append src's pages after tgt's so the merged series stays ONE clean + # 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( update(SeriesPage) .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( diff --git a/frontend/src/components/posts/PostSeriesMenu.vue b/frontend/src/components/posts/PostSeriesMenu.vue index 2bc694f..e1081fd 100644 --- a/frontend/src/components/posts/PostSeriesMenu.vue +++ b/frontend/src/components/posts/PostSeriesMenu.vue @@ -28,7 +28,8 @@ Add to series

- 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.

{ const api = useApi() const tagId = 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 gaps = ref([]) // missing-page gaps: [{after_chapter_id, start, end}] - const partGaps = ref([]) // missing-Part gaps: [{after_chapter_id, start, end}] + const pages = ref([]) // flat, ordered: [{image_id, page_number, stated_page, thumbnail_url, image_url, source_post}] + const dividers = ref([]) // [{id, anchor_image_id, page_number, title, stated_part}] + const partGaps = ref([]) // [{after_divider_id, start, end}] const pageCount = ref(0) - const targetChapterId = ref(null) // which chapter the picker adds into const picker = ref([]) // gallery scroll results const pickerCursor = ref(null) const pickerSelection = ref([]) // image ids @@ -32,15 +33,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { try { const body = await api.get(`/api/series/${id}/pages`) series.value = body.series - chapters.value = body.chapters || [] - gaps.value = body.gaps || [] + pages.value = body.pages || [] + dividers.value = body.dividers || [] partGaps.value = body.part_gaps || [] - pageCount.value = (body.pages || []).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 - } + pageCount.value = pages.value.length } finally { loading.value = false } @@ -50,79 +46,66 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { if (tagId.value != null) await load(tagId.value) } - function gapAfter(chapterId) { - return gaps.value.find(g => g.after_chapter_id === chapterId) || null + // The divider anchored at a given page (shown as a chapter bar before it). + function dividerAt(imageId) { + return dividers.value.find(d => d.anchor_image_id === imageId) || null } - function partGapAfter(chapterId) { - return partGaps.value.find(g => g.after_chapter_id === chapterId) || null + function partGapAfter(dividerId) { + return partGaps.value.find(g => g.after_divider_id === dividerId) || null } - // ---- chapters ---- - async function createChapter({ title = null, isPlaceholder = false } = {}) { - await api.post(`/api/series/${tagId.value}/chapters`, { - 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`, { + // ---- pages (series-wide) ---- + async function reorder(orderedImageIds) { + await api.post(`/api/series/${tagId.value}/reorder`, { body: { image_ids: orderedImageIds } }) 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) { if (reset) { picker.value = []; pickerCursor.value = null } const params = { limit: 50 } @@ -140,35 +123,20 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { async function addSelected() { if (pickerSelection.value.length === 0) return - if (targetChapterId.value == null) await createChapter() await api.post(`/api/series/${tagId.value}/pages`, { - body: { image_ids: pickerSelection.value, chapter_id: targetChapterId.value } + body: { image_ids: pickerSelection.value } }) pickerSelection.value = [] await refresh() - toast({ text: 'Added to chapter', 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() + toast({ text: 'Added to series', type: 'success' }) } return { - tagId, series, chapters, gaps, partGaps, pageCount, targetChapterId, + tagId, series, pages, dividers, partGaps, pageCount, picker, pickerCursor, pickerSelection, loading, - load, refresh, gapAfter, partGapAfter, - createChapter, renameChapter, setChapterPart, setChapterStated, - reorderChapters, moveChapter, deleteChapter, mergeChapter, reorderPages, - loadPicker, togglePick, addSelected, remove, setCover + load, refresh, dividerAt, partGapAfter, + reorder, remove, setCover, + createDivider, renameDivider, setDividerPart, deleteDivider, + loadPicker, togglePick, addSelected } }) diff --git a/frontend/src/stores/seriesReader.js b/frontend/src/stores/seriesReader.js index 86f2e98..6193c96 100644 --- a/frontend/src/stores/seriesReader.js +++ b/frontend/src/stores/seriesReader.js @@ -42,27 +42,23 @@ export const useSeriesReaderStore = defineStore('seriesReader', () => { await run(async () => { const body = await api.get(`/api/series/${tagId}/pages`) series.value = body.series - // page_number is now WITHIN a chapter, so it can't anchor scroll/jump - // (two chapters both have a page 1). Decorate each page with a global - // `seq` (reading-order position) for anchors, plus chapter-divider info - // so the reader can mark where each chapter begins. - const chapters = body.chapters || [] - const labelById = {} - for (const c of chapters) { - labelById[c.id] = c.title || `Chapter ${c.chapter_number}` + // FC-6.x: page_number is already the series-global reading order, so it + // anchors scroll/jump directly (seq = page_number). Chapters are cosmetic + // dividers anchored to a page; mark the page each divider begins on so the + // reader can render a chapter heading there. + const dividers = body.dividers || [] + const anchorIds = new Set(dividers.map(d => d.anchor_image_id)) + const labelByAnchor = {} + 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, i) => { - const isChapterStart = - chapters.length > 1 && p.chapter_id !== prevChapter - prevChapter = p.chapter_id - return { - ...p, - seq: i + 1, - isChapterStart, - chapterLabel: labelById[p.chapter_id] || null - } - }) + pages.value = (body.pages || []).map((p) => ({ + ...p, + seq: p.page_number, + isChapterStart: anchorIds.has(p.image_id), + chapterLabel: labelByAnchor[p.image_id] || 'Chapter' + })) }) } diff --git a/frontend/src/views/SeriesManageView.vue b/frontend/src/views/SeriesManageView.vue index ad9d4e2..3aa8c90 100644 --- a/frontend/src/views/SeriesManageView.vue +++ b/frontend/src/views/SeriesManageView.vue @@ -9,9 +9,13 @@ @click="renameOpen = true" /> - {{ store.chapters.length }} part(s) · {{ store.pageCount }} page(s) + {{ store.dividers.length }} chapter(s) · {{ store.pageCount }} page(s) + Add pages

- Each part is one installment — drag pages to set their order, and set the - Part # to mark where it falls in the story. Use - Add pages to pull images from the gallery into the part - you're working on. + One continuous series. Drag any page to set its order — + the numbers are the page's place in the whole series. Use a page's + ⋮ → Start a chapter here to drop a labeled divider before + it. New pages from Add pages append to the end, ready to + be dragged into place.

- -
-