feat(series): flat series sequence + cosmetic chapter dividers (#789 Phase 1)
Reframe a series from "ordered chapters that own pages" to ONE flat, series-global ordered run of pages with optional cosmetic chapter DIVIDERS over it. A chapter no longer wraps content — it's a labeled divider anchored to the page that begins it; a page's chapter is derived as the nearest preceding divider. This is what lets installments assembled from multiple sources sit in one continuous, correctly-numbered sequence (operator's Goblin Juice case). - migration 0047: flatten each series to a series-global page_number (preserving today's reading order); convert each existing chapter to a divider anchored at its first page (keeping title/stated_part); drop series_page.chapter_id; reshape series_chapter (anchor_page_id UNIQUE FK, drop chapter_number/is_placeholder/stated_page_start/end). Loss-safe for content; drops empty placeholder chapters + a redundant page-1 divider. - series_page: page_number is now the series-global order; no chapter_id. - series_chapter: anchored divider (anchor_page_id, title, stated_part). - series_service: flat list_pages (one run + derived dividers + per-page source_post + part_gaps), series-wide reorder/renumber, divider CRUD (create/update/move/delete); retired per-chapter reorder/merge/placement. - api/tags: drop chapter_id from add; /chapters endpoints are divider create/update/delete (removed chapter reorder/merge/page-reorder). - series_match_service: series "end" reads max(series_page.stated_page); accept appends via add_post. tag_service series-merge appends src's pages after tgt's max so the merged series stays one clean run. - frontend: seriesManage store + SeriesManageView → one continuous drag-reorder grid with inline divider bars + series-global page numbers; reader walks the flat run, headings from dividers; PostSeriesMenu copy. - tests reworked across the series suite for the divider model. Phase 2 (pending staging for add-from-post) is separate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+27
-86
@@ -391,14 +391,9 @@ 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, chapter_id=chapter_id
|
||||
)
|
||||
n = await SeriesService(session).add_images(tag_id, ids)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
@@ -453,30 +448,30 @@ async def series_cover(tag_id: int):
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ---- chapters (FC-6.1) ----------------------------------------------------
|
||||
# ---- 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.
|
||||
|
||||
|
||||
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
|
||||
async def series_chapter_create(tag_id: int):
|
||||
body = await request.get_json() or {}
|
||||
anchor, aerr = _opt_int(body, "anchor_image_id")
|
||||
if aerr:
|
||||
return aerr
|
||||
if anchor is None:
|
||||
return jsonify({"error": "anchor_image_id required"}), 400
|
||||
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
|
||||
part, perr = _opt_int(body, "stated_part")
|
||||
if perr:
|
||||
return perr
|
||||
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,
|
||||
ch = await SeriesService(session).create_divider(
|
||||
tag_id, anchor, title=title, stated_part=part,
|
||||
)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
@@ -484,21 +479,6 @@ async def series_chapter_create(tag_id: int):
|
||||
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"]
|
||||
)
|
||||
@@ -514,19 +494,18 @@ async def series_chapter_update(tag_id: int, chapter_id: int):
|
||||
if perr:
|
||||
return perr
|
||||
kwargs.update(set_part=True, stated_part=part)
|
||||
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)
|
||||
if "anchor_image_id" in body:
|
||||
anchor, aerr = _opt_int(body, "anchor_image_id")
|
||||
if aerr:
|
||||
return aerr
|
||||
if anchor is None:
|
||||
return jsonify(
|
||||
{"error": "anchor_image_id must be an integer"}
|
||||
), 400
|
||||
kwargs.update(set_anchor=True, anchor_image_id=anchor)
|
||||
async with get_session() as session:
|
||||
try:
|
||||
await SeriesService(session).update_chapter(
|
||||
await SeriesService(session).update_divider(
|
||||
tag_id, chapter_id, **kwargs
|
||||
)
|
||||
except SeriesError as exc:
|
||||
@@ -541,45 +520,7 @@ async def series_chapter_update(tag_id: int, chapter_id: int):
|
||||
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)
|
||||
await SeriesService(session).delete_divider(tag_id, chapter_id)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
@@ -635,7 +576,7 @@ async def series_add_post(tag_id: int):
|
||||
return jsonify({"error": "post_id required"}), 400
|
||||
async with get_session() as session:
|
||||
try:
|
||||
out = await SeriesService(session).add_post_as_chapter(tag_id, post_id)
|
||||
out = await SeriesService(session).add_post(tag_id, post_id)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
|
||||
Reference in New Issue
Block a user