Files
FabledCurator/backend/app/services/page_number_parser.py
T
bvandeusen db490e92df
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m4s
feat(series): post→series flows + browse list — backend (FC-6.2)
The post-aware on-ramp + the data behind the missing Series browse view.

- page_number_parser: conservative stated-page parser (pages 9-12 / page 5 /
  [3/8] / 3 of 8), keyword-gated to avoid false positives. Pure + unit-tested.
- SeriesService.promote_post_to_series: a self-contained post becomes its own
  series — series tag named after the post, one chapter, the post's images as
  pages (ordered by capture order; stated pages parsed from title/description).
- SeriesService.add_post_as_chapter: append a post as the next chapter of an
  existing series, titled after the post and slotted by parsed page number
  (a "pages 1-4" post lands ahead of the "pages 9-12" chapter).
- SeriesService.list_series: browse cards — cover thumb, artist, chapter/page
  counts, gap flag, last-updated; sort recent|name|size + filter by artist.
- API: GET /api/series, POST /api/series/from-post, POST /api/series/<id>/add-post.
- Resolver uses ImageRecord.primary_post_id (same linkage the posts feed renders).

Frontend (Add-to-series control + Series view + nav) lands next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:34:11 -04:00

42 lines
1.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Parse a stated page number/range out of a post's title/description (FC-6.2).
Artists often state where an installment sits in a series — "pages 9-12",
"Page 5", "[3/8]". We use that to order chapters and flag missing-page gaps.
This is best-effort: a confident match wins, otherwise we return None and the
caller falls back to capture/post-date order. Keep it conservative — a wrong
page number is worse than no page number — so matches require an explicit
page keyword (page/pg/pp) or a bracketed N/M fraction, never a bare number.
Supported forms (case-insensitive):
range "pages 9-12", "pg 912", "pp. 9 - 12" -> (9, 12)
fraction "page 3 of 8", "pg 3/8", "[3/8]", "(3/8)" -> (3, 3)
single "page 5", "pg 5", "pp 5" -> (5, 5)
"""
import re
# Page keyword: page/pages/pg/pgs/pp/pp. (NOT a bare "p" — too many false hits.)
_KW = r"(?:pages?|pgs?|pp\.?)"
_DASH = r"[-–—]"
_RANGE = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\s*{_DASH}\s*(\d{{1,4}})", re.I)
_OF = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\s*(?:of|/)\s*\d{{1,4}}\b", re.I)
_BRACKET = re.compile(r"[\[(]\s*(\d{1,4})\s*/\s*\d{1,4}\s*[\])]")
_SINGLE = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\b", re.I)
def parse_page_range(text: str | None) -> tuple[int, int] | None:
"""Return (start, end) or None. start <= end; a single page yields (n, n)."""
if not text:
return None
m = _RANGE.search(text)
if m:
a, b = int(m.group(1)), int(m.group(2))
return (a, b) if a <= b else (b, a)
for rx in (_OF, _BRACKET, _SINGLE):
m = rx.search(text)
if m:
n = int(m.group(1))
return (n, n)
return None