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
+22
View File
@@ -583,6 +583,28 @@ async def series_add_post(tag_id: int):
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) --------------------------------------------
+7 -2
View File
@@ -14,7 +14,7 @@ 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
@@ -32,7 +32,12 @@ class SeriesPage(Base):
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()
+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 = (