feat(series): flat sequence + cosmetic dividers + pending staging (#789) #97
@@ -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
@@ -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) --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user