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

This commit was merged in pull request #97.
This commit is contained in:
2026-06-11 22:07:57 -04:00
20 changed files with 1410 additions and 1158 deletions
@@ -0,0 +1,175 @@
"""series chapters become cosmetic dividers; pages become one series-global run
FC-6.x reframe (#789). A series is now ONE flat, series-global ordered run of
pages; chapters stop owning pages and become labeled dividers anchored to the
page that begins them.
Migration (order matters — series_page.chapter_id cascades, so it must be
dropped BEFORE any chapter row is deleted, or pages would cascade away):
a. Renumber series_page.page_number to a series-global 1..N (ordered by the
OLD (chapter_number, page_number)).
b. Add series_chapter.anchor_page_id and populate it with each chapter's first
page (lowest new page_number).
c. Drop series_page.chapter_id (severs the cascade link).
d. Prune chapters that shouldn't become dividers: empty/placeholder ones (no
anchor) and the redundant unlabeled chapter that would sit at page 1.
e. Reshape series_chapter into the divider: drop chapter_number,
is_placeholder, stated_page_start/end; make anchor_page_id NOT NULL +
UNIQUE + FK→series_page ON DELETE CASCADE.
Revision ID: 0047
Revises: 0046
Create Date: 2026-06-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0047"
down_revision: Union[str, None] = "0046"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# a. series-global page numbering, preserving the old reading order.
op.execute(
"""
WITH ordered AS (
SELECT sp.id,
ROW_NUMBER() OVER (
PARTITION BY sp.series_tag_id
ORDER BY sc.chapter_number, sp.page_number, sp.id
) AS rn
FROM series_page sp
JOIN series_chapter sc ON sc.id = sp.chapter_id
)
UPDATE series_page sp
SET page_number = ordered.rn
FROM ordered
WHERE sp.id = ordered.id
"""
)
# b. anchor each existing chapter at its first page (lowest new page_number).
op.add_column(
"series_chapter",
sa.Column("anchor_page_id", sa.Integer(), nullable=True),
)
op.execute(
"""
WITH firsts AS (
SELECT DISTINCT ON (sp.chapter_id)
sp.chapter_id, sp.id AS page_id
FROM series_page sp
ORDER BY sp.chapter_id, sp.page_number, sp.id
)
UPDATE series_chapter sc
SET anchor_page_id = firsts.page_id
FROM firsts
WHERE firsts.chapter_id = sc.id
"""
)
# c. sever the ownership link (drops the FK + index with the column) BEFORE
# pruning chapters, so deleting a chapter can't cascade-delete its pages.
op.drop_column("series_page", "chapter_id")
# d. prune chapters that don't become dividers: placeholders / empty ones
# (no anchor), and the unlabeled chapter that would land redundantly at
# page 1 (the series just starts — no divider needed there).
op.execute(
"""
DELETE FROM series_chapter sc
USING (
SELECT sc2.id
FROM series_chapter sc2
LEFT JOIN series_page sp ON sp.id = sc2.anchor_page_id
WHERE sc2.anchor_page_id IS NULL
OR (sp.page_number = 1
AND sc2.title IS NULL
AND sc2.stated_part IS NULL)
) gone
WHERE sc.id = gone.id
"""
)
# e. reshape into the divider model.
op.drop_column("series_chapter", "chapter_number")
op.drop_column("series_chapter", "is_placeholder")
op.drop_column("series_chapter", "stated_page_start")
op.drop_column("series_chapter", "stated_page_end")
op.alter_column("series_chapter", "anchor_page_id", nullable=False)
op.create_unique_constraint(
"uq_series_chapter_anchor_page", "series_chapter", ["anchor_page_id"]
)
op.create_foreign_key(
"fk_series_chapter_anchor_page",
"series_chapter",
"series_page",
["anchor_page_id"],
["id"],
ondelete="CASCADE",
)
def downgrade() -> None:
# Lossy: dividers can't be reconstructed as owning chapters. Collapse back to
# exactly one chapter per series that owns all its pages in order.
op.add_column(
"series_page", sa.Column("chapter_id", sa.Integer(), nullable=True)
)
op.drop_constraint(
"fk_series_chapter_anchor_page", "series_chapter", type_="foreignkey"
)
op.drop_constraint(
"uq_series_chapter_anchor_page", "series_chapter", type_="unique"
)
op.drop_column("series_chapter", "anchor_page_id")
op.add_column(
"series_chapter",
sa.Column(
"chapter_number", sa.Integer(), nullable=False, server_default="1"
),
)
op.add_column(
"series_chapter",
sa.Column(
"is_placeholder", sa.Boolean(), nullable=False,
server_default="false",
),
)
op.add_column(
"series_chapter",
sa.Column("stated_page_start", sa.Integer(), nullable=True),
)
op.add_column(
"series_chapter",
sa.Column("stated_page_end", sa.Integer(), nullable=True),
)
op.execute("DELETE FROM series_chapter")
op.execute(
"""
INSERT INTO series_chapter (series_tag_id, chapter_number)
SELECT DISTINCT series_tag_id, 1 FROM series_page
"""
)
op.execute(
"""
UPDATE series_page sp
SET chapter_id = sc.id
FROM series_chapter sc
WHERE sc.series_tag_id = sp.series_tag_id
"""
)
op.alter_column("series_page", "chapter_id", nullable=False)
op.create_foreign_key(
"fk_series_page_chapter",
"series_page",
"series_chapter",
["chapter_id"],
["id"],
ondelete="CASCADE",
)
@@ -0,0 +1,45 @@
"""series_page pending staging: status + nullable page_number (#789 Phase 2)
Pages added from a post no longer append straight into the run — they land
'pending' with a NULL page_number, staged grouped by their source post so the
operator can drop junk (text-free alts, bumpers) and place the keepers into the
sequence. A page only gets a series-global page_number once it's 'placed'.
Revision ID: 0048
Revises: 0047
Create Date: 2026-06-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0048"
down_revision: Union[str, None] = "0047"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"series_page",
sa.Column(
"status", sa.String(length=16), nullable=False,
server_default="placed",
),
)
op.alter_column(
"series_page", "page_number",
existing_type=sa.Integer(), nullable=True,
)
def downgrade() -> None:
# Lossy: pending pages are unsorted staging rows with no order — drop them.
op.execute("DELETE FROM series_page WHERE status = 'pending'")
op.alter_column(
"series_page", "page_number",
existing_type=sa.Integer(), nullable=False,
)
op.drop_column("series_page", "status")
+49 -86
View File
@@ -391,14 +391,9 @@ async def series_add(tag_id: int):
ids, err = _parse_bulk_ids(body, max_ids=500)
if err:
return err
chapter_id, cerr = _opt_int(body, "chapter_id")
if cerr:
return cerr
async with get_session() as session:
try:
n = await SeriesService(session).add_images(
tag_id, ids, chapter_id=chapter_id
)
n = await SeriesService(session).add_images(tag_id, ids)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
@@ -453,30 +448,30 @@ async def series_cover(tag_id: int):
return jsonify({"ok": True})
# ---- chapters (FC-6.1) ----------------------------------------------------
# ---- chapter dividers (FC-6.x) -------------------------------------------
# A chapter is a cosmetic divider anchored to the page that begins it; it owns
# no pages. Page ordering is series-wide (the /reorder endpoint above), so there
# is no per-chapter reorder/merge/chapter-reorder — those are gone.
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
async def series_chapter_create(tag_id: int):
body = await request.get_json() or {}
anchor, aerr = _opt_int(body, "anchor_image_id")
if aerr:
return aerr
if anchor is None:
return jsonify({"error": "anchor_image_id required"}), 400
title = body.get("title")
if title is not None and not isinstance(title, str):
return jsonify({"error": "title must be a string"}), 400
is_placeholder = bool(body.get("is_placeholder", False))
start, serr = _opt_int(body, "stated_page_start")
if serr:
return serr
end, eerr = _opt_int(body, "stated_page_end")
if eerr:
return eerr
part, perr = _opt_int(body, "stated_part")
if perr:
return perr
async with get_session() as session:
try:
ch = await SeriesService(session).create_chapter(
tag_id,
title=title,
is_placeholder=is_placeholder,
stated_page_start=start,
stated_page_end=end,
ch = await SeriesService(session).create_divider(
tag_id, anchor, title=title, stated_part=part,
)
except SeriesError as exc:
return _series_err(exc)
@@ -484,21 +479,6 @@ async def series_chapter_create(tag_id: int):
return jsonify(ch)
@tags_bp.route("/series/<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(
"/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:
return perr
kwargs.update(set_part=True, stated_part=part)
if "stated_page_start" in body:
start, serr = _opt_int(body, "stated_page_start")
if serr:
return serr
kwargs.update(set_start=True, stated_page_start=start)
if "stated_page_end" in body:
end, eerr = _opt_int(body, "stated_page_end")
if eerr:
return eerr
kwargs.update(set_end=True, stated_page_end=end)
if "anchor_image_id" in body:
anchor, aerr = _opt_int(body, "anchor_image_id")
if aerr:
return aerr
if anchor is None:
return jsonify(
{"error": "anchor_image_id must be an integer"}
), 400
kwargs.update(set_anchor=True, anchor_image_id=anchor)
async with get_session() as session:
try:
await SeriesService(session).update_chapter(
await SeriesService(session).update_divider(
tag_id, chapter_id, **kwargs
)
except SeriesError as exc:
@@ -541,45 +520,7 @@ async def series_chapter_update(tag_id: int, chapter_id: int):
async def series_chapter_delete(tag_id: int, chapter_id: int):
async with get_session() as session:
try:
await SeriesService(session).delete_chapter(tag_id, chapter_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route(
"/series/<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)
await SeriesService(session).delete_divider(tag_id, chapter_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
@@ -635,13 +576,35 @@ async def series_add_post(tag_id: int):
return jsonify({"error": "post_id required"}), 400
async with get_session() as session:
try:
out = await SeriesService(session).add_post_as_chapter(tag_id, post_id)
out = await SeriesService(session).add_post(tag_id, post_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
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) --------------------------------------------
+18 -25
View File
@@ -1,28 +1,22 @@
"""SeriesChapter — an ordered chapter/part within a series.
"""SeriesChapter — a cosmetic chapter DIVIDER within a series (FC-6.x reframe).
A series IS a Tag(kind='series'); a chapter groups ordered SeriesPages under it.
Reading order is (chapter.chapter_number, series_page.page_number): chapter_number
sets the order of chapters, page_number orders pages within a chapter.
A series is ONE flat, series-global ordered run of SeriesPages. A chapter is NOT
a container — it owns no pages. It is a labeled divider anchored to the page that
BEGINS the chapter (anchor_page_id → series_page): "a new chapter starts here."
A page's chapter is derived at read time as the nearest preceding divider.
chapter_number is an ordering key only (not unique) — reorder rewrites 1..N
wholesale, mirroring series_page.page_number, so a reorder can't transiently
collide on a unique index.
Dividers never affect page ordering or the series-global page numbers; they stay
pinned to their anchor page across reorders. anchor_page_id is UNIQUE — at most
one chapter begins at a given page — and FK-cascades, so removing the anchor page
from the series drops the divider (the chapter merges into the preceding run).
A chapter may be a placeholder (is_placeholder=True) — a reserved empty slot for
a section the operator doesn't have yet; it holds no pages and shows as a gap in
the reader. stated_page_start/end carry the page range parsed from the source
post (FC-6.2), used to flag missing-page gaps; both are nullable when unknown.
stated_part is the operator-facing "Part N" label (FC-6.4), separate from the
positional chapter_number: chapter_number is auto-managed ordering (rewritten
1..N on reorder/delete), while stated_part is the real installment number the
operator types — e.g. a series authored from a post that is Part 2 of a story.
Nullable when unset (the UI then falls back to showing chapter_number).
title is the optional chapter name; stated_part is the optional operator-facing
"Part N" label (shown instead of a derived ordinal when set).
"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text, func
from sqlalchemy import DateTime, ForeignKey, Integer, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -35,14 +29,13 @@ class SeriesChapter(Base):
series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
)
chapter_number: Mapped[int] = mapped_column(Integer, nullable=False)
stated_part: Mapped[int | None] = mapped_column(Integer, nullable=True)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
is_placeholder: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false"
anchor_page_id: Mapped[int] = mapped_column(
ForeignKey("series_page.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
stated_page_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
stated_page_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
stated_part: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+17 -14
View File
@@ -1,17 +1,20 @@
"""SeriesPage — ordered image membership for a series-kind Tag.
A series IS a Tag with kind='series'; series_page gives it ordered pages,
grouped into chapters (FC-6). An image belongs to at most one series
(UNIQUE image_id) ⇒ at most one chapter. Reading order is
(chapter.chapter_number, series_page.page_number): page_number orders pages
WITHIN a chapter and is an ordering key only (not unique) — reorder rewrites
1..N wholesale. stated_page carries the page number parsed from the source
post (FC-6.2), nullable when unknown.
A series IS a Tag with kind='series'; series_page gives it a SINGLE flat,
series-global ordered run of pages (FC-6.x divider reframe). An image belongs to
at most one series (UNIQUE image_id). Reading order is `page_number` alone — a
series-wide ordering key (not unique), rewritten 1..N wholesale on reorder so a
reorder can't transiently collide on an index.
Chapters are cosmetic DIVIDERS anchored to a page (see SeriesChapter); they do
NOT own pages, so there is no chapter_id here — a page's chapter is derived at
read time as the nearest preceding divider. stated_page carries the printed page
number parsed from the source post, nullable when unknown.
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, func
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -24,17 +27,17 @@ class SeriesPage(Base):
series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
)
chapter_id: Mapped[int] = mapped_column(
ForeignKey("series_chapter.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
image_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"),
nullable=False,
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)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
+5 -4
View File
@@ -18,7 +18,6 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ImageRecord, Post, Tag, TagKind
from ..models.series_chapter import SeriesChapter
from ..models.series_page import SeriesPage
from ..models.series_suggestion import SeriesSuggestion
from ..models.tag import image_tag
@@ -144,9 +143,11 @@ class SeriesMatchService:
return [t for t in rows if t]
async def _series_max_stated_end(self, series_tag_id: int) -> int | None:
# Highest printed page across the series' pages (FC-6.x: stated pages
# live on series_page now; chapters are cosmetic dividers).
return await self.session.scalar(
select(func.max(SeriesChapter.stated_page_end)).where(
SeriesChapter.series_tag_id == series_tag_id
select(func.max(SeriesPage.stated_page)).where(
SeriesPage.series_tag_id == series_tag_id
)
)
@@ -288,7 +289,7 @@ class SeriesMatchService:
raise SeriesError(f"suggestion {suggestion_id} not found")
if s.status != "pending":
raise SeriesError(f"suggestion {suggestion_id} is already {s.status}")
out = await SeriesService(self.session).add_post_as_chapter(
out = await SeriesService(self.session).add_post(
s.series_tag_id, s.post_id
)
s.status = "added"
File diff suppressed because it is too large Load Diff
+16 -7
View File
@@ -518,21 +518,30 @@ class TagService:
from ..models.series_chapter import SeriesChapter
from ..models.series_page import SeriesPage
# Move the chapters first so the pages' chapter_id FK stays valid: a
# chapter left pointing at src would cascade-delete (with its pages) when
# src is removed. chapter_number may now collide across the merged set —
# acceptable (it's an ordering key, not unique).
# Move src's dividers onto tgt. Their anchor_page_id stays valid — the
# anchored pages move too — and anchor is globally UNIQUE so no collision.
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.series_tag_id == src)
.values(series_tag_id=tgt)
)
# image_id is UNIQUE across series_page and src != tgt, so an
# image in src's series cannot already be in tgt's — no collision.
# Append src's pages after tgt's so the merged series stays ONE clean
# series-global run (image_id is UNIQUE, so the two page sets are
# disjoint). Offset src's page_number past tgt's current max.
tgt_max = (
await self.session.scalar(
select(
func.coalesce(func.max(SeriesPage.page_number), 0)
).where(SeriesPage.series_tag_id == tgt)
)
) or 0
await self.session.execute(
update(SeriesPage)
.where(SeriesPage.series_tag_id == src)
.values(series_tag_id=tgt)
.values(
series_tag_id=tgt,
page_number=SeriesPage.page_number + tgt_max,
)
)
async def _repoint_fandom_children(
@@ -28,7 +28,8 @@
<v-card-title>Add to series</v-card-title>
<v-card-text>
<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>
<v-autocomplete
v-model="picked"
@@ -122,7 +123,9 @@ async function onAddExisting() {
body: { post_id: props.post.id }
})
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) {
toast({ text: `Could not add to series: ${e.message}`, type: 'error' })
} finally {
+77 -98
View File
@@ -11,16 +11,18 @@ export function moveItem(arr, from, to) {
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', () => {
const api = useApi()
const tagId = ref(null)
const series = ref(null)
const chapters = ref([]) // [{id, chapter_number, stated_part, title, is_placeholder, stated_page_start/end, source_post, pages:[...]}]
const gaps = ref([]) // missing-page gaps: [{after_chapter_id, start, end}]
const partGaps = ref([]) // missing-Part gaps: [{after_chapter_id, start, end}]
const pages = ref([]) // flat, ordered: [{image_id, page_number, stated_page, thumbnail_url, image_url, source_post}]
const dividers = ref([]) // [{id, anchor_image_id, page_number, title, stated_part}]
const 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 targetChapterId = ref(null) // which chapter the picker adds into
const picker = ref([]) // gallery scroll results
const pickerCursor = ref(null)
const pickerSelection = ref([]) // image ids
@@ -32,15 +34,11 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
try {
const body = await api.get(`/api/series/${id}/pages`)
series.value = body.series
chapters.value = body.chapters || []
gaps.value = body.gaps || []
pages.value = body.pages || []
dividers.value = body.dividers || []
pending.value = body.pending || []
partGaps.value = body.part_gaps || []
pageCount.value = (body.pages || []).length
// Keep a valid add-target: the selected chapter, else the first one.
const ids = chapters.value.map(c => c.id)
if (!ids.includes(targetChapterId.value)) {
targetChapterId.value = ids.length ? ids[0] : null
}
pageCount.value = pages.value.length
} finally {
loading.value = false
}
@@ -50,79 +48,75 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
if (tagId.value != null) await load(tagId.value)
}
function gapAfter(chapterId) {
return gaps.value.find(g => g.after_chapter_id === chapterId) || null
// The divider anchored at a given page (shown as a chapter bar before it).
function dividerAt(imageId) {
return dividers.value.find(d => d.anchor_image_id === imageId) || null
}
function partGapAfter(chapterId) {
return partGaps.value.find(g => g.after_chapter_id === chapterId) || null
function partGapAfter(dividerId) {
return partGaps.value.find(g => g.after_divider_id === dividerId) || null
}
// ---- chapters ----
async function createChapter({ title = null, isPlaceholder = false } = {}) {
await api.post(`/api/series/${tagId.value}/chapters`, {
body: { title, is_placeholder: isPlaceholder }
})
await refresh()
}
async function renameChapter(chapterId, title) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { title }
})
await refresh()
}
async function setChapterPart(chapterId, part) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { stated_part: part }
})
await refresh()
}
async function setChapterStated(chapterId, start, end) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { stated_page_start: start, stated_page_end: end }
})
await refresh()
}
async function reorderChapters(orderedChapterIds) {
await api.post(`/api/series/${tagId.value}/chapters/reorder`, {
body: { chapter_ids: orderedChapterIds }
})
await refresh()
}
async function moveChapter(chapterId, dir) {
const ids = chapters.value.map(c => c.id)
const from = ids.indexOf(chapterId)
const to = from + dir
if (from === -1 || to < 0 || to >= ids.length) return
await reorderChapters(moveItem(ids, from, to))
}
async function deleteChapter(chapterId) {
await api.delete(`/api/series/${tagId.value}/chapters/${chapterId}`)
await refresh()
}
async function mergeChapter(sourceId, targetId) {
await api.post(`/api/series/${tagId.value}/chapters/${sourceId}/merge`, {
body: { target_chapter_id: targetId }
})
await refresh()
toast({ text: 'Chapters merged', type: 'success' })
}
async function reorderPages(chapterId, orderedImageIds) {
await api.post(`/api/series/${tagId.value}/chapters/${chapterId}/reorder`, {
// ---- pages (series-wide) ----
async function reorder(orderedImageIds) {
await api.post(`/api/series/${tagId.value}/reorder`, {
body: { image_ids: orderedImageIds }
})
await refresh()
}
// ---- picker / pages ----
async function remove(imageId) {
await api.post(`/api/series/${tagId.value}/pages/remove`, {
body: { image_ids: [imageId] }
})
await refresh()
}
async function setCover(imageId) {
await api.post(`/api/series/${tagId.value}/cover`, {
body: { image_id: imageId }
})
await refresh()
}
// ---- chapter dividers ----
async function createDivider(anchorImageId, { title = null, statedPart = null } = {}) {
await api.post(`/api/series/${tagId.value}/chapters`, {
body: { anchor_image_id: anchorImageId, title, stated_part: statedPart }
})
await refresh()
}
async function renameDivider(dividerId, title) {
await api.patch(`/api/series/${tagId.value}/chapters/${dividerId}`, {
body: { title }
})
await refresh()
}
async function setDividerPart(dividerId, part) {
await api.patch(`/api/series/${tagId.value}/chapters/${dividerId}`, {
body: { stated_part: part }
})
await refresh()
}
async function deleteDivider(dividerId) {
await api.delete(`/api/series/${tagId.value}/chapters/${dividerId}`)
await refresh()
toast({ text: 'Chapter divider removed', type: 'success' })
}
// ---- 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) {
if (reset) { picker.value = []; pickerCursor.value = null }
const params = { limit: 50 }
@@ -140,35 +134,20 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
async function addSelected() {
if (pickerSelection.value.length === 0) return
if (targetChapterId.value == null) await createChapter()
await api.post(`/api/series/${tagId.value}/pages`, {
body: { image_ids: pickerSelection.value, chapter_id: targetChapterId.value }
body: { image_ids: pickerSelection.value }
})
pickerSelection.value = []
await refresh()
toast({ text: 'Added to chapter', type: 'success' })
}
async function remove(imageId) {
await api.post(`/api/series/${tagId.value}/pages/remove`, {
body: { image_ids: [imageId] }
})
await refresh()
}
async function setCover(imageId) {
await api.post(`/api/series/${tagId.value}/cover`, {
body: { image_id: imageId }
})
await refresh()
toast({ text: 'Added to series', type: 'success' })
}
return {
tagId, series, chapters, gaps, partGaps, pageCount, targetChapterId,
tagId, series, pages, dividers, pending, partGaps, pageCount,
picker, pickerCursor, pickerSelection, loading,
load, refresh, gapAfter, partGapAfter,
createChapter, renameChapter, setChapterPart, setChapterStated,
reorderChapters, moveChapter, deleteChapter, mergeChapter, reorderPages,
loadPicker, togglePick, addSelected, remove, setCover
load, refresh, dividerAt, partGapAfter,
reorder, remove, setCover, placePending,
createDivider, renameDivider, setDividerPart, deleteDivider,
loadPicker, togglePick, addSelected
}
})
+16 -20
View File
@@ -42,27 +42,23 @@ export const useSeriesReaderStore = defineStore('seriesReader', () => {
await run(async () => {
const body = await api.get(`/api/series/${tagId}/pages`)
series.value = body.series
// page_number is now WITHIN a chapter, so it can't anchor scroll/jump
// (two chapters both have a page 1). Decorate each page with a global
// `seq` (reading-order position) for anchors, plus chapter-divider info
// so the reader can mark where each chapter begins.
const chapters = body.chapters || []
const labelById = {}
for (const c of chapters) {
labelById[c.id] = c.title || `Chapter ${c.chapter_number}`
// FC-6.x: page_number is already the series-global reading order, so it
// anchors scroll/jump directly (seq = page_number). Chapters are cosmetic
// dividers anchored to a page; mark the page each divider begins on so the
// reader can render a chapter heading there.
const dividers = body.dividers || []
const anchorIds = new Set(dividers.map(d => d.anchor_image_id))
const labelByAnchor = {}
for (const d of dividers) {
labelByAnchor[d.anchor_image_id] =
d.title || (d.stated_part != null ? `Part ${d.stated_part}` : 'Chapter')
}
let prevChapter = null
pages.value = (body.pages || []).map((p, i) => {
const isChapterStart =
chapters.length > 1 && p.chapter_id !== prevChapter
prevChapter = p.chapter_id
return {
...p,
seq: i + 1,
isChapterStart,
chapterLabel: labelById[p.chapter_id] || null
}
})
pages.value = (body.pages || []).map((p) => ({
...p,
seq: p.page_number,
isChapterStart: anchorIds.has(p.image_id),
chapterLabel: labelByAnchor[p.image_id] || 'Chapter'
}))
})
}
+251 -265
View File
@@ -9,9 +9,13 @@
@click="renameOpen = true"
/>
<span class="fc-series__count">
{{ store.chapters.length }} part(s) · {{ store.pageCount }} page(s)
{{ store.dividers.length }} chapter(s) · {{ store.pageCount }} page(s)
</span>
<v-spacer />
<v-btn
size="small" variant="tonal" prepend-icon="mdi-image-plus"
@click="pickerOpen = true"
>Add pages</v-btn>
<v-btn
v-if="store.pageCount > 0"
size="small" variant="tonal" color="accent"
@@ -21,161 +25,148 @@
</div>
<p class="fc-series__hint">
Each part is one installment drag pages to set their order, and set the
<strong>Part #</strong> to mark where it falls in the story. Use
<strong>Add pages</strong> to pull images from the gallery into the part
you're working on.
One continuous series. <strong>Drag any page</strong> to set its order
the numbers are the page's place in the whole series. Use a page's
<strong> Start a chapter here</strong> to drop a labeled divider before
it. New pages from <strong>Add pages</strong> append to the end, ready to
be dragged into place.
</p>
<!-- Parts, full width -->
<div class="fc-parts">
<template v-for="(ch, ci) in store.chapters" :key="ch.id">
<section class="fc-part">
<header class="fc-part__head">
<label class="fc-part__partfield" :title="'Part number for this installment'">
<span class="fc-part__partlabel">Part</span>
<input
class="fc-part__partnum" type="number" min="1"
:value="partDraft[ch.id] ?? ''"
:placeholder="String(ch.chapter_number)"
@input="partDraft[ch.id] = $event.target.value"
@keydown.enter.prevent="commitPart(ch)"
@blur="commitPart(ch)"
>
</label>
<v-text-field
v-model="titleDraft[ch.id]"
:placeholder="`Untitled — Part ${ch.stated_part ?? ch.chapter_number}`"
density="compact" variant="plain" hide-details
class="fc-part__title"
@keydown.enter="commitTitle(ch)"
@blur="commitTitle(ch)"
/>
<span
v-if="ch.source_post?.title" class="fc-part__src"
:title="ch.source_post.title"
>
<v-icon size="x-small">mdi-link-variant</v-icon>
{{ ch.source_post.title }}
</span>
<span v-if="ch.is_placeholder" class="fc-part__badge">placeholder</span>
<span v-else class="fc-part__pc">{{ ch.pages.length }} pg</span>
<v-spacer />
<v-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)"
<!-- Pending tray: pages staged from a post, grouped by source post, for the
operator to drop junk and place into the run. -->
<div v-if="store.pending.length" class="fc-pending">
<div class="fc-pending__head">
<v-icon size="small">mdi-tray-arrow-down</v-icon>
<span>Pending sort into the series</span>
</div>
<p class="fc-pending__hint">
New pages from a post land here. Drop the ones that don't belong
(text-free alternates, bumpers), then place the rest into the run.
</p>
<section v-for="(grp, gi) in store.pending" :key="gi" class="fc-pgroup">
<header class="fc-pgroup__head">
<span class="fc-pgroup__title" :title="grp.post?.title">
<v-icon size="x-small">mdi-link-variant</v-icon>
{{ grp.post?.title || 'Unknown post' }}
</span>
<span class="fc-pgroup__count">{{ grp.pages.length }} pending</span>
<v-spacer />
<v-btn
size="small" variant="tonal" color="accent"
prepend-icon="mdi-playlist-plus"
@click="store.placePending(grp.pages.map(p => p.image_id))"
>Place all into series</v-btn>
</header>
<div class="fc-pgroup__pages">
<div v-for="p in grp.pages" :key="p.image_id" class="fc-ppage">
<img :src="p.thumbnail_url" alt="" loading="lazy" />
<div class="fc-ppage__actions">
<v-btn
size="x-small" variant="flat" icon="mdi-playlist-plus"
title="Place this page into the series"
@click="store.placePending([p.image_id])"
/>
<v-list-item
prepend-icon="mdi-chevron-down" title="Move down"
:disabled="ci === store.chapters.length - 1"
@click="store.moveChapter(ch.id, 1)"
<v-btn
size="x-small" variant="flat" icon="mdi-close"
title="Drop — doesn't belong in the series"
@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>
</section>
<div v-if="store.partGapAfter(ch.id)" class="fc-gap">
<v-icon size="x-small">mdi-alert-outline</v-icon>
Missing
<template v-if="store.partGapAfter(ch.id).start === store.partGapAfter(ch.id).end">
Part {{ store.partGapAfter(ch.id).start }}
</template>
<template v-else>
Parts {{ store.partGapAfter(ch.id).start }}{{ store.partGapAfter(ch.id).end }}
</template>
</div>
<div v-if="store.gapAfter(ch.id)" class="fc-gap">
<v-icon size="x-small">mdi-alert-outline</v-icon>
Gap: printed pages {{ store.gapAfter(ch.id).start }}{{ store.gapAfter(ch.id).end }} missing
</section>
</div>
<!-- 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>
</template>
</div>
<div v-if="store.chapters.length === 0" class="fc-series__empty">
No parts yet add one, then add pages from the gallery.
</div>
<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 v-else class="fc-series__empty">
No pages yet — use <strong>Add pages</strong> to pull images from the
gallery into this series.
</div>
<!-- Picker slide-over -->
@@ -189,19 +180,13 @@
<v-btn size="x-small" variant="text" icon="mdi-close"
@click="pickerOpen = false" />
</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">
<span>{{ store.pickerSelection.length }} selected</span>
<v-btn
size="small" color="accent" variant="flat"
:disabled="store.pickerSelection.length === 0"
@click="store.addSelected()"
>Add to part</v-btn>
>Add to series</v-btn>
</div>
</div>
<div class="fc-picker__grid">
@@ -232,19 +217,18 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
import KebabMenu from '../components/common/KebabMenu.vue'
import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
const route = useRoute()
const store = useSeriesManageStore()
const sentinel = ref(null)
const drag = ref(null) // { chapterId, idx }
const titleDraft = reactive({}) // chapterId -> draft title
const partDraft = reactive({}) // chapterId -> draft stated_part (string)
const drag = ref(null) // index in the flat pages run being dragged
const titleDraft = reactive({}) // dividerId -> draft title
const partDraft = reactive({}) // dividerId -> draft stated_part (string)
const pickerOpen = ref(false)
const renameOpen = ref(false)
@@ -255,73 +239,47 @@ function onRenamed(updated) {
if (store.series && updated?.name) store.series.name = updated.name
}
// Keep local drafts in sync with loaded chapters. Edits commit on blur/Enter,
// which refreshes and resets the draft to the saved value.
watch(() => store.chapters, (chs) => {
// Keep local divider drafts in sync with loaded dividers; edits commit on
// blur/Enter, which refreshes and resets the draft to the saved value.
watch(() => store.dividers, (divs) => {
for (const k of Object.keys(titleDraft)) delete titleDraft[k]
for (const k of Object.keys(partDraft)) delete partDraft[k]
for (const c of chs) {
titleDraft[c.id] = c.title || ''
partDraft[c.id] = c.stated_part == null ? '' : String(c.stated_part)
for (const d of divs) {
titleDraft[d.id] = d.title || ''
partDraft[d.id] = d.stated_part == null ? '' : String(d.stated_part)
}
}, { immediate: true })
const chapterItems = computed(() =>
store.chapters.map(c => ({
id: c.id,
label: `Part ${c.stated_part ?? c.chapter_number}` +
(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 commitTitle(d) {
const v = (titleDraft[d.id] || '').trim()
if (v === (d.title || '')) return
store.renameDivider(d.id, v || null)
}
function commitPart(ch) {
const raw = (partDraft[ch.id] ?? '').trim()
function commitPart(d) {
const raw = (partDraft[d.id] ?? '').trim()
const next = raw === '' ? null : parseInt(raw, 10)
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
}
if (next === (ch.stated_part ?? null)) return
store.setChapterPart(ch.id, next)
if (next === (d.stated_part ?? null)) return
store.setDividerPart(d.id, next)
}
function onStated(ch, which, ev) {
const raw = ev.target.value
const n = raw === '' ? null : parseInt(raw, 10)
const start = which === 'start' ? n : ch.stated_page_start
const end = which === 'end' ? n : ch.stated_page_end
store.setChapterStated(ch.id, start, end)
function gapText(anchorImageId) {
const d = store.dividerAt(anchorImageId)
const g = d ? store.partGapAfter(d.id) : null
return { start: g?.start, end: g?.end, single: g && g.start === g.end }
}
function onPageDrop(chapter, toIdx) {
if (!drag.value || drag.value.chapterId !== chapter.id) { drag.value = null; return }
if (drag.value.idx === toIdx) { drag.value = null; return }
function onDrop(toIdx) {
if (drag.value === null || drag.value === toIdx) { drag.value = null; return }
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
store.reorderPages(chapter.id, 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
store.reorder(ordered)
}
useInfiniteScroll(sentinel, () => store.loadPicker())
@@ -342,76 +300,46 @@ onMounted(async () => {
}
.fc-series__hint {
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 */
.fc-parts { display: flex; flex-direction: column; gap: 14px; max-width: 1100px; }
.fc-part {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 10px; padding: 12px 14px;
background: rgb(var(--v-theme-surface));
/* One continuous page run with inline divider bars. */
.fc-run {
display: grid; gap: 10px; max-width: 1100px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.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;
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-radius: 8px; padding: 4px 8px;
border-radius: 8px; padding: 2px 8px;
}
.fc-part__partlabel {
font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em;
.fc-divider__partlabel {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-accent));
}
.fc-part__partnum {
width: 46px; text-align: center; font-size: 18px; font-weight: 600;
.fc-divider__partnum {
width: 42px; text-align: center; font-size: 16px; font-weight: 600;
font-variant-numeric: tabular-nums;
background: transparent; border: none; color: rgb(var(--v-theme-on-surface));
}
.fc-part__partnum:focus { outline: none; }
.fc-part__partnum::placeholder { color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.6; }
.fc-divider__partnum:focus { outline: none; }
.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; }
.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));
}
/* Big page tiles */
.fc-page {
position: relative; border-radius: 8px; overflow: hidden; cursor: grab;
background: rgb(var(--v-theme-surface-light));
@@ -429,6 +357,12 @@ onMounted(async () => {
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));
}
.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 {
position: absolute; top: 4px; right: 4px; z-index: 2;
display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s;
@@ -439,12 +373,64 @@ onMounted(async () => {
}
.fc-gap {
grid-column: 1 / -1;
display: flex; align-items: center; gap: 6px; padding: 2px 10px;
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 {
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 */
+34 -29
View File
@@ -13,17 +13,14 @@ function stubFetch(handler) {
})
}
// FC-6.x: a flat page run + cosmetic chapter dividers.
const SERIES_BODY = {
series: { id: 7, name: 'V' },
chapters: [
{
id: 1, chapter_number: 1, title: null, is_placeholder: false,
stated_page_start: null, stated_page_end: null,
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't' }]
}
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't', source_post: null }],
dividers: [
{ id: 1, anchor_image_id: 1, page_number: 1, title: null, stated_part: null }
],
gaps: [],
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't' }]
part_gaps: []
}
describe('seriesManage', () => {
@@ -35,17 +32,17 @@ describe('seriesManage', () => {
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()
stubFetch(() => ({ status: 200, body: SERIES_BODY }))
await s.load(7)
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.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()
s.tagId = 7
const calls = []
@@ -54,28 +51,29 @@ describe('seriesManage', () => {
if (url.includes('/reorder')) return { status: 200, body: { ok: true } }
return { status: 200, body: SERIES_BODY }
})
await s.reorderPages(3, [30, 10, 20])
const r = calls.find(c => c.url.includes('/chapters/3/reorder'))
expect(r.url).toContain('/api/series/7/chapters/3/reorder')
await s.reorder([30, 10, 20])
const r = calls.find(c => c.url.includes('/reorder'))
expect(r.url).toContain('/api/series/7/reorder')
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()
s.tagId = 7
s.chapters = [{ id: 1 }, { id: 2 }, { id: 3 }]
const calls = []
stubFetch((url, init) => {
calls.push({ url, body: init.body ? JSON.parse(init.body) : null })
if (url.includes('/chapters/reorder')) return { status: 200, body: { ok: true } }
calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : null })
if (init.method === 'POST' && url.endsWith('/chapters'))
return { status: 200, body: { id: 2, anchor_image_id: 5 } }
return { status: 200, body: SERIES_BODY }
})
await s.moveChapter(2, -1)
const r = calls.find(c => c.url.includes('/chapters/reorder'))
expect(r.body).toEqual({ chapter_ids: [2, 1, 3] })
await s.createDivider(5, { title: 'X', statedPart: 2 })
const c = calls.find(x => x.method === 'POST' && x.url.endsWith('/chapters'))
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()
s.tagId = 7
const calls = []
@@ -84,7 +82,7 @@ describe('seriesManage', () => {
if (init.method === 'PATCH') return { status: 200, body: { ok: true } }
return { status: 200, body: SERIES_BODY }
})
await s.setChapterPart(1, 2)
await s.setDividerPart(1, 2)
const p = calls.find(c => c.method === 'PATCH')
expect(p.url).toContain('/api/series/7/chapters/1')
expect(p.body).toEqual({ stated_part: 2 })
@@ -94,18 +92,25 @@ describe('seriesManage', () => {
const s = useSeriesManageStore()
stubFetch(() => ({
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)
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()
})
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()
s.tagId = 7
s.targetChapterId = 5
s.pickerSelection = [9, 10]
const calls = []
stubFetch((url, init) => {
@@ -116,7 +121,7 @@ describe('seriesManage', () => {
})
await s.addSelected()
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([])
})
})
+19 -10
View File
@@ -178,14 +178,21 @@ def test_find_unused_tags_excludes_fandom_referenced_by_character(db_sync):
assert "NobodysFandom" in names # genuinely orphaned → still swept
def test_find_unused_tags_excludes_series_with_only_chapters(db_sync):
# An all-placeholder series has chapters but no pages yet — not unused.
series = _make_tag(db_sync, name="EmptySeries", kind=TagKind.series)
db_sync.add(SeriesChapter(series_tag_id=series.id, chapter_number=1))
def test_find_unused_tags_excludes_series_with_a_divider(db_sync):
# A series with a chapter divider (which anchors to a page) is in use.
a = _make_artist(db_sync)
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()
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 ---------------------------------------------
@@ -391,7 +398,12 @@ def test_prune_unused_tags_commit_spares_fandom_and_chaptered_series(
image_record_id=img.id, tag_id=char.id,
))
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")
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": 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(
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()
+78 -95
View File
@@ -1,4 +1,4 @@
"""FC-6.1 — chapter layer over series_page."""
"""FC-6.x cosmetic chapter dividers over the flat series page run."""
import pytest
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
async def _chapter_numbers(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):
async def _order(db, sid):
rows = (
await db.execute(
select(SeriesPage.image_id)
.where(SeriesPage.chapter_id == chapter_id)
.where(SeriesPage.series_tag_id == sid)
.order_by(SeriesPage.page_number)
)
).scalars().all()
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
async def test_add_images_creates_default_chapter(db):
async def test_create_divider_anchors_before_a_page(db):
svc = SeriesService(db)
sid = await _series(db, "C1")
i1, i2 = await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2])
chapters = await _chapter_numbers(db, sid)
assert len(chapters) == 1
assert chapters[0][1] == 1
assert await _page_order(db, chapters[0][0]) == [i1, i2]
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2, i3])
await svc.create_divider(sid, i2, title="Two", stated_part=2)
divs = await _dividers(db, sid)
assert len(divs) == 1
assert divs[0].image_id == i2
assert divs[0].title == "Two"
assert divs[0].stated_part == 2
@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)
sid = await _series(db, "C2")
i1, i2 = await _img(db), await _img(db)
await svc.add_images(sid, [i1]) # default chapter 1
ch2 = await svc.create_chapter(sid, title="Two")
assert ch2["chapter_number"] == 2
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
i1 = await _img(db)
await svc.add_images(sid, [i1])
with pytest.raises(SeriesError):
await svc.create_divider(sid, 999999)
@pytest.mark.asyncio
async def test_reorder_chapters(db):
async def test_one_divider_per_page(db):
svc = SeriesService(db)
sid = await _series(db, "C3")
c1 = (await svc.create_chapter(sid))["id"]
c2 = (await svc.create_chapter(sid))["id"]
c3 = (await svc.create_chapter(sid))["id"]
await svc.reorder_chapters(sid, [c3, c1, c2])
assert await _chapter_numbers(db, sid) == [(c3, 1), (c1, 2), (c2, 3)]
i1, i2 = await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2])
await svc.create_divider(sid, i2)
with pytest.raises(SeriesError):
await svc.reorder_chapters(sid, [c1, c2])
await svc.create_divider(sid, i2)
@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)
sid = await _series(db, "C4")
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2, i3])
cid = (await _chapter_numbers(db, sid))[0][0]
await svc.reorder_pages(sid, cid, [i3, i1, i2])
assert await _page_order(db, cid) == [i3, i1, i2]
d = await svc.create_divider(sid, i2)
await svc.update_divider(
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
async def test_legacy_reorder_rejects_multi_chapter(db):
async def test_delete_divider_leaves_pages(db):
svc = SeriesService(db)
sid = await _series(db, "C5")
i1 = await _img(db)
await svc.add_images(sid, [i1])
await svc.create_chapter(sid) # now 2 chapters
with pytest.raises(SeriesError):
await svc.reorder(sid, [i1])
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2, i3])
d = await svc.create_divider(sid, i2)
await svc.delete_divider(sid, d["id"])
assert await _order(db, sid) == [i1, i2, i3]
assert len(await _dividers(db, sid)) == 0
@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)
sid = await _series(db, "C6")
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1]) # chapter 1
c2 = (await svc.create_chapter(sid))["id"]
await svc.add_images(sid, [i2, i3], chapter_id=c2)
c1 = (await _chapter_numbers(db, sid))[0][0]
moved = await svc.merge_chapter(sid, c2, c1)
assert moved == 2
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.
await svc.add_images(sid, [i1, i2, i3])
await svc.create_divider(sid, i2)
await svc.remove_images(sid, [i2])
# the divider anchored at i2 cascaded away; pages compacted to 1..N.
assert len(await _dividers(db, sid)) == 0
assert await _order(db, sid) == [i1, i3]
cnt = await db.scalar(
select(func.count()).select_from(SeriesPage)
.where(SeriesPage.image_id == i2)
select(func.count()).select_from(SeriesChapter)
.where(SeriesChapter.series_tag_id == sid)
)
assert cnt == 0
@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)
sid = await _series(db, "C8")
sid = await _series(db, "C7")
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1]) # chapter 1
c2 = (await svc.create_chapter(sid))["id"]
await svc.add_images(sid, [i2, i3], chapter_id=c2)
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)
await svc.add_images(sid, [i1, i2, i3])
await svc.create_divider(sid, i1, stated_part=1)
await svc.create_divider(sid, i3, stated_part=3)
out = await svc.list_pages(sid)
assert len(out["chapters"]) == 2
assert len(out["gaps"]) == 1
assert out["gaps"][0]["start"] == 5
assert out["gaps"][0]["end"] == 8
assert len(out["dividers"]) == 2
assert len(out["part_gaps"]) == 1
assert out["part_gaps"][0]["start"] == 2
assert out["part_gaps"][0]["end"] == 2
@pytest.mark.asyncio
async def test_add_with_stated_pages_records_them(db):
svc = SeriesService(db)
sid = await _series(db, "C10")
sid = await _series(db, "C8")
i1, i2 = await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2], stated_pages={i1: 9, i2: 10})
rows = dict(
+37 -63
View File
@@ -1,9 +1,11 @@
"""FC-6.2 — promote a post to a series + add a post as a chapter + browse list."""
"""FC-6.x — promote a post to a series + add a post's pages + browse list."""
import pytest
from sqlalchemy import select
from backend.app.models import Artist, ImageRecord, Post, TagKind
from backend.app.services.series_service import SeriesService
from backend.app.models.series_page import SeriesPage
from backend.app.services.series_service import SeriesError, SeriesService
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
@@ -53,12 +55,10 @@ async def test_promote_post_to_series(db):
assert out["name"] == "My Comic pages 1-3"
data = await svc.list_pages(out["series_tag_id"])
assert len(data["chapters"]) == 1
ch = data["chapters"][0]
assert ch["stated_page_start"] == 1
assert ch["stated_page_end"] == 3
assert [p["image_id"] for p in ch["pages"]] == imgs # capture order
assert [p["stated_page"] for p in ch["pages"]] == [1, 2, 3]
# one flat run in capture order, no dividers, stated pages parsed 1..3
assert [p["image_id"] for p in data["pages"]] == imgs
assert [p["stated_page"] for p in data["pages"]] == [1, 2, 3]
assert data["dividers"] == []
@pytest.mark.asyncio
@@ -66,31 +66,34 @@ async def test_promote_requires_images(db):
svc = SeriesService(db)
artist = await _artist(db, "Empty Artist")
post = await _post(db, artist, "No images", "pp2")
from backend.app.services.series_service import SeriesError
with pytest.raises(SeriesError):
await svc.promote_post_to_series(post.id)
@pytest.mark.asyncio
async def test_add_post_as_chapter_slots_by_stated_page(db):
async def test_add_post_stages_pending(db):
svc = SeriesService(db)
artist = await _artist(db, "Story Artist")
sid = (await TagService(db).find_or_create("Story", TagKind.series)).id
# An existing chapter stating pages 9-12.
await svc.create_chapter(sid, stated_page_start=9, stated_page_end=12)
# A post stating pages 1-4 should slot BEFORE the 9-12 chapter.
post = await _post(db, artist, "Story pages 1-4", "pp3")
await _post_images(db, post, artist, 4)
out = await svc.add_post_as_chapter(sid, post.id)
assert out["added"] == 4
# Seed the series with two PLACED pages.
seed_post = await _post(db, artist, "seed", "pp3a")
seed = await _post_images(db, seed_post, artist, 2)
await svc.add_images(sid, seed)
# A later post is STAGED as pending — not in the placed run yet.
post = await _post(db, artist, "Story pages 9-11", "pp3b")
imgs = await _post_images(db, post, artist, 3)
out = await svc.add_post(sid, post.id)
assert out["staged"] == 3
data = await svc.list_pages(sid)
chapters = data["chapters"]
assert chapters[0]["stated_page_start"] == 1 # new one is first
assert chapters[1]["stated_page_start"] == 9
# gap 5-8 flagged between them
assert data["gaps"][0]["start"] == 5
assert data["gaps"][0]["end"] == 8
# placed run unchanged (just the seed); pending grouped under the post.
assert [p["image_id"] for p in data["pages"]] == seed
assert len(data["pending"]) == 1
grp = data["pending"][0]
assert grp["post"]["id"] == post.id
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
@@ -104,61 +107,32 @@ async def test_list_series_cards(db):
rows = await svc.list_series()
card = next(r for r in rows if r["id"] == out["series_tag_id"])
assert card["name"] == "Card Comic pages 1-2"
assert card["chapter_count"] == 1
assert card["chapter_count"] == 0 # a flat promote has no dividers
assert card["page_count"] == 2
assert card["artist_name"] == "Card Artist"
assert card["cover_thumbnail_url"]
assert card["has_gap"] is False
# artist filter keeps it; a different artist id drops it.
assert any(r["id"] == out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id))
assert all(r["id"] != out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id + 99999))
# --- FC-6.4: stated_part, part_gaps, source_post label ---------------------
assert any(
r["id"] == out["series_tag_id"]
for r in await svc.list_series(artist_id=artist.id)
)
assert all(
r["id"] != out["series_tag_id"]
for r in await svc.list_series(artist_id=artist.id + 99999)
)
@pytest.mark.asyncio
async def test_update_chapter_sets_and_clears_stated_part(db):
svc = SeriesService(db)
sid = (await TagService(db).find_or_create("Part Series", TagKind.series)).id
ch = await svc.create_chapter(sid)
# Set the installment to Part 2 (chapter_number stays its positional value).
await svc.update_chapter(sid, ch["id"], stated_part=2, set_part=True)
data = await svc.list_pages(sid)
assert data["chapters"][0]["stated_part"] == 2
assert data["chapters"][0]["chapter_number"] == 1
# Clearing it writes NULL back.
await svc.update_chapter(sid, ch["id"], stated_part=None, set_part=True)
data = await svc.list_pages(sid)
assert data["chapters"][0]["stated_part"] is None
@pytest.mark.asyncio
async def test_part_gaps_flagged_from_stated_part(db):
svc = SeriesService(db)
sid = (await TagService(db).find_or_create("Gappy Series", TagKind.series)).id
c1 = await svc.create_chapter(sid)
c3 = await svc.create_chapter(sid)
await svc.update_chapter(sid, c1["id"], stated_part=1, set_part=True)
await svc.update_chapter(sid, c3["id"], stated_part=3, set_part=True)
data = await svc.list_pages(sid)
assert len(data["part_gaps"]) == 1
gap = data["part_gaps"][0]
assert gap["after_chapter_id"] == c1["id"]
assert gap["start"] == 2
assert gap["end"] == 2
@pytest.mark.asyncio
async def test_source_post_label_when_pages_share_one_post(db):
async def test_source_post_label_is_per_page(db):
svc = SeriesService(db)
artist = await _artist(db, "Src Artist")
post = await _post(db, artist, "Source Comic pages 1-2", "pp5")
await _post_images(db, post, artist, 2)
out = await svc.promote_post_to_series(post.id)
data = await svc.list_pages(out["series_tag_id"])
sp = data["chapters"][0]["source_post"]
sp = data["pages"][0]["source_post"]
assert sp is not None
assert sp["id"] == post.id
assert sp["title"] == "Source Comic pages 1-2"
+5 -2
View File
@@ -79,7 +79,7 @@ async def test_match_is_idempotent_under_unique(db):
@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")
a = await _post_with_images(db, artist, "Saga pages 1-4", "a", 3)
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"])
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(
select(SeriesSuggestion.status).where(SeriesSuggestion.id == sug["id"])
)
+17 -15
View File
@@ -25,20 +25,10 @@ async def _img(db):
@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):
s = await TagService(db).find_or_create("Vol1", TagKind.series)
i1 = await _img(db)
ch = await _chapter(db, s.id)
db.add(SeriesPage(
series_tag_id=s.id, chapter_id=ch, image_id=i1, page_number=1
))
db.add(SeriesPage(series_tag_id=s.id, image_id=i1, page_number=1))
await db.flush()
got = await db.scalar(
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
# UNIQUE image_id: same image can't be a page twice (any 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, chapter_id=ch2, image_id=i1, page_number=1
))
db.add(SeriesPage(series_tag_id=s2.id, image_id=i1, page_number=1))
with pytest.raises(IntegrityError):
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):
await db.flush()
+127
View File
@@ -0,0 +1,127 @@
"""FC-6.x Phase 2 — pending staging for add-from-post."""
import pytest
from sqlalchemy import select
from backend.app.models import Artist, ImageRecord, Post, TagKind
from backend.app.models.series_page import SeriesPage
from backend.app.services.series_service import SeriesService
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
_SEQ = 0
async def _artist(db, name):
a = Artist(name=name, slug=name.lower().replace(" ", "-"))
db.add(a)
await db.flush()
return a
async def _post(db, artist, title, ext):
p = Post(artist_id=artist.id, external_post_id=ext, post_title=title)
db.add(p)
await db.flush()
return p
async def _post_images(db, post, artist, n):
global _SEQ
ids = []
for _ in range(n):
_SEQ += 1
rec = ImageRecord(
path=f"/tmp/fc_pend_{_SEQ}.png", sha256=f"{_SEQ:064d}",
size_bytes=1, mime="image/png", origin="downloaded",
primary_post_id=post.id, artist_id=artist.id,
)
db.add(rec)
await db.flush()
ids.append(rec.id)
return ids
async def _placed_order(db, sid):
rows = (
await db.execute(
select(SeriesPage.image_id)
.where(
SeriesPage.series_tag_id == sid,
SeriesPage.status == "placed",
)
.order_by(SeriesPage.page_number)
)
).scalars().all()
return list(rows)
async def _setup(db, name, seed_n=2):
svc = SeriesService(db)
artist = await _artist(db, name + " Artist")
sid = (await TagService(db).find_or_create(name, TagKind.series)).id
seed = []
if seed_n:
seed_post = await _post(db, artist, name + " seed", name + "-s")
seed = await _post_images(db, seed_post, artist, seed_n)
await svc.add_images(sid, seed)
return svc, artist, sid, seed
@pytest.mark.asyncio
async def test_place_pending_appends_and_flips_status(db):
svc, artist, sid, seed = await _setup(db, "PA")
post = await _post(db, artist, "PA pages", "pa-p")
imgs = await _post_images(db, post, artist, 3)
await svc.add_post(sid, post.id)
placed = await svc.place_pending(sid, imgs)
assert placed == 3
assert await _placed_order(db, sid) == seed + imgs
# nothing left pending; page numbers compact 1..5
data = await svc.list_pages(sid)
assert data["pending"] == []
assert [p["page_number"] for p in data["pages"]] == [1, 2, 3, 4, 5]
@pytest.mark.asyncio
async def test_place_pending_before_a_placed_page(db):
svc, artist, sid, seed = await _setup(db, "PB")
a, b = seed
post = await _post(db, artist, "PB pages", "pb-p")
x, y = await _post_images(db, post, artist, 2)
await svc.add_post(sid, post.id)
await svc.place_pending(sid, [x, y], before_image_id=b)
assert await _placed_order(db, sid) == [a, x, y, b]
@pytest.mark.asyncio
async def test_place_pending_ignores_already_placed(db):
svc, artist, sid, seed = await _setup(db, "PC", seed_n=1)
# the seed page is already placed — place_pending is a no-op for it.
assert await svc.place_pending(sid, [seed[0]]) == 0
assert await _placed_order(db, sid) == seed
@pytest.mark.asyncio
async def test_remove_drops_a_pending_junk_page(db):
svc, artist, sid, _ = await _setup(db, "PD", seed_n=0)
post = await _post(db, artist, "PD pages", "pd-p")
x, y, z = await _post_images(db, post, artist, 3)
await svc.add_post(sid, post.id)
# y is junk (a text-free alt) — drop it before placing.
await svc.remove_images(sid, [y])
data = await svc.list_pages(sid)
assert [pg["image_id"] for pg in data["pending"][0]["pages"]] == [x, z]
@pytest.mark.asyncio
async def test_place_pending_route_requires_ids(client):
r = await client.post(
"/api/tags", json={"name": "PReq", "kind": "series"}
)
sid = (await r.get_json())["id"]
resp = await client.post(f"/api/series/{sid}/pending/place", json={})
assert resp.status_code == 400
+5 -6
View File
@@ -409,19 +409,18 @@ async def test_merge_repoints_series_pages(db):
a = await svc.find_or_create("SerA", TagKind.series)
b = await svc.find_or_create("SerB", TagKind.series)
img = await _img(db)
cha = SeriesChapter(series_tag_id=a.id, chapter_number=1)
db.add(cha)
p = SeriesPage(series_tag_id=a.id, image_id=img, page_number=1)
db.add(p)
await db.flush()
db.add(SeriesPage(
series_tag_id=a.id, chapter_id=cha.id, image_id=img, page_number=1
))
cha = SeriesChapter(series_tag_id=a.id, anchor_page_id=p.id)
db.add(cha)
await db.flush()
await svc.merge(a.id, b.id)
owner = await db.scalar(
select(SeriesPage.series_tag_id).where(SeriesPage.image_id == img)
)
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(
select(SeriesChapter.series_tag_id).where(SeriesChapter.id == cha.id)
)