Browse hub, series rename, full-prediction dropdown + a DRY pass (7 sweeps) #90

Merged
bvandeusen merged 10 commits from dev into main 2026-06-10 00:24:01 -04:00
4 changed files with 38 additions and 40 deletions
Showing only changes of commit 074c5868fb - Show all commits
+2 -1
View File
@@ -24,7 +24,8 @@ from ..models import (
from ..models.tag import image_tag from ..models.tag import image_tag
from ..utils.slug import slugify from ..utils.slug import slugify
from .db_helpers import get_or_create from .db_helpers import get_or_create
from .gallery_service import decode_cursor, encode_cursor, thumbnail_url from .gallery_service import thumbnail_url
from .pagination import decode_cursor, encode_cursor
@dataclass(frozen=True) @dataclass(frozen=True)
+1 -17
View File
@@ -14,7 +14,6 @@ Decoding rejects malformed cursors with a ValueError; the API layer
translates that to HTTP 400. translates that to HTTP 400.
""" """
import base64
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -24,8 +23,7 @@ from sqlalchemy.orm import aliased
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
from ..models.tag import image_tag from ..models.tag import image_tag
from .pagination import decode_cursor, encode_cursor
CURSOR_SEPARATOR = "|"
# Reserved `platform` filter value selecting images with NO platformed # Reserved `platform` filter value selecting images with NO platformed
# provenance (filesystem imports). Returned by facets() as a null-valued # provenance (filesystem imports). Returned by facets() as a null-valued
@@ -35,20 +33,6 @@ CURSOR_SEPARATOR = "|"
UNSOURCED_PLATFORM = "__unsourced__" UNSOURCED_PLATFORM = "__unsourced__"
def encode_cursor(effective_date: datetime, image_id: int) -> str:
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1)
return datetime.fromisoformat(ts_part), int(id_part)
except Exception as exc:
raise ValueError(f"invalid cursor: {cursor!r}") from exc
def _effective_date_col(): def _effective_date_col():
"""The materialized gallery sort key: image_record.effective_date """The materialized gallery sort key: image_record.effective_date
(alembic 0035) = COALESCE(primary post's post_date, created_at), (alembic 0035) = COALESCE(primary post's post_date, created_at),
+31
View File
@@ -0,0 +1,31 @@
"""Shared opaque keyset-pagination cursor.
A cursor is base64(``<iso8601_sort_key>|<row_id>``). The sort key is whatever
DESC-ordered timestamp a feed paginates on (gallery: effective_date; posts:
COALESCE(post_date, downloaded_at); artists: created_at). `decode_cursor` rejects
a malformed cursor with ValueError, which the API layer maps to HTTP 400.
This was hand-rolled identically in gallery_service and post_feed_service (with
artist_service importing gallery's copy). Two divergent copies of a cursor format
silently break pagination in whichever feed drifts, so it lives once here now
(DRY pattern sweep 2026-06-10).
"""
import base64
from datetime import datetime
CURSOR_SEPARATOR = "|"
def encode_cursor(sort_key: datetime, row_id: int) -> str:
raw = f"{sort_key.isoformat()}{CURSOR_SEPARATOR}{row_id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1)
return datetime.fromisoformat(ts_part), int(id_part)
except Exception as exc:
raise ValueError(f"invalid cursor: {cursor!r}") from exc
+4 -22
View File
@@ -1,9 +1,8 @@
"""FC-3e: cursor-paginated read service for the Posts stream. """FC-3e: cursor-paginated read service for the Posts stream.
Mirrors GalleryService.scroll's cursor encoding so the frontend pattern Uses the shared `pagination` cursor (base64 of "<iso8601_sort_key>|<id>") so
is identical: base64 of "<iso8601_sort_key>|<post_id>". Sort key is every feed paginates identically. Sort key here is COALESCE(Post.post_date,
COALESCE(Post.post_date, Post.downloaded_at) so posts without a Post.downloaded_at) so posts without a publish date sort by when we captured them.
publish date sort by when we captured them.
Pure read-surface; no writes. The service composes the post dict Pure read-surface; no writes. The service composes the post dict
(thumbnails from every image linked to the post — its own primary images (thumbnails from every image linked to the post — its own primary images
@@ -12,9 +11,6 @@ attachments from PostAttachment) so the API layer can jsonify directly.
""" """
from __future__ import annotations from __future__ import annotations
import base64
from datetime import datetime
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,26 +24,12 @@ from ..models import (
) )
from ..utils.text import html_to_plain, truncate_at_word from ..utils.text import html_to_plain, truncate_at_word
from .gallery_service import thumbnail_url from .gallery_service import thumbnail_url
from .pagination import decode_cursor, encode_cursor
CURSOR_SEPARATOR = "|"
DESCRIPTION_LIMIT = 280 DESCRIPTION_LIMIT = 280
THUMBNAIL_LIMIT = 6 THUMBNAIL_LIMIT = 6
def encode_cursor(sort_key: datetime, post_id: int) -> str:
raw = f"{sort_key.isoformat()}{CURSOR_SEPARATOR}{post_id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1)
return datetime.fromisoformat(ts_part), int(id_part)
except Exception as exc:
raise ValueError(f"invalid cursor: {cursor!r}") from exc
def _sort_key(): def _sort_key():
"""Postgres COALESCE expression used in ORDER BY and WHERE clauses.""" """Postgres COALESCE expression used in ORDER BY and WHERE clauses."""
return func.coalesce(Post.post_date, Post.downloaded_at) return func.coalesce(Post.post_date, Post.downloaded_at)