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>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""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 9–12", "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
|
||||
Reference in New Issue
Block a user