feat(series): chapter layer over series_page — backend (FC-6.1)
Adds an ordered chapter layer to series. Reading order becomes (series_chapter.chapter_number, series_page.page_number); a chapter may be a placeholder reserving a slot, and carries an optional parsed stated-page range used to flag missing-page gaps. An image still lives in at most one series ⇒ one chapter (image_id stays UNIQUE). - models: series_chapter; series_page gains chapter_id (NOT NULL, cascade) + stated_page. Migration 0040 backfills every existing series into one auto-chapter holding its current flat pages — no data loss. - SeriesService: chapter CRUD (create/update/reorder/delete/merge), page→chapter assignment, reorder_pages, chapter-aware set_cover; list_pages now returns chapters[] + gaps[] alongside a back-compat flat pages[]. Legacy series-wide reorder operates on the single default chapter and rejects multi-chapter series. - API: chapter endpoints under /api/series/<tag>/chapters; POST pages accepts an optional chapter_id. - TagService.merge now repoints series_chapter too, so a merged series' chapters (and their pages) survive the source tag's deletion instead of cascading away. - Tests: new chapter suite; updated the 4 direct SeriesPage(...) constructions to supply chapter_id. Frontend (chapter-aware manage view + reader) lands next; until then the existing UI keeps working via the flat pages[] + single default chapter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+159
-1
@@ -368,6 +368,31 @@ def _series_err(exc: SeriesError):
|
||||
return jsonify({"error": msg}), status
|
||||
|
||||
|
||||
def _opt_int(body, key: str):
|
||||
"""(value, error) — value is None when absent, error is (json, status)."""
|
||||
if not body or body.get(key) is None:
|
||||
return None, None
|
||||
try:
|
||||
return int(body[key]), None
|
||||
except (TypeError, ValueError):
|
||||
return None, (jsonify({"error": f"{key} must be an integer"}), 400)
|
||||
|
||||
|
||||
def _parse_int_list(body, key: str, *, max_ids: int = 500):
|
||||
"""(list, error) for a required list of ints under `key`."""
|
||||
if not body or key not in body:
|
||||
return None, (jsonify({"error": f"{key} required"}), 400)
|
||||
raw = body[key]
|
||||
if not isinstance(raw, list) or not raw:
|
||||
return None, (jsonify({"error": f"{key} must be a non-empty list"}), 400)
|
||||
if len(raw) > max_ids:
|
||||
return None, (jsonify({"error": f"too many ids (max {max_ids})"}), 400)
|
||||
try:
|
||||
return [int(x) for x in raw], None
|
||||
except (TypeError, ValueError):
|
||||
return None, (jsonify({"error": f"{key} must be integers"}), 400)
|
||||
|
||||
|
||||
@tags_bp.route("/series/<int:tag_id>/pages", methods=["GET"])
|
||||
async def series_pages(tag_id: int):
|
||||
async with get_session() as session:
|
||||
@@ -384,9 +409,14 @@ async def series_add(tag_id: int):
|
||||
ids, err = _parse_bulk_ids(body, max_ids=500)
|
||||
if err:
|
||||
return err
|
||||
chapter_id, cerr = _opt_int(body, "chapter_id")
|
||||
if cerr:
|
||||
return cerr
|
||||
async with get_session() as session:
|
||||
try:
|
||||
n = await SeriesService(session).add_images(tag_id, ids)
|
||||
n = await SeriesService(session).add_images(
|
||||
tag_id, ids, chapter_id=chapter_id
|
||||
)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
@@ -439,3 +469,131 @@ async def series_cover(tag_id: int):
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ---- chapters (FC-6.1) ----------------------------------------------------
|
||||
|
||||
|
||||
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
|
||||
async def series_chapter_create(tag_id: int):
|
||||
body = await request.get_json() or {}
|
||||
title = body.get("title")
|
||||
if title is not None and not isinstance(title, str):
|
||||
return jsonify({"error": "title must be a string"}), 400
|
||||
is_placeholder = bool(body.get("is_placeholder", False))
|
||||
start, serr = _opt_int(body, "stated_page_start")
|
||||
if serr:
|
||||
return serr
|
||||
end, eerr = _opt_int(body, "stated_page_end")
|
||||
if eerr:
|
||||
return eerr
|
||||
async with get_session() as session:
|
||||
try:
|
||||
ch = await SeriesService(session).create_chapter(
|
||||
tag_id,
|
||||
title=title,
|
||||
is_placeholder=is_placeholder,
|
||||
stated_page_start=start,
|
||||
stated_page_end=end,
|
||||
)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
return jsonify(ch)
|
||||
|
||||
|
||||
@tags_bp.route("/series/<int:tag_id>/chapters/reorder", methods=["POST"])
|
||||
async def series_chapter_reorder(tag_id: int):
|
||||
body = await request.get_json()
|
||||
ids, err = _parse_int_list(body, "chapter_ids")
|
||||
if err:
|
||||
return err
|
||||
async with get_session() as session:
|
||||
try:
|
||||
await SeriesService(session).reorder_chapters(tag_id, ids)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@tags_bp.route(
|
||||
"/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["PATCH"]
|
||||
)
|
||||
async def series_chapter_update(tag_id: int, chapter_id: int):
|
||||
body = await request.get_json() or {}
|
||||
kwargs: dict = {}
|
||||
if "title" in body:
|
||||
if body["title"] is not None and not isinstance(body["title"], str):
|
||||
return jsonify({"error": "title must be a string"}), 400
|
||||
kwargs.update(set_title=True, title=body["title"])
|
||||
if "stated_page_start" in body:
|
||||
start, serr = _opt_int(body, "stated_page_start")
|
||||
if serr:
|
||||
return serr
|
||||
kwargs.update(set_start=True, stated_page_start=start)
|
||||
if "stated_page_end" in body:
|
||||
end, eerr = _opt_int(body, "stated_page_end")
|
||||
if eerr:
|
||||
return eerr
|
||||
kwargs.update(set_end=True, stated_page_end=end)
|
||||
async with get_session() as session:
|
||||
try:
|
||||
await SeriesService(session).update_chapter(
|
||||
tag_id, chapter_id, **kwargs
|
||||
)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@tags_bp.route(
|
||||
"/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["DELETE"]
|
||||
)
|
||||
async def series_chapter_delete(tag_id: int, chapter_id: int):
|
||||
async with get_session() as session:
|
||||
try:
|
||||
await SeriesService(session).delete_chapter(tag_id, chapter_id)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@tags_bp.route(
|
||||
"/series/<int:tag_id>/chapters/<int:chapter_id>/merge", methods=["POST"]
|
||||
)
|
||||
async def series_chapter_merge(tag_id: int, chapter_id: int):
|
||||
body = await request.get_json()
|
||||
target, terr = _opt_int(body, "target_chapter_id")
|
||||
if terr:
|
||||
return terr
|
||||
if target is None:
|
||||
return jsonify({"error": "target_chapter_id required"}), 400
|
||||
async with get_session() as session:
|
||||
try:
|
||||
moved = await SeriesService(session).merge_chapter(
|
||||
tag_id, chapter_id, target
|
||||
)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
return jsonify({"moved_count": moved})
|
||||
|
||||
|
||||
@tags_bp.route(
|
||||
"/series/<int:tag_id>/chapters/<int:chapter_id>/reorder", methods=["POST"]
|
||||
)
|
||||
async def series_chapter_reorder_pages(tag_id: int, chapter_id: int):
|
||||
body = await request.get_json()
|
||||
ids, err = _parse_bulk_ids(body, max_ids=500)
|
||||
if err:
|
||||
return err
|
||||
async with get_session() as session:
|
||||
try:
|
||||
await SeriesService(session).reorder_pages(tag_id, chapter_id, ids)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
Reference in New Issue
Block a user