feat(series): operator-set sparse page numbers + gap blocks (#789 tweak)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Failing after 3m17s

Replaces the auto-renumbered 1..N position key with operator-OWNED page
numbers: sparse, gaps allowed, editable, never auto-renumbered. Order follows
the numbers; unnumbered pages sort to the tail. This is the fix for the model
that clobbered hand-set numbers on the flatten — numbers are now data, not a
derived sequence.

- series_service: drop the renumber-on-reorder/remove; order by page_number
  NULLS LAST; new set_page_number(image_id, n|None); list_pages returns `gaps`
  (one entry per missing-number run) + each pending group's parsed `start_page`;
  set_cover renumbers below the current min; place_pending(image_ids, start_page)
  numbers placed pages sequentially from the start (drop junk first → numbers
  line up); add_post stamps the parsed start on staged pages.
- api/tags: POST /series/<id>/pages/number (set one page's number); /pending/
  place takes start_page; removed /reorder.
- frontend: per-card editable number input; one gap block per gap with
  drop-on-edge to assign the adjacent number (middle → type); append drop zone;
  pending tray gets a "from page N" field + "Place from page N".
- tests reworked: sparse numbers + gaps, place-from-start, set-page-number route.

No migration; nothing destructive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 23:10:30 -04:00
parent 7bb765b6ed
commit 013b9d7f06
8 changed files with 401 additions and 228 deletions
+27 -15
View File
@@ -415,15 +415,26 @@ async def series_remove(tag_id: int):
return jsonify({"removed_count": n})
@tags_bp.route("/series/<int:tag_id>/reorder", methods=["POST"])
async def series_reorder(tag_id: int):
body = await request.get_json()
ids, err = _parse_bulk_ids(body, max_ids=500)
if err:
return err
@tags_bp.route("/series/<int:tag_id>/pages/number", methods=["POST"])
async def series_set_page_number(tag_id: int):
"""Set one placed page's number — the operator's value (sparse, gaps
allowed); pass page_number: null to leave it unnumbered."""
body = await request.get_json() or {}
image_id, ierr = _opt_int(body, "image_id")
if ierr:
return ierr
if image_id is None:
return jsonify({"error": "image_id required"}), 400
if "page_number" not in body:
return jsonify({"error": "page_number required (may be null)"}), 400
page_number, perr = _opt_int(body, "page_number")
if perr:
return perr
async with get_session() as session:
try:
await SeriesService(session).reorder(tag_id, ids)
await SeriesService(session).set_page_number(
tag_id, image_id, page_number
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
@@ -450,8 +461,9 @@ async def series_cover(tag_id: int):
# ---- 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.
# no pages. Page ordering follows each page's operator-set number (the
# /pages/number endpoint), so there is no per-chapter reorder/merge — those are
# gone.
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
@@ -585,19 +597,19 @@ async def series_add_post(tag_id: int):
@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)."""
"""Place staged (pending) pages into the run, numbered sequentially from
`start_page` in the given order (#789). start_page null → unnumbered."""
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
start, serr = _opt_int(body, "start_page")
if serr:
return serr
async with get_session() as session:
try:
n = await SeriesService(session).place_pending(
tag_id, ids, before_image_id=before
tag_id, ids, start_page=start
)
except SeriesError as exc:
return _series_err(exc)
+102 -81
View File
@@ -11,7 +11,7 @@ nearest preceding divider. An image lives in at most one series
All mutations are Core/set-based and run in the request transaction.
"""
from sqlalchemy import and_, func, select, text, update
from sqlalchemy import and_, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -69,7 +69,10 @@ class SeriesService:
SeriesPage.status == "placed",
)
)
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
.order_by(
SeriesPage.page_number.asc().nulls_last(),
SeriesPage.id.asc(),
)
)
).scalars().all()
return list(rows)
@@ -88,25 +91,6 @@ class SeriesService:
raise SeriesError(f"image {image_id} is not in this series")
return pid
async def _renumber(self, series_tag_id: int) -> None:
"""Compact page_number to 1..N preserving current order (set-based)."""
await self.session.execute(
text(
"""
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (
ORDER BY page_number, id
) AS rn
FROM series_page WHERE series_tag_id = :sid
)
UPDATE series_page sp SET page_number = ordered.rn
FROM ordered
WHERE sp.id = ordered.id AND sp.page_number <> ordered.rn
"""
),
{"sid": series_tag_id},
)
async def _require_divider(self, series_tag_id: int, divider_id: int):
row = (
await self.session.execute(
@@ -148,6 +132,30 @@ class SeriesService:
prev = d
return out
@staticmethod
def _page_gaps(pages: list[dict]) -> list[dict]:
"""Missing-number gaps between consecutive NUMBERED placed pages — one
entry per gap (e.g. pages 2 then 5 → one gap 34). The UI renders each as
a single block whose edges accept a dropped page. Unnumbered pages (at
the tail) don't participate."""
out: list[dict] = []
prev = None
for p in pages:
n = p["page_number"]
if n is None:
continue
if prev is not None and n > prev["page_number"] + 1:
out.append(
{
"after_image_id": prev["image_id"],
"before_image_id": p["image_id"],
"from": prev["page_number"] + 1,
"to": n - 1,
}
)
prev = p
return out
async def list_pages(self, series_tag_id: int) -> dict:
tag = await self._require_series(series_tag_id)
rows = (
@@ -172,7 +180,10 @@ class SeriesService:
SeriesPage.status == "placed",
)
)
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
.order_by(
SeriesPage.page_number.asc().nulls_last(),
SeriesPage.id.asc(),
)
)
).all()
pages = [
@@ -259,6 +270,7 @@ class SeriesService:
if r.primary_post_id is not None
else None
),
"start_page": None,
"pages": [],
}
by_post[r.primary_post_id] = grp
@@ -273,10 +285,18 @@ class SeriesService:
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
}
)
# Group start page = the post's parsed starting page (stored on the
# staged pages); surfaced as the default for "Place from page N".
if r.stated_page is not None:
cur = grp["start_page"]
grp["start_page"] = (
r.stated_page if cur is None else min(cur, r.stated_page)
)
return {
"series": {"id": tag.id, "name": tag.name},
"pages": pages,
"gaps": self._page_gaps(pages),
"dividers": dividers,
"pending": pending_groups,
"part_gaps": self._part_gaps(dividers),
@@ -357,45 +377,58 @@ class SeriesService:
)
)
# Dividers anchored to a removed page cascade away (chapter merges into
# the preceding run); compact the remaining pages back to 1..N.
await self._renumber(series_tag_id)
# the preceding run). Page numbers are operator-set and sparse, so we do
# NOT renumber — removing a page just leaves a gap.
return res.rowcount or 0
async def reorder(
self, series_tag_id: int, ordered_image_ids: list[int]
async def set_page_number(
self, series_tag_id: int, image_id: int, page_number: int | None
) -> None:
"""Series-wide reorder. ordered_image_ids must be exactly the series'
pages; page_number is rewritten 1..N in that order."""
"""Set one placed page's number — the operator's value: sparse, gaps
allowed, or None to leave it unnumbered (sorts to the end). No other page
is touched; reading order follows the numbers."""
await self._require_series(series_tag_id)
ordered = self._clean_ids(ordered_image_ids)
current = set(await self._page_order(series_tag_id))
if set(ordered) != current or len(ordered) != len(current):
raise SeriesError(
"ordered image_ids must exactly match the series' pages"
)
for idx, iid in enumerate(ordered, start=1):
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == iid,
)
res = await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == image_id,
SeriesPage.status == "placed",
)
.values(page_number=idx)
)
.values(page_number=page_number)
)
if (res.rowcount or 0) == 0:
raise SeriesError(
f"image {image_id} is not a placed page of this series"
)
async def set_cover(self, series_tag_id: int, image_id: int) -> None:
"""Cover = the first page. Move the image to the front of the series."""
"""Cover = the lowest-numbered page. Renumber the chosen image to sit
just before the current minimum so it sorts first."""
await self._require_series(series_tag_id)
pages = await self._page_order(series_tag_id)
if image_id not in pages:
raise SeriesError(f"image {image_id} is not in this series")
if pages and pages[0] != image_id:
await self.reorder(
series_tag_id,
[image_id] + [p for p in pages if p != image_id],
await self._page_id_for(series_tag_id, image_id) # membership guard
min_other = await self.session.scalar(
select(func.min(SeriesPage.page_number)).where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.status == "placed",
SeriesPage.image_id != image_id,
)
)
)
new_pn = (min_other - 1) if min_other is not None else 1
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == image_id,
)
)
.values(page_number=new_pn)
)
# ---- chapter dividers -------------------------------------------------
@@ -561,8 +594,10 @@ class SeriesService:
SeriesPage.image_id.in_(to_stage)
)
)
# Store the post's parsed START page on every staged page (constant), so
# the group's start survives junk removal and seeds "Place from page N".
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 {}
start = rng[0] if rng else None
await self.session.execute(
pg_insert(SeriesPage).values(
[
@@ -571,7 +606,7 @@ class SeriesService:
"image_id": iid,
"status": "pending",
"page_number": None,
"stated_page": sp.get(iid),
"stated_page": start,
}
for iid in to_stage
]
@@ -583,11 +618,13 @@ class SeriesService:
self,
series_tag_id: int,
image_ids: list[int],
before_image_id: int | None = None,
start_page: 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."""
"""Place staged (pending) pages into the run, numbering them sequentially
from `start_page` in the given order (start_page, start_page+1, …). With
start_page None they're placed unnumbered (sorting to the tail). Drop the
junk BEFORE placing so the numbers line up; only pending pages are
touched, and other pages keep their numbers."""
await self._require_series(series_tag_id)
ids = self._clean_ids(image_ids)
rows = dict(
@@ -602,32 +639,11 @@ class SeriesService:
)
).all()
)
# Preserve the caller's order, restricted to actually-pending pages.
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):
for offset, iid in enumerate(staged):
await self.session.execute(
update(SeriesPage)
.where(
@@ -636,7 +652,12 @@ class SeriesService:
SeriesPage.image_id == iid,
)
)
.values(page_number=idx)
.values(
status="placed",
page_number=(
start_page + offset if start_page is not None else None
),
)
)
return len(staged)