diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 6751975..3ab53a9 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -415,15 +415,26 @@ async def series_remove(tag_id: int): return jsonify({"removed_count": n}) -@tags_bp.route("/series//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//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//chapters", methods=["POST"]) @@ -585,19 +597,19 @@ async def series_add_post(tag_id: int): @tags_bp.route("/series//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) diff --git a/backend/app/services/series_service.py b/backend/app/services/series_service.py index f07bc9b..2bcbc32 100644 --- a/backend/app/services/series_service.py +++ b/backend/app/services/series_service.py @@ -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) diff --git a/frontend/src/stores/seriesManage.js b/frontend/src/stores/seriesManage.js index 47f22e3..2cfa5cf 100644 --- a/frontend/src/stores/seriesManage.js +++ b/frontend/src/stores/seriesManage.js @@ -20,7 +20,8 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { const series = ref(null) const pages = ref([]) // flat, ordered: [{image_id, page_number, stated_page, thumbnail_url, image_url, source_post}] const dividers = ref([]) // [{id, anchor_image_id, page_number, title, stated_part}] - const pending = ref([]) // staged-from-post: [{post:{id,title}|null, pages:[{image_id, stated_page, thumbnail_url, image_url}]}] + const gaps = ref([]) // missing-number gaps: [{after_image_id, before_image_id, from, to}] + const pending = ref([]) // staged-from-post: [{post:{id,title}|null, start_page, pages:[{image_id, stated_page, thumbnail_url, image_url}]}] const partGaps = ref([]) // [{after_divider_id, start, end}] const pageCount = ref(0) const picker = ref([]) // gallery scroll results @@ -36,6 +37,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { series.value = body.series pages.value = body.pages || [] dividers.value = body.dividers || [] + gaps.value = body.gaps || [] pending.value = body.pending || [] partGaps.value = body.part_gaps || [] pageCount.value = pages.value.length @@ -57,14 +59,19 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { return partGaps.value.find(g => g.after_divider_id === dividerId) || null } - // ---- pages (series-wide) ---- - async function reorder(orderedImageIds) { - await api.post(`/api/series/${tagId.value}/reorder`, { - body: { image_ids: orderedImageIds } + // ---- pages (number-driven) ---- + // Set one page's number (sparse, gaps allowed; pass null to unnumber). + async function setPageNumber(imageId, pageNumber) { + await api.post(`/api/series/${tagId.value}/pages/number`, { + body: { image_id: imageId, page_number: pageNumber } }) await refresh() } + function gapAfter(imageId) { + return gaps.value.find(g => g.after_image_id === imageId) || null + } + async function remove(imageId) { await api.post(`/api/series/${tagId.value}/pages/remove`, { body: { image_ids: [imageId] } @@ -108,9 +115,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { } // ---- pending staging (add-from-post) ---- - async function placePending(imageIds, beforeImageId = null) { + // Place pending pages, numbered sequentially from startPage in the given order. + async function placePending(imageIds, startPage = null) { await api.post(`/api/series/${tagId.value}/pending/place`, { - body: { image_ids: imageIds, before_image_id: beforeImageId } + body: { image_ids: imageIds, start_page: startPage } }) await refresh() toast({ text: 'Pages placed into the series', type: 'success' }) @@ -143,10 +151,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { } return { - tagId, series, pages, dividers, pending, partGaps, pageCount, + tagId, series, pages, dividers, gaps, pending, partGaps, pageCount, picker, pickerCursor, pickerSelection, loading, - load, refresh, dividerAt, partGapAfter, - reorder, remove, setCover, placePending, + load, refresh, dividerAt, partGapAfter, gapAfter, + setPageNumber, remove, setCover, placePending, createDivider, renameDivider, setDividerPart, deleteDivider, loadPicker, togglePick, addSelected } diff --git a/frontend/src/views/SeriesManageView.vue b/frontend/src/views/SeriesManageView.vue index f16b43a..68746ec 100644 --- a/frontend/src/views/SeriesManageView.vue +++ b/frontend/src/views/SeriesManageView.vue @@ -25,23 +25,22 @@

- One continuous series. Drag any page to set its order — - the numbers are the page's place in the whole series. Use a page's - ⋮ → Start a chapter here to drop a labeled divider before - it. New pages from Add pages append to the end, ready to - be dragged into place. + One continuous series. Each page's number is yours — type + it on the card; gaps are fine. Drag a page onto a gap's edge + to give it the adjacent number, or onto the end to append. New pages from a + post land in the pending tray to sort.

- +
mdi-tray-arrow-down Pending — sort into the series

- New pages from a post land here. Drop the ones that don't belong - (text-free alternates, bumpers), then place the rest into the run. + Drop the pages that don't belong (text-free alternates, bumpers), then + place the rest — they'll be numbered up from the start page you set.

@@ -50,22 +49,26 @@ {{ grp.post?.title || 'Unknown post' }} {{ grp.pages.length }} pending + Place all into series + :disabled="effectiveStart(grp, gi) == null" + @click="placeGroup(grp, gi)" + >Place from page {{ effectiveStart(grp, gi) ?? '?' }}
-
- +
-