feat(series): operator-set sparse page numbers + gap blocks (#789 tweak)
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:
+27
-15
@@ -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)
|
||||
|
||||
@@ -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 3–4). 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user