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}) return jsonify({"removed_count": n})
@tags_bp.route("/series/<int:tag_id>/reorder", methods=["POST"]) @tags_bp.route("/series/<int:tag_id>/pages/number", methods=["POST"])
async def series_reorder(tag_id: int): async def series_set_page_number(tag_id: int):
body = await request.get_json() """Set one placed page's number — the operator's value (sparse, gaps
ids, err = _parse_bulk_ids(body, max_ids=500) allowed); pass page_number: null to leave it unnumbered."""
if err: body = await request.get_json() or {}
return err 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: async with get_session() as session:
try: try:
await SeriesService(session).reorder(tag_id, ids) await SeriesService(session).set_page_number(
tag_id, image_id, page_number
)
except SeriesError as exc: except SeriesError as exc:
return _series_err(exc) return _series_err(exc)
await session.commit() await session.commit()
@@ -450,8 +461,9 @@ async def series_cover(tag_id: int):
# ---- chapter dividers (FC-6.x) ------------------------------------------- # ---- chapter dividers (FC-6.x) -------------------------------------------
# A chapter is a cosmetic divider anchored to the page that begins it; it owns # 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 # no pages. Page ordering follows each page's operator-set number (the
# is no per-chapter reorder/merge/chapter-reorder — those are gone. # /pages/number endpoint), so there is no per-chapter reorder/merge — those are
# gone.
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"]) @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"]) @tags_bp.route("/series/<int:tag_id>/pending/place", methods=["POST"])
async def series_place_pending(tag_id: int): async def series_place_pending(tag_id: int):
"""Move staged (pending) pages into the placed run — before `before_image_id` """Place staged (pending) pages into the run, numbered sequentially from
(a placed page) or appended when omitted (#789 P2).""" `start_page` in the given order (#789). start_page null → unnumbered."""
body = await request.get_json() body = await request.get_json()
ids, err = _parse_bulk_ids(body, max_ids=500) ids, err = _parse_bulk_ids(body, max_ids=500)
if err: if err:
return err return err
before, berr = _opt_int(body, "before_image_id") start, serr = _opt_int(body, "start_page")
if berr: if serr:
return berr return serr
async with get_session() as session: async with get_session() as session:
try: try:
n = await SeriesService(session).place_pending( n = await SeriesService(session).place_pending(
tag_id, ids, before_image_id=before tag_id, ids, start_page=start
) )
except SeriesError as exc: except SeriesError as exc:
return _series_err(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. 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.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -69,7 +69,10 @@ class SeriesService:
SeriesPage.status == "placed", 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() ).scalars().all()
return list(rows) return list(rows)
@@ -88,25 +91,6 @@ class SeriesService:
raise SeriesError(f"image {image_id} is not in this series") raise SeriesError(f"image {image_id} is not in this series")
return pid 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): async def _require_divider(self, series_tag_id: int, divider_id: int):
row = ( row = (
await self.session.execute( await self.session.execute(
@@ -148,6 +132,30 @@ class SeriesService:
prev = d prev = d
return out 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: async def list_pages(self, series_tag_id: int) -> dict:
tag = await self._require_series(series_tag_id) tag = await self._require_series(series_tag_id)
rows = ( rows = (
@@ -172,7 +180,10 @@ class SeriesService:
SeriesPage.status == "placed", SeriesPage.status == "placed",
) )
) )
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc()) .order_by(
SeriesPage.page_number.asc().nulls_last(),
SeriesPage.id.asc(),
)
) )
).all() ).all()
pages = [ pages = [
@@ -259,6 +270,7 @@ class SeriesService:
if r.primary_post_id is not None if r.primary_post_id is not None
else None else None
), ),
"start_page": None,
"pages": [], "pages": [],
} }
by_post[r.primary_post_id] = grp by_post[r.primary_post_id] = grp
@@ -273,10 +285,18 @@ class SeriesService:
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}", "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 { return {
"series": {"id": tag.id, "name": tag.name}, "series": {"id": tag.id, "name": tag.name},
"pages": pages, "pages": pages,
"gaps": self._page_gaps(pages),
"dividers": dividers, "dividers": dividers,
"pending": pending_groups, "pending": pending_groups,
"part_gaps": self._part_gaps(dividers), "part_gaps": self._part_gaps(dividers),
@@ -357,45 +377,58 @@ class SeriesService:
) )
) )
# Dividers anchored to a removed page cascade away (chapter merges into # Dividers anchored to a removed page cascade away (chapter merges into
# the preceding run); compact the remaining pages back to 1..N. # the preceding run). Page numbers are operator-set and sparse, so we do
await self._renumber(series_tag_id) # NOT renumber — removing a page just leaves a gap.
return res.rowcount or 0 return res.rowcount or 0
async def reorder( async def set_page_number(
self, series_tag_id: int, ordered_image_ids: list[int] self, series_tag_id: int, image_id: int, page_number: int | None
) -> None: ) -> None:
"""Series-wide reorder. ordered_image_ids must be exactly the series' """Set one placed page's number — the operator's value: sparse, gaps
pages; page_number is rewritten 1..N in that order.""" 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) await self._require_series(series_tag_id)
ordered = self._clean_ids(ordered_image_ids) res = await self.session.execute(
current = set(await self._page_order(series_tag_id)) update(SeriesPage)
if set(ordered) != current or len(ordered) != len(current): .where(
raise SeriesError( and_(
"ordered image_ids must exactly match the series' pages" SeriesPage.series_tag_id == series_tag_id,
) SeriesPage.image_id == image_id,
for idx, iid in enumerate(ordered, start=1): SeriesPage.status == "placed",
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == iid,
)
) )
.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: 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) await self._require_series(series_tag_id)
pages = await self._page_order(series_tag_id) await self._page_id_for(series_tag_id, image_id) # membership guard
if image_id not in pages: min_other = await self.session.scalar(
raise SeriesError(f"image {image_id} is not in this series") select(func.min(SeriesPage.page_number)).where(
if pages and pages[0] != image_id: and_(
await self.reorder( SeriesPage.series_tag_id == series_tag_id,
series_tag_id, SeriesPage.status == "placed",
[image_id] + [p for p in pages if p != image_id], 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 ------------------------------------------------- # ---- chapter dividers -------------------------------------------------
@@ -561,8 +594,10 @@ class SeriesService:
SeriesPage.image_id.in_(to_stage) 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 ''}") 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( await self.session.execute(
pg_insert(SeriesPage).values( pg_insert(SeriesPage).values(
[ [
@@ -571,7 +606,7 @@ class SeriesService:
"image_id": iid, "image_id": iid,
"status": "pending", "status": "pending",
"page_number": None, "page_number": None,
"stated_page": sp.get(iid), "stated_page": start,
} }
for iid in to_stage for iid in to_stage
] ]
@@ -583,11 +618,13 @@ class SeriesService:
self, self,
series_tag_id: int, series_tag_id: int,
image_ids: list[int], image_ids: list[int],
before_image_id: int | None = None, start_page: int | None = None,
) -> int: ) -> int:
"""Move staged (pending) pages into the placed run — spliced in just """Place staged (pending) pages into the run, numbering them sequentially
before `before_image_id` (a placed page), or appended at the end when from `start_page` in the given order (start_page, start_page+1, …). With
it's None. Page numbers are then rewritten 1..N across the run.""" 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) await self._require_series(series_tag_id)
ids = self._clean_ids(image_ids) ids = self._clean_ids(image_ids)
rows = dict( rows = dict(
@@ -602,32 +639,11 @@ class SeriesService:
) )
).all() ).all()
) )
# Preserve the caller's order, restricted to actually-pending pages.
staged = [i for i in ids if rows.get(i) == "pending"] staged = [i for i in ids if rows.get(i) == "pending"]
if not staged: if not staged:
return 0 return 0
placed = await self._page_order(series_tag_id) for offset, iid in enumerate(staged):
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( await self.session.execute(
update(SeriesPage) update(SeriesPage)
.where( .where(
@@ -636,7 +652,12 @@ class SeriesService:
SeriesPage.image_id == iid, 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) return len(staged)
+18 -10
View File
@@ -20,7 +20,8 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
const series = ref(null) const series = ref(null)
const pages = ref([]) // flat, ordered: [{image_id, page_number, stated_page, thumbnail_url, image_url, source_post}] 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 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 partGaps = ref([]) // [{after_divider_id, start, end}]
const pageCount = ref(0) const pageCount = ref(0)
const picker = ref([]) // gallery scroll results const picker = ref([]) // gallery scroll results
@@ -36,6 +37,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
series.value = body.series series.value = body.series
pages.value = body.pages || [] pages.value = body.pages || []
dividers.value = body.dividers || [] dividers.value = body.dividers || []
gaps.value = body.gaps || []
pending.value = body.pending || [] pending.value = body.pending || []
partGaps.value = body.part_gaps || [] partGaps.value = body.part_gaps || []
pageCount.value = pages.value.length 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 return partGaps.value.find(g => g.after_divider_id === dividerId) || null
} }
// ---- pages (series-wide) ---- // ---- pages (number-driven) ----
async function reorder(orderedImageIds) { // Set one page's number (sparse, gaps allowed; pass null to unnumber).
await api.post(`/api/series/${tagId.value}/reorder`, { async function setPageNumber(imageId, pageNumber) {
body: { image_ids: orderedImageIds } await api.post(`/api/series/${tagId.value}/pages/number`, {
body: { image_id: imageId, page_number: pageNumber }
}) })
await refresh() await refresh()
} }
function gapAfter(imageId) {
return gaps.value.find(g => g.after_image_id === imageId) || null
}
async function remove(imageId) { async function remove(imageId) {
await api.post(`/api/series/${tagId.value}/pages/remove`, { await api.post(`/api/series/${tagId.value}/pages/remove`, {
body: { image_ids: [imageId] } body: { image_ids: [imageId] }
@@ -108,9 +115,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
} }
// ---- pending staging (add-from-post) ---- // ---- 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`, { 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() await refresh()
toast({ text: 'Pages placed into the series', type: 'success' }) toast({ text: 'Pages placed into the series', type: 'success' })
@@ -143,10 +151,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
} }
return { return {
tagId, series, pages, dividers, pending, partGaps, pageCount, tagId, series, pages, dividers, gaps, pending, partGaps, pageCount,
picker, pickerCursor, pickerSelection, loading, picker, pickerCursor, pickerSelection, loading,
load, refresh, dividerAt, partGapAfter, load, refresh, dividerAt, partGapAfter, gapAfter,
reorder, remove, setCover, placePending, setPageNumber, remove, setCover, placePending,
createDivider, renameDivider, setDividerPart, deleteDivider, createDivider, renameDivider, setDividerPart, deleteDivider,
loadPicker, togglePick, addSelected loadPicker, togglePick, addSelected
} }
+209 -99
View File
@@ -25,23 +25,22 @@
</div> </div>
<p class="fc-series__hint"> <p class="fc-series__hint">
One continuous series. <strong>Drag any page</strong> to set its order One continuous series. <strong>Each page's number is yours</strong> — type
the numbers are the page's place in the whole series. Use a page's it on the card; gaps are fine. Drag a page onto a <strong>gap's edge</strong>
<strong> Start a chapter here</strong> to drop a labeled divider before to give it the adjacent number, or onto the end to append. New pages from a
it. New pages from <strong>Add pages</strong> append to the end, ready to post land in the <strong>pending tray</strong> to sort.
be dragged into place.
</p> </p>
<!-- Pending tray: pages staged from a post, grouped by source post, for the <!-- Pending tray: pages staged from a post, grouped by source post. Drop the
operator to drop junk and place into the run. --> junk, set the start page, then place them numbered from there. -->
<div v-if="store.pending.length" class="fc-pending"> <div v-if="store.pending.length" class="fc-pending">
<div class="fc-pending__head"> <div class="fc-pending__head">
<v-icon size="small">mdi-tray-arrow-down</v-icon> <v-icon size="small">mdi-tray-arrow-down</v-icon>
<span>Pending sort into the series</span> <span>Pending sort into the series</span>
</div> </div>
<p class="fc-pending__hint"> <p class="fc-pending__hint">
New pages from a post land here. Drop the ones that don't belong Drop the pages that don't belong (text-free alternates, bumpers), then
(text-free alternates, bumpers), then place the rest into the run. place the rest — they'll be numbered up from the start page you set.
</p> </p>
<section v-for="(grp, gi) in store.pending" :key="gi" class="fc-pgroup"> <section v-for="(grp, gi) in store.pending" :key="gi" class="fc-pgroup">
<header class="fc-pgroup__head"> <header class="fc-pgroup__head">
@@ -50,22 +49,26 @@
{{ grp.post?.title || 'Unknown post' }} {{ grp.post?.title || 'Unknown post' }}
</span> </span>
<span class="fc-pgroup__count">{{ grp.pages.length }} pending</span> <span class="fc-pgroup__count">{{ grp.pages.length }} pending</span>
<label class="fc-pgroup__startfield">
from page
<input
class="fc-pgroup__start" type="number" min="1" placeholder="?"
:value="startDraft[gi] ?? grp.start_page ?? ''"
@input="startDraft[gi] = $event.target.value"
>
</label>
<v-spacer /> <v-spacer />
<v-btn <v-btn
size="small" variant="tonal" color="accent" size="small" variant="tonal" color="accent"
prepend-icon="mdi-playlist-plus" prepend-icon="mdi-playlist-plus"
@click="store.placePending(grp.pages.map(p => p.image_id))" :disabled="effectiveStart(grp, gi) == null"
>Place all into series</v-btn> @click="placeGroup(grp, gi)"
>Place from page {{ effectiveStart(grp, gi) ?? '?' }}</v-btn>
</header> </header>
<div class="fc-pgroup__pages"> <div class="fc-pgroup__pages">
<div v-for="p in grp.pages" :key="p.image_id" class="fc-ppage"> <div v-for="p in grp.pages" :key="p.image_id" class="fc-ppage">
<img :src="p.thumbnail_url" alt="" loading="lazy" /> <img :src="p.thumbnail_url" alt="" loading="lazy" />
<div class="fc-ppage__actions"> <div class="fc-ppage__actions">
<v-btn
size="x-small" variant="flat" icon="mdi-playlist-plus"
title="Place this page into the series"
@click="store.placePending([p.image_id])"
/>
<v-btn <v-btn
size="x-small" variant="flat" icon="mdi-close" size="x-small" variant="flat" icon="mdi-close"
title="Drop — doesn't belong in the series" title="Drop — doesn't belong in the series"
@@ -77,66 +80,58 @@
</section> </section>
</div> </div>
<!-- One continuous page run; chapter dividers render inline before their <!-- One continuous run, ordered by each page's number. Chapter dividers and
anchor page. --> missing-number gap blocks render inline. -->
<div v-if="store.pages.length" class="fc-run"> <div v-if="store.pages.length" class="fc-run">
<template v-for="(p, pi) in store.pages" :key="p.image_id"> <template v-for="p in store.pages" :key="p.image_id">
<!-- chapter divider bar, if one is anchored at this page --> <!-- chapter divider bar, if one is anchored at this page -->
<template v-if="store.dividerAt(p.image_id)"> <div v-if="store.dividerAt(p.image_id)" class="fc-divider">
<div class="fc-divider"> <v-icon size="small" class="fc-divider__icon">mdi-format-section</v-icon>
<v-icon size="small" class="fc-divider__icon">mdi-format-section</v-icon> <label class="fc-divider__partfield" title="Part number for this chapter">
<label class="fc-divider__partfield" title="Part number for this chapter"> <span class="fc-divider__partlabel">Part</span>
<span class="fc-divider__partlabel">Part</span> <input
<input class="fc-divider__partnum" type="number" min="1"
class="fc-divider__partnum" type="number" min="1" :value="partDraft[store.dividerAt(p.image_id).id] ?? ''"
:value="partDraft[store.dividerAt(p.image_id).id] ?? ''" placeholder="—"
placeholder="" @input="partDraft[store.dividerAt(p.image_id).id] = $event.target.value"
@input="partDraft[store.dividerAt(p.image_id).id] = $event.target.value" @keydown.enter.prevent="commitPart(store.dividerAt(p.image_id))"
@keydown.enter.prevent="commitPart(store.dividerAt(p.image_id))" @blur="commitPart(store.dividerAt(p.image_id))"
@blur="commitPart(store.dividerAt(p.image_id))" >
> </label>
</label> <v-text-field
<v-text-field :model-value="titleDraft[store.dividerAt(p.image_id).id]"
:model-value="titleDraft[store.dividerAt(p.image_id).id]" placeholder="Untitled chapter"
placeholder="Untitled chapter" density="compact" variant="plain" hide-details
density="compact" variant="plain" hide-details class="fc-divider__title"
class="fc-divider__title" @update:model-value="titleDraft[store.dividerAt(p.image_id).id] = $event"
@update:model-value="titleDraft[store.dividerAt(p.image_id).id] = $event" @keydown.enter="commitTitle(store.dividerAt(p.image_id))"
@keydown.enter="commitTitle(store.dividerAt(p.image_id))" @blur="commitTitle(store.dividerAt(p.image_id))"
@blur="commitTitle(store.dividerAt(p.image_id))" />
/> <v-spacer />
<v-spacer /> <v-btn
<v-btn size="x-small" variant="text" icon="mdi-close"
size="x-small" variant="text" icon="mdi-close" title="Remove this chapter divider"
title="Remove this chapter divider" @click="store.deleteDivider(store.dividerAt(p.image_id).id)"
@click="store.deleteDivider(store.dividerAt(p.image_id).id)" />
/> </div>
</div>
<div
v-if="store.partGapAfter(store.dividerAt(p.image_id).id)"
class="fc-gap"
>
<v-icon size="x-small">mdi-alert-outline</v-icon>
Missing
<template v-if="gapText(p.image_id).single">
Part {{ gapText(p.image_id).start }}
</template>
<template v-else>
Parts {{ gapText(p.image_id).start }}{{ gapText(p.image_id).end }}
</template>
</div>
</template>
<!-- the page --> <!-- the page -->
<div <div
class="fc-page" draggable="true" class="fc-page" draggable="true"
:class="{ 'fc-page--dragging': drag === pi }" :class="{ 'fc-page--dragging': drag === p.image_id }"
@dragstart="drag = pi" @dragstart="drag = p.image_id"
@dragend="drag = null" @dragend="drag = null"
@dragover.prevent
@drop="onDrop(pi)"
> >
<span class="fc-page__pn">{{ p.page_number }}</span> <label class="fc-page__numfield" title="Page number (gaps allowed)">
<input
class="fc-page__num" type="number" min="1"
:value="numDraft[p.image_id] ?? ''" placeholder="—"
@input="numDraft[p.image_id] = $event.target.value"
@keydown.enter.prevent="commitNum(p)"
@blur="commitNum(p)"
@mousedown.stop
>
</label>
<img :src="p.thumbnail_url" alt="" loading="lazy" /> <img :src="p.thumbnail_url" alt="" loading="lazy" />
<span <span
v-if="p.source_post?.title" class="fc-page__src" v-if="p.source_post?.title" class="fc-page__src"
@@ -161,7 +156,29 @@
/> />
</div> </div>
</div> </div>
<!-- gap block after this page (drop a page on an edge to number it) -->
<div
v-if="store.gapAfter(p.image_id)" class="fc-gapblock"
@dragover.prevent
>
<div
class="fc-gapblock__edge"
@drop="onGapDrop(store.gapAfter(p.image_id).from)"
>← {{ store.gapAfter(p.image_id).from }}</div>
<span class="fc-gapblock__label">{{ gapLabel(store.gapAfter(p.image_id)) }}</span>
<div
class="fc-gapblock__edge"
@drop="onGapDrop(store.gapAfter(p.image_id).to)"
>{{ store.gapAfter(p.image_id).to }} →</div>
</div>
</template> </template>
<!-- append zone: drop here to give the page the next number -->
<div class="fc-append" @dragover.prevent @drop="onAppendDrop">
<v-icon size="small">mdi-tray-plus</v-icon>
drop here to append (page {{ nextNumber }})
</div>
</div> </div>
<div v-else class="fc-series__empty"> <div v-else class="fc-series__empty">
@@ -217,30 +234,37 @@
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js' import { useSeriesManageStore } from '../stores/seriesManage.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js' import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
import TagRenameDialog from '../components/modal/TagRenameDialog.vue' import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
const route = useRoute() const route = useRoute()
const store = useSeriesManageStore() const store = useSeriesManageStore()
const sentinel = ref(null) const sentinel = ref(null)
const drag = ref(null) // index in the flat pages run being dragged const drag = ref(null) // image_id of the page being dragged
const numDraft = reactive({}) // image_id -> draft page number (string)
const titleDraft = reactive({}) // dividerId -> draft title const titleDraft = reactive({}) // dividerId -> draft title
const partDraft = reactive({}) // dividerId -> draft stated_part (string) const partDraft = reactive({}) // dividerId -> draft stated_part (string)
const startDraft = reactive({}) // pending group index -> draft start page (string)
const pickerOpen = ref(false) const pickerOpen = ref(false)
const renameOpen = ref(false) const renameOpen = ref(false)
// A series IS a Tag(kind=series); TagRenameDialog PATCHes /api/tags/<id> and
// handles the same-name collision→merge flow. Reflect the new name in place.
function onRenamed(updated) { function onRenamed(updated) {
renameOpen.value = false renameOpen.value = false
if (store.series && updated?.name) store.series.name = updated.name if (store.series && updated?.name) store.series.name = updated.name
} }
// Keep local divider drafts in sync with loaded dividers; edits commit on // Keep card/divider drafts in sync with loaded data; edits commit on blur/Enter,
// blur/Enter, which refreshes and resets the draft to the saved value. // which refreshes and resets the draft to the saved value.
watch(() => store.pages, (ps) => {
for (const k of Object.keys(numDraft)) delete numDraft[k]
for (const p of ps) {
numDraft[p.image_id] = p.page_number == null ? '' : String(p.page_number)
}
}, { immediate: true })
watch(() => store.dividers, (divs) => { watch(() => store.dividers, (divs) => {
for (const k of Object.keys(titleDraft)) delete titleDraft[k] for (const k of Object.keys(titleDraft)) delete titleDraft[k]
for (const k of Object.keys(partDraft)) delete partDraft[k] for (const k of Object.keys(partDraft)) delete partDraft[k]
@@ -250,6 +274,46 @@ watch(() => store.dividers, (divs) => {
} }
}, { immediate: true }) }, { immediate: true })
// Next number = one past the highest assigned page number (1 if none).
const nextNumber = computed(() => {
let max = 0
for (const p of store.pages) {
if (p.page_number != null && p.page_number > max) max = p.page_number
}
return max + 1
})
function commitNum(p) {
const raw = (numDraft[p.image_id] ?? '').trim()
const next = raw === '' ? null : parseInt(raw, 10)
if (raw !== '' && (Number.isNaN(next) || next < 1)) {
numDraft[p.image_id] = p.page_number == null ? '' : String(p.page_number)
return
}
if (next === (p.page_number ?? null)) return
store.setPageNumber(p.image_id, next)
}
function onGapDrop(number) {
if (drag.value == null) return
const id = drag.value
drag.value = null
store.setPageNumber(id, number)
}
function onAppendDrop() {
if (drag.value == null) return
const id = drag.value
drag.value = null
store.setPageNumber(id, nextNumber.value)
}
function gapLabel(g) {
return g.from === g.to
? `page ${g.from} missing`
: `pages ${g.from}${g.to} missing`
}
function commitTitle(d) { function commitTitle(d) {
const v = (titleDraft[d.id] || '').trim() const v = (titleDraft[d.id] || '').trim()
if (v === (d.title || '')) return if (v === (d.title || '')) return
@@ -267,19 +331,21 @@ function commitPart(d) {
store.setDividerPart(d.id, next) store.setDividerPart(d.id, next)
} }
function gapText(anchorImageId) { // Pending: effective start page = the typed override, else the post's parsed
const d = store.dividerAt(anchorImageId) // start. null when neither is set (operator must type one).
const g = d ? store.partGapAfter(d.id) : null function effectiveStart(grp, gi) {
return { start: g?.start, end: g?.end, single: g && g.start === g.end } const raw = (startDraft[gi] ?? '').toString().trim()
if (raw !== '') {
const n = parseInt(raw, 10)
return Number.isNaN(n) || n < 1 ? null : n
}
return grp.start_page ?? null
} }
function onDrop(toIdx) { function placeGroup(grp, gi) {
if (drag.value === null || drag.value === toIdx) { drag.value = null; return } const start = effectiveStart(grp, gi)
const ordered = moveItem( if (start == null) return
store.pages.map(p => p.image_id), drag.value, toIdx store.placePending(grp.pages.map(p => p.image_id), start)
)
drag.value = null
store.reorder(ordered)
} }
useInfiniteScroll(sentinel, () => store.loadPicker()) useInfiniteScroll(sentinel, () => store.loadPicker())
@@ -303,7 +369,7 @@ onMounted(async () => {
margin-bottom: 18px; max-width: 820px; margin-bottom: 18px; max-width: 820px;
} }
/* One continuous page run with inline divider bars. */ /* One continuous run with inline dividers + gap blocks. */
.fc-run { .fc-run {
display: grid; gap: 10px; max-width: 1100px; display: grid; gap: 10px; max-width: 1100px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
@@ -339,7 +405,38 @@ onMounted(async () => {
} }
.fc-divider__title { flex: 1 1 200px; min-width: 120px; font-size: 15px; } .fc-divider__title { flex: 1 1 200px; min-width: 120px; font-size: 15px; }
/* Big page tiles */ /* Gap block — one per gap; drop a page on an edge to number it. */
.fc-gapblock {
grid-column: 1 / -1;
display: flex; align-items: stretch; gap: 8px;
padding: 4px; border: 1px dashed rgb(var(--v-theme-on-surface-variant), 0.5);
border-radius: 8px;
}
.fc-gapblock__edge {
display: flex; align-items: center; justify-content: center;
min-width: 80px; padding: 6px 10px; border-radius: 6px;
font-variant-numeric: tabular-nums; font-size: 13px;
background: rgb(var(--v-theme-surface-light));
color: rgb(var(--v-theme-on-surface-variant)); cursor: copy;
}
.fc-gapblock__edge:hover {
background: rgb(var(--v-theme-accent), 0.2);
color: rgb(var(--v-theme-accent));
}
.fc-gapblock__label {
flex: 1; display: flex; align-items: center; justify-content: center;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-append {
grid-column: 1 / -1;
display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 12px; border: 1px dashed rgb(var(--v-theme-on-surface-variant), 0.4);
border-radius: 8px; font-size: 13px;
color: rgb(var(--v-theme-on-surface-variant));
}
/* Page tiles */
.fc-page { .fc-page {
position: relative; border-radius: 8px; overflow: hidden; cursor: grab; position: relative; border-radius: 8px; overflow: hidden; cursor: grab;
background: rgb(var(--v-theme-surface-light)); background: rgb(var(--v-theme-surface-light));
@@ -350,13 +447,19 @@ onMounted(async () => {
width: 100%; aspect-ratio: 3 / 4; object-fit: contain; width: 100%; aspect-ratio: 3 / 4; object-fit: contain;
display: block; background: rgb(var(--v-theme-background)); display: block; background: rgb(var(--v-theme-background));
} }
.fc-page__pn { .fc-page__numfield {
position: absolute; top: 6px; left: 6px; z-index: 2; position: absolute; top: 6px; left: 6px; z-index: 2;
min-width: 24px; height: 24px; padding: 0 6px; border-radius: 12px; background: rgb(var(--v-theme-accent)); border-radius: 12px;
display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; height: 24px; display: flex; align-items: center;
font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
background: rgb(var(--v-theme-accent)); color: rgb(var(--v-theme-on-accent, 0 0 0));
} }
.fc-page__num {
width: 34px; height: 22px; text-align: center;
font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
background: transparent; border: none;
color: rgb(var(--v-theme-on-accent, 0 0 0));
}
.fc-page__num:focus { outline: none; }
.fc-page__num::placeholder { color: rgb(var(--v-theme-on-accent, 0 0 0)); opacity: 0.5; }
.fc-page__src { .fc-page__src {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
display: flex; align-items: center; gap: 4px; padding: 3px 6px; display: flex; align-items: center; gap: 4px; padding: 3px 6px;
@@ -372,11 +475,6 @@ onMounted(async () => {
background: rgba(0, 0, 0, 0.55); color: #fff; background: rgba(0, 0, 0, 0.55); color: #fff;
} }
.fc-gap {
grid-column: 1 / -1;
display: flex; align-items: center; gap: 6px; padding: 2px 10px;
font-size: 12px; color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
}
.fc-series__empty { .fc-series__empty {
padding: 40px; text-align: center; color: rgb(var(--v-theme-on-surface-variant)); padding: 40px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
max-width: 1100px; max-width: 1100px;
@@ -401,15 +499,27 @@ onMounted(async () => {
.fc-pgroup { margin-bottom: 12px; } .fc-pgroup { margin-bottom: 12px; }
.fc-pgroup__head { .fc-pgroup__head {
display: flex; align-items: center; gap: 10px; margin-bottom: 6px; display: flex; align-items: center; gap: 10px; margin-bottom: 6px;
flex-wrap: wrap;
} }
.fc-pgroup__title { .fc-pgroup__title {
display: inline-flex; align-items: center; gap: 4px; max-width: 360px; display: inline-flex; align-items: center; gap: 4px; max-width: 320px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 13px; font-weight: 600; font-size: 13px; font-weight: 600;
} }
.fc-pgroup__count { .fc-pgroup__count {
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant)); font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
} }
.fc-pgroup__startfield {
display: inline-flex; align-items: center; gap: 4px;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-pgroup__start {
width: 56px; text-align: center; font-size: 13px;
background: rgb(var(--v-theme-surface-light));
border: 1px solid transparent; border-radius: 4px; padding: 2px 4px;
color: rgb(var(--v-theme-on-surface));
}
.fc-pgroup__start:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
.fc-pgroup__pages { .fc-pgroup__pages {
display: grid; gap: 8px; display: grid; gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
+17 -6
View File
@@ -42,19 +42,30 @@ describe('seriesManage', () => {
expect(s.pageCount).toBe(1) expect(s.pageCount).toBe(1)
}) })
it('reorder posts ordered ids to the series reorder route', async () => { it('setPageNumber posts the image + number to /pages/number', async () => {
const s = useSeriesManageStore() const s = useSeriesManageStore()
s.tagId = 7 s.tagId = 7
const calls = [] const calls = []
stubFetch((url, init) => { stubFetch((url, init) => {
calls.push({ url, body: init.body ? JSON.parse(init.body) : null }) calls.push({ url, body: init.body ? JSON.parse(init.body) : null })
if (url.includes('/reorder')) return { status: 200, body: { ok: true } } if (url.includes('/pages/number')) return { status: 200, body: { ok: true } }
return { status: 200, body: SERIES_BODY } return { status: 200, body: SERIES_BODY }
}) })
await s.reorder([30, 10, 20]) await s.setPageNumber(42, 5)
const r = calls.find(c => c.url.includes('/reorder')) const r = calls.find(c => c.url.includes('/pages/number'))
expect(r.url).toContain('/api/series/7/reorder') expect(r.url).toContain('/api/series/7/pages/number')
expect(r.body).toEqual({ image_ids: [30, 10, 20] }) expect(r.body).toEqual({ image_id: 42, page_number: 5 })
})
it('gapAfter looks up a gap by its after_image_id', async () => {
const s = useSeriesManageStore()
stubFetch(() => ({
status: 200,
body: { ...SERIES_BODY, gaps: [{ after_image_id: 1, before_image_id: 9, from: 2, to: 3 }] }
}))
await s.load(7)
expect(s.gapAfter(1)).toEqual({ after_image_id: 1, before_image_id: 9, from: 2, to: 3 })
expect(s.gapAfter(99)).toBeNull()
}) })
it('createDivider posts the anchor image + labels', async () => { it('createDivider posts the anchor image + labels', async () => {
+3 -3
View File
@@ -44,10 +44,10 @@ async def test_add_over_cap_400(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reorder_mismatch_400(client): async def test_set_page_number_requires_image_id(client):
sid = await _series(client, "Re") sid = await _series(client, "Num")
resp = await client.post( resp = await client.post(
f"/api/series/{sid}/reorder", json={"image_ids": [1, 2, 3]} f"/api/series/{sid}/pages/number", json={"page_number": 5}
) )
assert resp.status_code == 400 assert resp.status_code == 400
+11 -7
View File
@@ -76,25 +76,29 @@ async def test_place_pending_appends_and_flips_status(db):
imgs = await _post_images(db, post, artist, 3) imgs = await _post_images(db, post, artist, 3)
await svc.add_post(sid, post.id) await svc.add_post(sid, post.id)
placed = await svc.place_pending(sid, imgs) placed = await svc.place_pending(sid, imgs, start_page=3)
assert placed == 3 assert placed == 3
assert await _placed_order(db, sid) == seed + imgs assert await _placed_order(db, sid) == seed + imgs
# nothing left pending; page numbers compact 1..5 # nothing left pending; the placed pages are numbered 1,2 (seed) + 3,4,5
data = await svc.list_pages(sid) data = await svc.list_pages(sid)
assert data["pending"] == [] assert data["pending"] == []
assert [p["page_number"] for p in data["pages"]] == [1, 2, 3, 4, 5] assert [p["page_number"] for p in data["pages"]] == [1, 2, 3, 4, 5]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_place_pending_before_a_placed_page(db): async def test_place_pending_numbers_from_start(db):
svc, artist, sid, seed = await _setup(db, "PB") svc, artist, sid, seed = await _setup(db, "PB") # seed numbered 1, 2
a, b = seed
post = await _post(db, artist, "PB pages", "pb-p") post = await _post(db, artist, "PB pages", "pb-p")
x, y = await _post_images(db, post, artist, 2) x, y = await _post_images(db, post, artist, 2)
await svc.add_post(sid, post.id) await svc.add_post(sid, post.id)
await svc.place_pending(sid, [x, y], before_image_id=b) await svc.place_pending(sid, [x, y], start_page=10)
assert await _placed_order(db, sid) == [a, x, y, b] data = await svc.list_pages(sid)
nums = {p["image_id"]: p["page_number"] for p in data["pages"]}
assert nums[x] == 10
assert nums[y] == 11
# order = seed (1, 2) then x (10), y (11)
assert [p["image_id"] for p in data["pages"]] == seed + [x, y]
@pytest.mark.asyncio @pytest.mark.asyncio
+14 -7
View File
@@ -84,17 +84,24 @@ async def test_remove(db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reorder_rewrites_and_rejects_mismatch(db): async def test_set_page_number_sparse_with_gaps(db):
s = await TagService(db).find_or_create("S4", TagKind.series) s = await TagService(db).find_or_create("S4", TagKind.series)
i1, i2, i3 = await _img(db), await _img(db), await _img(db) i1, i2, i3 = await _img(db), await _img(db), await _img(db)
svc = SeriesService(db) svc = SeriesService(db)
await svc.add_images(s.id, [i1, i2, i3]) await svc.add_images(s.id, [i1, i2, i3]) # numbered 1, 2, 3
await svc.reorder(s.id, [i3, i1, i2]) # set a sparse number, leaving a gap
assert await _order(db, s.id) == [i3, i1, i2] await svc.set_page_number(s.id, i3, 5)
out = await svc.list_pages(s.id)
assert [p["page_number"] for p in out["pages"]] == [1, 2, 5]
assert len(out["gaps"]) == 1
assert out["gaps"][0]["from"] == 3
assert out["gaps"][0]["to"] == 4
# unnumber a page → it sorts to the tail
await svc.set_page_number(s.id, i1, None)
out = await svc.list_pages(s.id)
assert [p["page_number"] for p in out["pages"]] == [2, 5, None]
with pytest.raises(SeriesError): with pytest.raises(SeriesError):
await svc.reorder(s.id, [i1, i2]) await svc.set_page_number(s.id, 99999, 1)
with pytest.raises(SeriesError):
await svc.reorder(s.id, [i1, i2, i3, 99999])
@pytest.mark.asyncio @pytest.mark.asyncio