feat(series): pending staging for add-from-post (#789 Phase 2)
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:
@@ -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) --------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user