Merge pull request 'feat(series): flat sequence + cosmetic dividers + pending staging (#789)' (#97) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m13s
Build images / build-ml (push) Successful in 2m49s
CI / integration (push) Successful in 3m9s

This commit was merged in pull request #97.
This commit is contained in:
2026-06-11 22:07:57 -04:00
20 changed files with 1410 additions and 1158 deletions
@@ -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",
)
@@ -0,0 +1,45 @@
"""series_page pending staging: status + nullable page_number (#789 Phase 2)
Pages added from a post no longer append straight into the run — they land
'pending' with a NULL page_number, staged grouped by their source post so the
operator can drop junk (text-free alts, bumpers) and place the keepers into the
sequence. A page only gets a series-global page_number once it's 'placed'.
Revision ID: 0048
Revises: 0047
Create Date: 2026-06-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0048"
down_revision: Union[str, None] = "0047"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"series_page",
sa.Column(
"status", sa.String(length=16), nullable=False,
server_default="placed",
),
)
op.alter_column(
"series_page", "page_number",
existing_type=sa.Integer(), nullable=True,
)
def downgrade() -> None:
# Lossy: pending pages are unsorted staging rows with no order — drop them.
op.execute("DELETE FROM series_page WHERE status = 'pending'")
op.alter_column(
"series_page", "page_number",
existing_type=sa.Integer(), nullable=False,
)
op.drop_column("series_page", "status")
+49 -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,13 +576,35 @@ 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()
return jsonify(out) return jsonify(out)
@tags_bp.route("/series/<int:tag_id>/pending/place", methods=["POST"])
async def series_place_pending(tag_id: int):
"""Move staged (pending) pages into the placed run — before `before_image_id`
(a placed page) or appended when omitted (#789 P2)."""
body = await request.get_json()
ids, err = _parse_bulk_ids(body, max_ids=500)
if err:
return err
before, berr = _opt_int(body, "before_image_id")
if berr:
return berr
async with get_session() as session:
try:
n = await SeriesService(session).place_pending(
tag_id, ids, before_image_id=before
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"placed_count": n})
# ---- suggestion queue (FC-6.3) -------------------------------------------- # ---- suggestion queue (FC-6.3) --------------------------------------------
+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()
) )
+17 -14
View File
@@ -1,17 +1,20 @@
"""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
from sqlalchemy import DateTime, ForeignKey, Integer, func from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@@ -24,17 +27,17 @@ 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,
unique=True, unique=True,
) )
page_number: Mapped[int] = mapped_column(Integer, nullable=False) # 'placed' = in the series-global run (page_number set); 'pending' = staged
# from a post awaiting the operator's sort (page_number NULL). (#789 P2)
status: Mapped[str] = mapped_column(
String(16), nullable=False, server_default="placed"
)
page_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
stated_page: Mapped[int | None] = mapped_column(Integer, nullable=True) stated_page: 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()
+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"
File diff suppressed because it is too large Load Diff
+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"
@@ -122,7 +123,9 @@ async function onAddExisting() {
body: { post_id: props.post.id } body: { post_id: props.post.id }
}) })
pickerOpen.value = false pickerOpen.value = false
toast({ text: 'Added to series', type: 'success' }) toast({ text: 'Staged — sort the new pages into the series', type: 'success' })
// Take the operator to the series so they can drop junk + place the pages.
router.push({ name: 'series-manage', params: { tagId: picked.value } })
} catch (e) { } catch (e) {
toast({ text: `Could not add to series: ${e.message}`, type: 'error' }) toast({ text: `Could not add to series: ${e.message}`, type: 'error' })
} finally { } finally {
+77 -98
View File
@@ -11,16 +11,18 @@ 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 pending = ref([]) // staged-from-post: [{post:{id,title}|null, pages:[{image_id, stated_page, thumbnail_url, image_url}]}]
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 +34,11 @@ 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 || []
pending.value = body.pending || []
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 +48,75 @@ 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' })
}
// ---- pending staging (add-from-post) ----
async function placePending(imageIds, beforeImageId = null) {
await api.post(`/api/series/${tagId.value}/pending/place`, {
body: { image_ids: imageIds, before_image_id: beforeImageId }
})
await refresh()
toast({ text: 'Pages placed into the series', 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 +134,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, pending, 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, placePending,
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
}
})
}) })
} }
+251 -265
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,148 @@
</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 --> <!-- Pending tray: pages staged from a post, grouped by source post, for the
<div class="fc-parts"> operator to drop junk and place into the run. -->
<template v-for="(ch, ci) in store.chapters" :key="ch.id"> <div v-if="store.pending.length" class="fc-pending">
<section class="fc-part"> <div class="fc-pending__head">
<header class="fc-part__head"> <v-icon size="small">mdi-tray-arrow-down</v-icon>
<label class="fc-part__partfield" :title="'Part number for this installment'"> <span>Pending sort into the series</span>
<span class="fc-part__partlabel">Part</span> </div>
<input <p class="fc-pending__hint">
class="fc-part__partnum" type="number" min="1" New pages from a post land here. Drop the ones that don't belong
:value="partDraft[ch.id] ?? ''" (text-free alternates, bumpers), then place the rest into the run.
:placeholder="String(ch.chapter_number)" </p>
@input="partDraft[ch.id] = $event.target.value" <section v-for="(grp, gi) in store.pending" :key="gi" class="fc-pgroup">
@keydown.enter.prevent="commitPart(ch)" <header class="fc-pgroup__head">
@blur="commitPart(ch)" <span class="fc-pgroup__title" :title="grp.post?.title">
> <v-icon size="x-small">mdi-link-variant</v-icon>
</label> {{ grp.post?.title || 'Unknown post' }}
</span>
<v-text-field <span class="fc-pgroup__count">{{ grp.pages.length }} pending</span>
v-model="titleDraft[ch.id]" <v-spacer />
:placeholder="`Untitled — Part ${ch.stated_part ?? ch.chapter_number}`" <v-btn
density="compact" variant="plain" hide-details size="small" variant="tonal" color="accent"
class="fc-part__title" prepend-icon="mdi-playlist-plus"
@keydown.enter="commitTitle(ch)" @click="store.placePending(grp.pages.map(p => p.image_id))"
@blur="commitTitle(ch)" >Place all into series</v-btn>
/> </header>
<div class="fc-pgroup__pages">
<span <div v-for="p in grp.pages" :key="p.image_id" class="fc-ppage">
v-if="ch.source_post?.title" class="fc-part__src" <img :src="p.thumbnail_url" alt="" loading="lazy" />
:title="ch.source_post.title" <div class="fc-ppage__actions">
> <v-btn
<v-icon size="x-small">mdi-link-variant</v-icon> size="x-small" variant="flat" icon="mdi-playlist-plus"
{{ ch.source_post.title }} title="Place this page into the series"
</span> @click="store.placePending([p.image_id])"
<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-btn
v-if="!ch.is_placeholder"
size="small" variant="tonal" color="accent"
prepend-icon="mdi-image-plus"
@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 <v-btn
prepend-icon="mdi-chevron-down" title="Move down" size="x-small" variant="flat" icon="mdi-close"
:disabled="ci === store.chapters.length - 1" title="Drop — doesn't belong in the series"
@click="store.moveChapter(ch.id, 1)" @click="store.remove(p.image_id)"
/> />
<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 v-if="ch.is_placeholder" class="fc-part__reserved">
Reserved slot — a part you don't have yet.
</div>
<div v-else-if="ch.pages.length === 0" class="fc-part__empty">
No pages yet.
<v-btn
size="small" variant="text" color="accent"
prepend-icon="mdi-image-plus" @click="openPicker(ch.id)"
>Add pages</v-btn>
</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>
</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>
<div v-if="store.gapAfter(ch.id)" class="fc-gap"> </section>
<v-icon size="x-small">mdi-alert-outline</v-icon> </div>
Gap: printed pages {{ store.gapAfter(ch.id).start }}{{ store.gapAfter(ch.id).end }} missing
<!-- One continuous page run; chapter dividers render inline before their
anchor page. -->
<div v-if="store.pages.length" class="fc-run">
<template v-for="(p, pi) in store.pages" :key="p.image_id">
<!-- chapter divider bar, if one is anchored at this page -->
<template v-if="store.dividerAt(p.image_id)">
<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
class="fc-divider__partnum" type="number" min="1"
:value="partDraft[store.dividerAt(p.image_id).id] ?? ''"
placeholder=""
@input="partDraft[store.dividerAt(p.image_id).id] = $event.target.value"
@keydown.enter.prevent="commitPart(store.dividerAt(p.image_id))"
@blur="commitPart(store.dividerAt(p.image_id))"
>
</label>
<v-text-field
:model-value="titleDraft[store.dividerAt(p.image_id).id]"
placeholder="Untitled chapter"
density="compact" variant="plain" hide-details
class="fc-divider__title"
@update:model-value="titleDraft[store.dividerAt(p.image_id).id] = $event"
@keydown.enter="commitTitle(store.dividerAt(p.image_id))"
@blur="commitTitle(store.dividerAt(p.image_id))"
/>
<v-spacer />
<v-btn
size="x-small" variant="text" icon="mdi-close"
title="Remove this chapter divider"
@click="store.deleteDivider(store.dividerAt(p.image_id).id)"
/>
</div>
<div
v-if="store.partGapAfter(store.dividerAt(p.image_id).id)"
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>
</template>
<!-- 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
size="x-small" variant="flat" icon="mdi-image-frame"
title="Make series cover" @click="store.setCover(p.image_id)"
/>
<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>
</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 +180,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 +217,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 +239,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 +300,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 +357,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 +373,64 @@ 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;
}
/* Pending tray */
.fc-pending {
max-width: 1100px; margin-bottom: 18px;
border: 1px solid rgb(var(--v-theme-accent), 0.4);
border-radius: 10px; padding: 10px 14px;
background: rgb(var(--v-theme-accent), 0.06);
}
.fc-pending__head {
display: flex; align-items: center; gap: 8px;
font-family: 'Fraunces', Georgia, serif; font-size: 16px;
color: rgb(var(--v-theme-accent));
}
.fc-pending__hint {
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
margin: 4px 0 10px;
}
.fc-pgroup { margin-bottom: 12px; }
.fc-pgroup__head {
display: flex; align-items: center; gap: 10px; margin-bottom: 6px;
}
.fc-pgroup__title {
display: inline-flex; align-items: center; gap: 4px; max-width: 360px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 13px; font-weight: 600;
}
.fc-pgroup__count {
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-pgroup__pages {
display: grid; gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
.fc-ppage {
position: relative; border-radius: 6px; overflow: hidden;
background: rgb(var(--v-theme-surface-light));
border: 1px dashed rgb(var(--v-theme-accent), 0.5);
}
.fc-ppage img {
width: 100%; aspect-ratio: 3 / 4; object-fit: contain; display: block;
background: rgb(var(--v-theme-background));
}
.fc-ppage__actions {
position: absolute; top: 4px; right: 4px; display: flex; gap: 2px;
opacity: 0; transition: opacity 0.12s;
}
.fc-ppage:hover .fc-ppage__actions,
.fc-ppage:focus-within .fc-ppage__actions { opacity: 1; }
.fc-ppage__actions :deep(.v-btn) {
background: rgba(0, 0, 0, 0.55); color: #fff;
} }
/* 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(
+37 -63
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,34 @@ 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_stages_pending(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 PLACED 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 is STAGED as pending — not in the placed run yet.
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["staged"] == 3
data = await svc.list_pages(sid) data = await svc.list_pages(sid)
chapters = data["chapters"] # placed run unchanged (just the seed); pending grouped under the post.
assert chapters[0]["stated_page_start"] == 1 # new one is first assert [p["image_id"] for p in data["pages"]] == seed
assert chapters[1]["stated_page_start"] == 9 assert len(data["pending"]) == 1
# gap 5-8 flagged between them grp = data["pending"][0]
assert data["gaps"][0]["start"] == 5 assert grp["post"]["id"] == post.id
assert data["gaps"][0]["end"] == 8 assert grp["post"]["title"] == "Story pages 9-11"
assert [pg["image_id"] for pg in grp["pages"]] == imgs
assert [pg["stated_page"] for pg in grp["pages"]] == [9, 10, 11]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -104,61 +107,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"
+5 -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,10 @@ 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 are STAGED pending (3 from a placed + 3 from b pending),
# awaiting the operator's sort.
assert len(data["pages"]) == 3
assert sum(len(g["pages"]) for g in data["pending"]) == 3
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()
+127
View File
@@ -0,0 +1,127 @@
"""FC-6.x Phase 2 — pending staging for add-from-post."""
import pytest
from sqlalchemy import select
from backend.app.models import Artist, ImageRecord, Post, TagKind
from backend.app.models.series_page import SeriesPage
from backend.app.services.series_service import SeriesService
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
_SEQ = 0
async def _artist(db, name):
a = Artist(name=name, slug=name.lower().replace(" ", "-"))
db.add(a)
await db.flush()
return a
async def _post(db, artist, title, ext):
p = Post(artist_id=artist.id, external_post_id=ext, post_title=title)
db.add(p)
await db.flush()
return p
async def _post_images(db, post, artist, n):
global _SEQ
ids = []
for _ in range(n):
_SEQ += 1
rec = ImageRecord(
path=f"/tmp/fc_pend_{_SEQ}.png", sha256=f"{_SEQ:064d}",
size_bytes=1, mime="image/png", origin="downloaded",
primary_post_id=post.id, artist_id=artist.id,
)
db.add(rec)
await db.flush()
ids.append(rec.id)
return ids
async def _placed_order(db, sid):
rows = (
await db.execute(
select(SeriesPage.image_id)
.where(
SeriesPage.series_tag_id == sid,
SeriesPage.status == "placed",
)
.order_by(SeriesPage.page_number)
)
).scalars().all()
return list(rows)
async def _setup(db, name, seed_n=2):
svc = SeriesService(db)
artist = await _artist(db, name + " Artist")
sid = (await TagService(db).find_or_create(name, TagKind.series)).id
seed = []
if seed_n:
seed_post = await _post(db, artist, name + " seed", name + "-s")
seed = await _post_images(db, seed_post, artist, seed_n)
await svc.add_images(sid, seed)
return svc, artist, sid, seed
@pytest.mark.asyncio
async def test_place_pending_appends_and_flips_status(db):
svc, artist, sid, seed = await _setup(db, "PA")
post = await _post(db, artist, "PA pages", "pa-p")
imgs = await _post_images(db, post, artist, 3)
await svc.add_post(sid, post.id)
placed = await svc.place_pending(sid, imgs)
assert placed == 3
assert await _placed_order(db, sid) == seed + imgs
# nothing left pending; page numbers compact 1..5
data = await svc.list_pages(sid)
assert data["pending"] == []
assert [p["page_number"] for p in data["pages"]] == [1, 2, 3, 4, 5]
@pytest.mark.asyncio
async def test_place_pending_before_a_placed_page(db):
svc, artist, sid, seed = await _setup(db, "PB")
a, b = seed
post = await _post(db, artist, "PB pages", "pb-p")
x, y = await _post_images(db, post, artist, 2)
await svc.add_post(sid, post.id)
await svc.place_pending(sid, [x, y], before_image_id=b)
assert await _placed_order(db, sid) == [a, x, y, b]
@pytest.mark.asyncio
async def test_place_pending_ignores_already_placed(db):
svc, artist, sid, seed = await _setup(db, "PC", seed_n=1)
# the seed page is already placed — place_pending is a no-op for it.
assert await svc.place_pending(sid, [seed[0]]) == 0
assert await _placed_order(db, sid) == seed
@pytest.mark.asyncio
async def test_remove_drops_a_pending_junk_page(db):
svc, artist, sid, _ = await _setup(db, "PD", seed_n=0)
post = await _post(db, artist, "PD pages", "pd-p")
x, y, z = await _post_images(db, post, artist, 3)
await svc.add_post(sid, post.id)
# y is junk (a text-free alt) — drop it before placing.
await svc.remove_images(sid, [y])
data = await svc.list_pages(sid)
assert [pg["image_id"] for pg in data["pending"][0]["pages"]] == [x, z]
@pytest.mark.asyncio
async def test_place_pending_route_requires_ids(client):
r = await client.post(
"/api/tags", json={"name": "PReq", "kind": "series"}
)
sid = (await r.get_json())["id"]
resp = await client.post(f"/api/series/{sid}/pending/place", json={})
assert resp.status_code == 400
+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)
) )