feat(series): pending staging for add-from-post (#789 Phase 2)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m17s

Add-from-post no longer appends straight into the run — it STAGES the post's
pages as pending (per-page status; page_number NULL), grouped by source post,
so the operator drops junk (text-free alts, bumpers) and places the keepers
into the sequence with clean series-global numbering.

- migration 0048: series_page.status ('placed' default | 'pending') + nullable
  page_number.
- series_service: placed/pending split everywhere (list_pages returns the
  placed run + a `pending` section grouped by source post; reorder/cover/
  list_series operate on placed only); add_post stages pending; new
  place_pending(image_ids, before_image_id=None) flips pending→placed spliced
  before a page (or appended) and renumbers; junk removal reuses remove_images.
- api/tags: /add-post now returns staged count; new POST /series/<id>/pending/
  place.
- frontend: PostSeriesMenu navigates to the series after staging; seriesManage
  store surfaces `pending` + placePending; SeriesManageView gains a pending
  tray (per-post groups, place-all / place-one / drop-junk).
- tests: pending staging, place (append + insert-before), ignore-already-
  placed, drop-junk, route guard; updated add_post + match-accept expectations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:47:58 -04:00
parent 59746d213d
commit 7bb765b6ed
10 changed files with 521 additions and 42 deletions
+191 -21
View File
@@ -57,11 +57,18 @@ class SeriesService:
return out
async def _page_order(self, series_tag_id: int) -> list[int]:
"""The series' image_ids in reading order (series-global page_number)."""
"""The series' PLACED image_ids in reading order (series-global
page_number). Pending (staged) pages are excluded — they have no order
yet."""
rows = (
await self.session.execute(
select(SeriesPage.image_id)
.where(SeriesPage.series_tag_id == series_tag_id)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.status == "placed",
)
)
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
)
).scalars().all()
@@ -159,7 +166,12 @@ class SeriesService:
.select_from(SeriesPage)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.outerjoin(Post, Post.id == ImageRecord.primary_post_id)
.where(SeriesPage.series_tag_id == series_tag_id)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.status == "placed",
)
)
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
)
).all()
@@ -207,10 +219,66 @@ class SeriesService:
for r in drows
]
# Pending (staged-from-post) pages, grouped by their source post so the
# operator can drop junk and place the keepers into the run.
prows = (
await self.session.execute(
select(
SeriesPage.image_id,
SeriesPage.stated_page,
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.path,
ImageRecord.thumbnail_path,
ImageRecord.primary_post_id,
Post.post_title,
)
.select_from(SeriesPage)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.outerjoin(Post, Post.id == ImageRecord.primary_post_id)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.status == "pending",
)
)
.order_by(
ImageRecord.primary_post_id.asc().nulls_last(),
SeriesPage.id.asc(),
)
)
).all()
pending_groups: list[dict] = []
by_post: dict = {}
for r in prows:
grp = by_post.get(r.primary_post_id)
if grp is None:
grp = {
"post": (
{"id": r.primary_post_id, "title": r.post_title}
if r.primary_post_id is not None
else None
),
"pages": [],
}
by_post[r.primary_post_id] = grp
pending_groups.append(grp)
grp["pages"].append(
{
"image_id": r.image_id,
"stated_page": r.stated_page,
"thumbnail_url": thumbnail_url(
r.thumbnail_path, r.sha256, r.mime
),
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
}
)
return {
"series": {"id": tag.id, "name": tag.name},
"pages": pages,
"dividers": dividers,
"pending": pending_groups,
"part_gaps": self._part_gaps(dividers),
}
@@ -243,13 +311,7 @@ class SeriesService:
await self.session.execute(
SeriesPage.__table__.delete().where(SeriesPage.image_id.in_(to_add))
)
max_pn = (
await self.session.scalar(
select(
func.coalesce(func.max(SeriesPage.page_number), 0)
).where(SeriesPage.series_tag_id == series_tag_id)
)
) or 0
max_pn = await self._max_placed_page_number(series_tag_id)
sp = stated_pages or {}
await self.session.execute(
pg_insert(SeriesPage).values(
@@ -257,6 +319,7 @@ class SeriesService:
{
"series_tag_id": series_tag_id,
"image_id": iid,
"status": "placed",
"page_number": max_pn + offset,
"stated_page": sp.get(iid),
}
@@ -266,6 +329,20 @@ class SeriesService:
)
return len(to_add)
async def _max_placed_page_number(self, series_tag_id: int) -> int:
return (
await self.session.scalar(
select(
func.coalesce(func.max(SeriesPage.page_number), 0)
).where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.status == "placed",
)
)
)
) or 0
async def remove_images(
self, series_tag_id: int, image_ids: list[int]
) -> int:
@@ -454,10 +531,11 @@ class SeriesService:
}
async def add_post(self, series_tag_id: int, post_id: int) -> dict:
"""Case 2: append a post's images to the end of an existing series' flat
run (stated pages parsed from the post when present). No chapter wrapping
— the operator orders/labels afterward. (Phase 2 will stage these as
pending instead of appending directly.)"""
"""Case 2: STAGE a post's images as PENDING pages of an existing series
(#789 P2). They don't enter the ordered run yet — the operator drops junk
(text-free alts, bumpers) and places the keepers via place_pending, so
the series-global numbering stays clean. Stated pages are parsed from the
post when present."""
await self._require_series(series_tag_id)
post = await self.session.get(Post, post_id)
if post is None:
@@ -465,13 +543,102 @@ class SeriesService:
image_ids = await self._post_images_ordered(post_id)
if not image_ids:
raise SeriesError("post has no images to add")
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
start = rng[0] if rng else None
added = await self.add_images(
series_tag_id, image_ids,
stated_pages=self._stated_map(image_ids, start),
# Skip images already in THIS series (placed or pending); move any in
# another series here as pending (image_id is UNIQUE).
existing = dict(
(
await self.session.execute(
select(SeriesPage.image_id, SeriesPage.series_tag_id)
.where(SeriesPage.image_id.in_(image_ids))
)
).all()
)
return {"series_tag_id": series_tag_id, "added": added}
to_stage = [i for i in image_ids if existing.get(i) != series_tag_id]
if not to_stage:
return {"series_tag_id": series_tag_id, "staged": 0}
await self.session.execute(
SeriesPage.__table__.delete().where(
SeriesPage.image_id.in_(to_stage)
)
)
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
sp = self._stated_map(image_ids, rng[0] if rng else None) or {}
await self.session.execute(
pg_insert(SeriesPage).values(
[
{
"series_tag_id": series_tag_id,
"image_id": iid,
"status": "pending",
"page_number": None,
"stated_page": sp.get(iid),
}
for iid in to_stage
]
)
)
return {"series_tag_id": series_tag_id, "staged": len(to_stage)}
async def place_pending(
self,
series_tag_id: int,
image_ids: list[int],
before_image_id: int | None = None,
) -> int:
"""Move staged (pending) pages into the placed run — spliced in just
before `before_image_id` (a placed page), or appended at the end when
it's None. Page numbers are then rewritten 1..N across the run."""
await self._require_series(series_tag_id)
ids = self._clean_ids(image_ids)
rows = dict(
(
await self.session.execute(
select(SeriesPage.image_id, SeriesPage.status).where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id.in_(ids),
)
)
)
).all()
)
staged = [i for i in ids if rows.get(i) == "pending"]
if not staged:
return 0
placed = await self._page_order(series_tag_id)
if before_image_id is not None and before_image_id not in placed:
raise SeriesError(
"before_image_id is not a placed page of this series"
)
new_order: list[int] = []
for iid in placed:
if iid == before_image_id:
new_order.extend(staged)
new_order.append(iid)
if before_image_id is None:
new_order.extend(staged)
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id.in_(staged),
)
)
.values(status="placed")
)
for idx, iid in enumerate(new_order, start=1):
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == iid,
)
)
.values(page_number=idx)
)
return len(staged)
# ---- browse list (FC-6.2) --------------------------------------------
@@ -488,7 +655,9 @@ class SeriesService:
SeriesPage.series_tag_id,
func.count().label("pages"),
func.max(SeriesPage.updated_at).label("updated_at"),
).group_by(SeriesPage.series_tag_id)
)
.where(SeriesPage.status == "placed")
.group_by(SeriesPage.series_tag_id)
)
).all()
page_count = {r.series_tag_id: r.pages for r in page_rows}
@@ -532,6 +701,7 @@ class SeriesService:
.label("rn"),
)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.where(SeriesPage.status == "placed")
.subquery()
)
cover_rows = (