Merge pull request 'Gallery speed + fandom editing + filters + pinned filter bar' (#61) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 11s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m29s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 11s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m29s
This commit was merged in pull request #61.
This commit is contained in:
@@ -61,6 +61,9 @@ Thumbs.db
|
||||
|
||||
# Claude Code per-user local overrides (shared .claude/settings.json is OK to commit)
|
||||
.claude/settings.local.json
|
||||
# Transient scheduler lock/state (committed by accident in 3f30327)
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/scheduled_tasks*.json
|
||||
|
||||
# Alembic / DB scratch
|
||||
alembic/versions/__pycache__/
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""image_record.effective_date: materialized gallery sort key + index
|
||||
|
||||
Revision ID: 0035
|
||||
Revises: 0034
|
||||
Create Date: 2026-06-04
|
||||
|
||||
The gallery ordered/cursored on COALESCE(post.post_date,
|
||||
image_record.created_at) across the Post outer join. That expression spans
|
||||
two tables, so no index can serve it — every /scroll sorted a large slice
|
||||
of the library, and the frontend fired ten of them serially per initial
|
||||
load. Materialize the value into image_record.effective_date and index
|
||||
(effective_date DESC, id DESC) so the cursor scroll is an index range scan.
|
||||
|
||||
Backfill = COALESCE(primary post's post_date, created_at) so existing rows
|
||||
keep their exact ordering. New rows get the created_at-equivalent server
|
||||
default; services/importer.py overrides it with the post's date when a
|
||||
primary post with a date is linked.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0035"
|
||||
down_revision: Union[str, None] = "0034"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add nullable first so the backfill can populate before NOT NULL.
|
||||
op.add_column(
|
||||
"image_record",
|
||||
sa.Column("effective_date", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# Pure set-based UPDATEs (no per-row params) — immune to the 65535
|
||||
# bind-parameter ceiling regardless of library size.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE image_record AS ir
|
||||
SET effective_date = COALESCE(p.post_date, ir.created_at)
|
||||
FROM post AS p
|
||||
WHERE ir.primary_post_id = p.id
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE image_record
|
||||
SET effective_date = created_at
|
||||
WHERE effective_date IS NULL
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"image_record",
|
||||
"effective_date",
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
)
|
||||
# DESC/DESC matches the gallery's ORDER BY effective_date DESC, id DESC
|
||||
# so the scroll is a forward index scan; raw SQL because alembic's
|
||||
# column list doesn't express per-column DESC cleanly.
|
||||
op.execute(
|
||||
"CREATE INDEX ix_image_record_effective_date "
|
||||
"ON image_record (effective_date DESC, id DESC)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_image_record_effective_date", table_name="image_record")
|
||||
op.drop_column("image_record", "effective_date")
|
||||
+33
-22
@@ -8,26 +8,42 @@ from ..services.gallery_service import GalleryService
|
||||
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
|
||||
|
||||
|
||||
def _parse_filters():
|
||||
"""Parse the composable gallery filters from query args. Raises
|
||||
ValueError (→ 400) on malformed ids. `tag_id` accepts a single id or a
|
||||
comma-separated list (AND); `media` is image|video; `sort` is
|
||||
newest|oldest."""
|
||||
tag_raw = request.args.get("tag_id")
|
||||
tag_ids = (
|
||||
[int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None
|
||||
) or None
|
||||
post_id_raw = request.args.get("post_id")
|
||||
post_id = int(post_id_raw) if post_id_raw else None
|
||||
artist_id_raw = request.args.get("artist_id")
|
||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
||||
media = request.args.get("media")
|
||||
media_type = media if media in ("image", "video") else None
|
||||
sort = request.args.get("sort")
|
||||
sort = sort if sort in ("newest", "oldest") else "newest"
|
||||
return tag_ids, post_id, artist_id, media_type, sort
|
||||
|
||||
|
||||
@gallery_bp.route("/scroll", methods=["GET"])
|
||||
async def scroll():
|
||||
cursor = request.args.get("cursor") or None
|
||||
try:
|
||||
limit = int(request.args.get("limit", "50"))
|
||||
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
|
||||
except ValueError:
|
||||
return jsonify({"error": "limit must be an integer"}), 400
|
||||
tag_id_raw = request.args.get("tag_id")
|
||||
tag_id = int(tag_id_raw) if tag_id_raw else None
|
||||
post_id_raw = request.args.get("post_id")
|
||||
post_id = int(post_id_raw) if post_id_raw else None
|
||||
artist_id_raw = request.args.get("artist_id")
|
||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
||||
return jsonify({"error": "invalid filter or limit parameter"}), 400
|
||||
|
||||
async with get_session() as session:
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
page = await svc.scroll(
|
||||
cursor=cursor, limit=limit, tag_id=tag_id,
|
||||
cursor=cursor, limit=limit, tag_ids=tag_ids,
|
||||
post_id=post_id, artist_id=artist_id,
|
||||
media_type=media_type, sort=sort,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
@@ -58,17 +74,16 @@ async def scroll():
|
||||
|
||||
@gallery_bp.route("/timeline", methods=["GET"])
|
||||
async def timeline():
|
||||
tag_id_raw = request.args.get("tag_id")
|
||||
tag_id = int(tag_id_raw) if tag_id_raw else None
|
||||
post_id_raw = request.args.get("post_id")
|
||||
post_id = int(post_id_raw) if post_id_raw else None
|
||||
artist_id_raw = request.args.get("artist_id")
|
||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
||||
try:
|
||||
tag_ids, post_id, artist_id, media_type, _sort = _parse_filters()
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid filter parameter"}), 400
|
||||
async with get_session() as session:
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
buckets = await svc.timeline(
|
||||
tag_id=tag_id, post_id=post_id, artist_id=artist_id
|
||||
tag_ids=tag_ids, post_id=post_id, artist_id=artist_id,
|
||||
media_type=media_type,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
@@ -82,20 +97,16 @@ async def jump():
|
||||
try:
|
||||
year = int(request.args["year"])
|
||||
month = int(request.args["month"])
|
||||
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
|
||||
except (KeyError, ValueError):
|
||||
return jsonify({"error": "year and month query params required"}), 400
|
||||
tag_id_raw = request.args.get("tag_id")
|
||||
tag_id = int(tag_id_raw) if tag_id_raw else None
|
||||
post_id_raw = request.args.get("post_id")
|
||||
post_id = int(post_id_raw) if post_id_raw else None
|
||||
artist_id_raw = request.args.get("artist_id")
|
||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
||||
async with get_session() as session:
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
cursor = await svc.jump_cursor(
|
||||
year=year, month=month, tag_id=tag_id,
|
||||
year=year, month=month, tag_ids=tag_ids,
|
||||
post_id=post_id, artist_id=artist_id,
|
||||
media_type=media_type, sort=sort,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
+42
-6
@@ -194,15 +194,46 @@ async def remove_tag_from_image(image_id: int, tag_id: int):
|
||||
return "", 204
|
||||
|
||||
|
||||
@tags_bp.route("/tags/<int:tag_id>", methods=["GET"])
|
||||
async def get_tag(tag_id: int):
|
||||
"""Resolve a single tag (used by the gallery to label its active
|
||||
tag-filter chip)."""
|
||||
async with get_session() as session:
|
||||
tag = await session.get(Tag, tag_id)
|
||||
if tag is None:
|
||||
return jsonify({"error": "tag not found"}), 404
|
||||
return jsonify(
|
||||
{
|
||||
"id": tag.id,
|
||||
"name": tag.name,
|
||||
"kind": tag.kind.value,
|
||||
"fandom_id": tag.fandom_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
|
||||
async def rename_tag(tag_id: int):
|
||||
body = await request.get_json()
|
||||
if not body or "name" not in body:
|
||||
return jsonify({"error": "name required"}), 400
|
||||
async def update_tag(tag_id: int):
|
||||
"""Rename and/or re-fandom a tag. Body may carry `name` and/or
|
||||
`fandom_id` (a fandom tag id, or null to clear — character tags only).
|
||||
`merge: true` resolves a collision by merging into the existing tag.
|
||||
"""
|
||||
body = await request.get_json() or {}
|
||||
has_name = "name" in body
|
||||
has_fandom = "fandom_id" in body
|
||||
if not has_name and not has_fandom:
|
||||
return jsonify({"error": "name or fandom_id required"}), 400
|
||||
do_merge = bool(body.get("merge"))
|
||||
async with get_session() as session:
|
||||
svc = TagService(session)
|
||||
try:
|
||||
tag = await svc.rename(tag_id, body["name"])
|
||||
tag = None
|
||||
if has_name:
|
||||
tag = await svc.rename(tag_id, body["name"])
|
||||
if has_fandom:
|
||||
tag = await svc.set_fandom(
|
||||
tag_id, body["fandom_id"], merge=do_merge
|
||||
)
|
||||
except TagMergeConflict as exc:
|
||||
return jsonify(
|
||||
{
|
||||
@@ -219,7 +250,12 @@ async def rename_tag(tag_id: int):
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
await session.commit()
|
||||
return jsonify(
|
||||
{"id": tag.id, "name": tag.name, "kind": tag.kind.value}
|
||||
{
|
||||
"id": tag.id,
|
||||
"name": tag.name,
|
||||
"kind": tag.kind.value,
|
||||
"fandom_id": tag.fandom_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -74,6 +74,17 @@ class ImageRecord(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
# Denormalized gallery sort key = COALESCE(primary post's post_date,
|
||||
# created_at) (alembic 0035). The gallery used to compute this as a
|
||||
# COALESCE across the Post outer join on every /scroll, which can't use
|
||||
# an index and re-sorted a large slice of the library per page (×10 with
|
||||
# the old serial batching). Materializing it lets the cursor scroll read
|
||||
# ix_image_record_effective_date directly. Maintained by the importer
|
||||
# (services/importer.py _apply_sidecar) when a primary post with a date
|
||||
# is linked; plain inserts keep the created_at-equivalent server default.
|
||||
effective_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
|
||||
@@ -43,16 +43,17 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]:
|
||||
|
||||
|
||||
def _effective_date_col():
|
||||
"""SQL expression: COALESCE(post.post_date, image_record.created_at).
|
||||
"""The materialized gallery sort key: image_record.effective_date
|
||||
(alembic 0035) = COALESCE(primary post's post_date, created_at),
|
||||
maintained at write time by the importer.
|
||||
|
||||
Used as the canonical sort/group/filter key across the gallery so
|
||||
images backfilled with primary_post_id (e.g. via tag_apply phase 4)
|
||||
surface at their original publish date, not their FC import date.
|
||||
Images without a Post (or with Post.post_date NULL) fall back to
|
||||
image_record.created_at and still order coherently against
|
||||
post-attached ones.
|
||||
Canonical sort/group/filter key across the gallery so images attached
|
||||
to a post surface at their original publish date, not their FC import
|
||||
date — and, now that it's a single indexed column rather than a
|
||||
COALESCE across the Post outer join, the cursor scroll is an index
|
||||
range scan instead of a full re-sort per page.
|
||||
"""
|
||||
return func.coalesce(Post.post_date, ImageRecord.created_at)
|
||||
return ImageRecord.effective_date
|
||||
|
||||
|
||||
def _outer_join_primary_post(stmt: Select) -> Select:
|
||||
@@ -117,13 +118,43 @@ def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str
|
||||
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
|
||||
|
||||
|
||||
def _require_single_filter(tag_id, post_id, artist_id) -> None:
|
||||
if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1:
|
||||
def _require_single_filter(tag_ids, post_id, artist_id) -> None:
|
||||
"""post_id is the post-detail view — it can't combine with the
|
||||
composable filters. tag_ids + artist_id (+ media_type) compose freely
|
||||
(AND)."""
|
||||
if post_id is not None and (tag_ids or artist_id is not None):
|
||||
raise ValueError(
|
||||
"tag_id, post_id, artist_id are mutually exclusive"
|
||||
"post_id cannot be combined with tag or artist filters"
|
||||
)
|
||||
|
||||
|
||||
def _apply_scope(stmt, *, tag_ids, post_id, artist_id, media_type):
|
||||
"""Apply the composable gallery filters to a statement already joined
|
||||
to Post via _outer_join_primary_post.
|
||||
|
||||
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag
|
||||
(AND), which avoids the row-multiplication a multi-join would cause.
|
||||
- post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded
|
||||
by _require_single_filter).
|
||||
- media_type: 'image' | 'video' narrows by mime prefix.
|
||||
"""
|
||||
for tid in tag_ids or []:
|
||||
stmt = stmt.where(
|
||||
exists().where(
|
||||
image_tag.c.image_record_id == ImageRecord.id,
|
||||
image_tag.c.tag_id == tid,
|
||||
)
|
||||
)
|
||||
prov = _provenance_clause(post_id, artist_id)
|
||||
if prov is not None:
|
||||
stmt = stmt.where(prov)
|
||||
if media_type == "image":
|
||||
stmt = stmt.where(ImageRecord.mime.like("image/%"))
|
||||
elif media_type == "video":
|
||||
stmt = stmt.where(ImageRecord.mime.like("video/%"))
|
||||
return stmt
|
||||
|
||||
|
||||
def _provenance_clause(post_id, artist_id):
|
||||
"""Correlated EXISTS clause (NOT a join) so an image with multiple
|
||||
matching provenance rows is returned exactly once and the
|
||||
@@ -179,35 +210,43 @@ class GalleryService:
|
||||
self,
|
||||
cursor: str | None,
|
||||
limit: int = 50,
|
||||
tag_id: int | None = None,
|
||||
tag_ids: list[int] | None = None,
|
||||
post_id: int | None = None,
|
||||
artist_id: int | None = None,
|
||||
media_type: str | None = None,
|
||||
sort: str = "newest",
|
||||
) -> GalleryPage:
|
||||
if limit < 1 or limit > 200:
|
||||
raise ValueError("limit must be between 1 and 200")
|
||||
_require_single_filter(tag_id, post_id, artist_id)
|
||||
_require_single_filter(tag_ids, post_id, artist_id)
|
||||
|
||||
eff = _effective_date_col()
|
||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
if tag_id is not None:
|
||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
||||
image_tag.c.tag_id == tag_id
|
||||
)
|
||||
prov = _provenance_clause(post_id, artist_id)
|
||||
if prov is not None:
|
||||
stmt = stmt.where(prov)
|
||||
stmt = _apply_scope(
|
||||
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
)
|
||||
|
||||
descending = sort != "oldest"
|
||||
if cursor:
|
||||
cur_ts, cur_id = decode_cursor(cursor)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
eff < cur_ts,
|
||||
and_(eff == cur_ts, ImageRecord.id < cur_id),
|
||||
# The cursor is just (last eff, last id); the request's sort
|
||||
# decides which side of it the next page lies on.
|
||||
if descending:
|
||||
stmt = stmt.where(
|
||||
or_(eff < cur_ts, and_(eff == cur_ts, ImageRecord.id < cur_id))
|
||||
)
|
||||
else:
|
||||
stmt = stmt.where(
|
||||
or_(eff > cur_ts, and_(eff == cur_ts, ImageRecord.id > cur_id))
|
||||
)
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(limit + 1)
|
||||
if descending:
|
||||
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
|
||||
stmt = stmt.limit(limit + 1)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
|
||||
next_cursor = None
|
||||
@@ -243,9 +282,10 @@ class GalleryService:
|
||||
|
||||
async def timeline(
|
||||
self,
|
||||
tag_id: int | None = None,
|
||||
tag_ids: list[int] | None = None,
|
||||
post_id: int | None = None,
|
||||
artist_id: int | None = None,
|
||||
media_type: str | None = None,
|
||||
) -> list[TimelineBucket]:
|
||||
eff = _effective_date_col()
|
||||
year_col = func.date_part("year", eff).label("yr")
|
||||
@@ -254,25 +294,23 @@ class GalleryService:
|
||||
year_col, month_col, func.count(ImageRecord.id).label("cnt")
|
||||
)
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
_require_single_filter(tag_id, post_id, artist_id)
|
||||
if tag_id is not None:
|
||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
||||
image_tag.c.tag_id == tag_id
|
||||
)
|
||||
prov = _provenance_clause(post_id, artist_id)
|
||||
if prov is not None:
|
||||
stmt = stmt.where(prov)
|
||||
_require_single_filter(tag_ids, post_id, artist_id)
|
||||
stmt = _apply_scope(
|
||||
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
)
|
||||
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows]
|
||||
|
||||
async def jump_cursor(
|
||||
self, year: int, month: int, tag_id: int | None = None,
|
||||
self, year: int, month: int, tag_ids: list[int] | None = None,
|
||||
post_id: int | None = None, artist_id: int | None = None,
|
||||
media_type: str | None = None, sort: str = "newest",
|
||||
) -> str | None:
|
||||
"""Returns a cursor that, when passed to scroll(), positions at the
|
||||
first image of the given year-month (by effective_date, not
|
||||
created_at). None if the bucket is empty.
|
||||
"""Returns a cursor that, when passed to scroll() with the same sort,
|
||||
positions at the first image of the given year-month. None if the
|
||||
bucket is empty.
|
||||
"""
|
||||
from sqlalchemy import extract
|
||||
|
||||
@@ -282,22 +320,24 @@ class GalleryService:
|
||||
extract("month", eff) == month,
|
||||
)
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
_require_single_filter(tag_id, post_id, artist_id)
|
||||
if tag_id is not None:
|
||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
||||
image_tag.c.tag_id == tag_id
|
||||
)
|
||||
prov = _provenance_clause(post_id, artist_id)
|
||||
if prov is not None:
|
||||
stmt = stmt.where(prov)
|
||||
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(1)
|
||||
first = (await self.session.execute(stmt)).first()
|
||||
_require_single_filter(tag_ids, post_id, artist_id)
|
||||
stmt = _apply_scope(
|
||||
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
)
|
||||
descending = sort != "oldest"
|
||||
if descending:
|
||||
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
|
||||
first = (await self.session.execute(stmt.limit(1))).first()
|
||||
if first is None:
|
||||
return None
|
||||
record, eff_date = first
|
||||
# Cursor is exclusive; we encode a cursor with id+1 so the row itself
|
||||
# is the first result in the next scroll().
|
||||
return encode_cursor(eff_date, record.id + 1)
|
||||
# Cursor is exclusive; nudge the id one past the boundary row (in the
|
||||
# scan direction) so the row itself is the first result of scroll().
|
||||
boundary = record.id + 1 if descending else record.id - 1
|
||||
return encode_cursor(eff_date, boundary)
|
||||
|
||||
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
||||
record = await self.session.get(ImageRecord, image_id)
|
||||
@@ -357,17 +397,10 @@ class GalleryService:
|
||||
}
|
||||
|
||||
async def _neighbors(self, record: ImageRecord) -> dict:
|
||||
# Compute the boundary image's effective_date in Python (one query
|
||||
# below + the SELECT we already have on `record`) and use it for
|
||||
# the neighbor comparison. Cheaper than re-deriving in SQL via
|
||||
# correlated subquery.
|
||||
boundary_eff = record.created_at
|
||||
if record.primary_post_id is not None:
|
||||
post_date = (await self.session.execute(
|
||||
select(Post.post_date).where(Post.id == record.primary_post_id)
|
||||
)).scalar_one_or_none()
|
||||
if post_date is not None:
|
||||
boundary_eff = post_date
|
||||
# The boundary image's sort key is materialized on the row now
|
||||
# (alembic 0035) — read it directly instead of re-deriving COALESCE
|
||||
# via an extra Post lookup.
|
||||
boundary_eff = record.effective_date
|
||||
|
||||
eff = _effective_date_col()
|
||||
prev_stmt = _outer_join_primary_post(
|
||||
|
||||
@@ -960,6 +960,14 @@ class Importer:
|
||||
sp.rollback()
|
||||
if record.primary_post_id is None:
|
||||
record.primary_post_id = post.id
|
||||
# Keep the denormalized gallery sort key (alembic 0035) aligned with
|
||||
# the primary post's publish date so /scroll orders off
|
||||
# ix_image_record_effective_date instead of COALESCE-ing across the
|
||||
# post join. Only override when THIS post is the primary AND carries
|
||||
# a date; otherwise the column keeps its created_at-equivalent server
|
||||
# default (matches the old COALESCE(post_date, created_at) fallback).
|
||||
if record.primary_post_id == post.id and post.post_date is not None:
|
||||
record.effective_date = post.post_date
|
||||
self.session.flush()
|
||||
|
||||
def _copy_to_library(
|
||||
|
||||
@@ -280,6 +280,69 @@ class TagService:
|
||||
await self.session.flush()
|
||||
return tag
|
||||
|
||||
async def set_fandom(
|
||||
self, tag_id: int, fandom_id: int | None, *, merge: bool = False
|
||||
) -> Tag:
|
||||
"""Set / change / clear a character tag's fandom.
|
||||
|
||||
Raises TagValidationError unless the tag is a character and fandom_id
|
||||
(when given) references a fandom tag. If the change would collide with
|
||||
an existing character of the same name in the TARGET fandom, raises
|
||||
TagMergeConflict (the API turns that into a 409 merge hint) — unless
|
||||
merge=True, in which case this tag is merged INTO that existing
|
||||
character (a deliberate cross-fandom merge) and the surviving target
|
||||
is returned. Passing fandom_id=None clears the fandom.
|
||||
"""
|
||||
tag = await self.session.get(Tag, tag_id)
|
||||
if tag is None:
|
||||
raise TagValidationError(f"Tag {tag_id} not found")
|
||||
if tag.kind != TagKind.character:
|
||||
raise TagValidationError("Only character tags can have a fandom")
|
||||
if fandom_id is not None:
|
||||
fandom = await self.session.get(Tag, fandom_id)
|
||||
if fandom is None or fandom.kind != TagKind.fandom:
|
||||
raise TagValidationError(
|
||||
f"fandom_id {fandom_id} does not reference a fandom tag"
|
||||
)
|
||||
if fandom_id == tag.fandom_id:
|
||||
return tag
|
||||
|
||||
# Collision: another character with the same name already lives in the
|
||||
# target fandom. Mirrors rename's (name, kind, fandom_id) uniqueness.
|
||||
clash_stmt = (
|
||||
select(Tag)
|
||||
.where(Tag.name == tag.name)
|
||||
.where(Tag.kind == TagKind.character)
|
||||
.where(
|
||||
Tag.fandom_id.is_(None)
|
||||
if fandom_id is None
|
||||
else Tag.fandom_id == fandom_id
|
||||
)
|
||||
.where(Tag.id != tag_id)
|
||||
)
|
||||
clash = (await self.session.execute(clash_stmt)).scalar_one_or_none()
|
||||
if clash is not None:
|
||||
if not merge:
|
||||
source_image_count = await self.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(image_tag)
|
||||
.where(image_tag.c.tag_id == tag_id)
|
||||
)
|
||||
will_alias = await self._keep_as_alias(tag_id)
|
||||
raise TagMergeConflict(
|
||||
f"A character named {tag.name!r} already exists in that fandom",
|
||||
target_id=clash.id,
|
||||
target_name=clash.name,
|
||||
source_image_count=int(source_image_count or 0),
|
||||
will_alias=will_alias,
|
||||
)
|
||||
await self._do_merge(tag, clash)
|
||||
return clash
|
||||
|
||||
tag.fandom_id = fandom_id
|
||||
await self.session.flush()
|
||||
return tag
|
||||
|
||||
async def merge(self, source_id: int, target_id: int) -> MergeResult:
|
||||
"""Transactionally repoint every FK from source→target, optionally
|
||||
keep source's name as a tagger alias, delete source. Atomic: any
|
||||
@@ -298,7 +361,14 @@ class TagService:
|
||||
raise TagValidationError(
|
||||
"Tags must be the same kind and fandom to merge"
|
||||
)
|
||||
return await self._do_merge(source, target)
|
||||
|
||||
async def _do_merge(self, source: Tag, target: Tag) -> MergeResult:
|
||||
"""Repoint every FK source→target, optionally keep source's name as a
|
||||
tagger alias, delete source. NO kind/fandom validation — callers that
|
||||
need it (public merge()) validate first; set_fandom's collision
|
||||
resolution calls this directly for a deliberate CROSS-fandom merge."""
|
||||
source_id, target_id = source.id, target.id
|
||||
keep_as_alias = await self._keep_as_alias(source_id)
|
||||
source_name = source.name
|
||||
source_kind = source.kind
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-if="card.kind === 'character'"
|
||||
title="Set fandom…"
|
||||
prepend-icon="mdi-book-open-page-variant"
|
||||
@click="$emit('set-fandom', card)"
|
||||
/>
|
||||
<v-list-item
|
||||
title="Merge with…"
|
||||
prepend-icon="mdi-call-merge"
|
||||
@@ -78,7 +84,9 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({ card: { type: Object, required: true } })
|
||||
const emit = defineEmits(['open', 'rename', 'manage', 'read', 'merge-with', 'delete'])
|
||||
const emit = defineEmits([
|
||||
'open', 'rename', 'manage', 'read', 'merge-with', 'delete', 'set-fandom',
|
||||
])
|
||||
|
||||
const editing = ref(false)
|
||||
const draft = ref('')
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="fc-filterbar">
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
:items="searchItems"
|
||||
:loading="searchLoading"
|
||||
item-title="name" item-value="value"
|
||||
no-filter clearable hide-details density="compact" variant="outlined"
|
||||
placeholder="Filter by tag or artist…"
|
||||
prepend-inner-icon="mdi-filter-variant"
|
||||
class="fc-filterbar__search"
|
||||
@update:search="onSearch"
|
||||
@update:model-value="onPick"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps" :title="item.raw.name">
|
||||
<template #prepend>
|
||||
<v-icon size="small">{{ iconFor(item.raw) }}</v-icon>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
{{ item.raw.kind === 'artist' ? 'artist'
|
||||
: (item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind) }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
|
||||
<div class="fc-filterbar__chips">
|
||||
<v-chip
|
||||
v-for="id in store.filter.tag_ids" :key="`t${id}`"
|
||||
size="small" closable :color="chipColor(id)" variant="tonal"
|
||||
@click:close="removeTag(id)"
|
||||
>{{ store.tagLabels[id] || `#${id}` }}</v-chip>
|
||||
<v-chip
|
||||
v-if="store.filter.artist_id"
|
||||
size="small" closable color="accent" variant="tonal"
|
||||
prepend-icon="mdi-account"
|
||||
@click:close="clearArtist"
|
||||
>{{ store.artistLabel || `Artist #${store.filter.artist_id}` }}</v-chip>
|
||||
</div>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-btn-toggle
|
||||
:model-value="store.filter.media_type ?? 'all'"
|
||||
density="compact" mandatory variant="outlined" divided
|
||||
@update:model-value="(v) => setMedia(v === 'all' ? null : v)"
|
||||
>
|
||||
<v-btn value="all" size="small">All</v-btn>
|
||||
<v-btn value="image" size="small">Images</v-btn>
|
||||
<v-btn value="video" size="small">Videos</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<v-select
|
||||
:model-value="store.filter.sort"
|
||||
:items="SORTS"
|
||||
density="compact" hide-details variant="outlined"
|
||||
class="fc-filterbar__sort"
|
||||
@update:model-value="setSort"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
v-if="hasActiveFilters" variant="text" size="small"
|
||||
prepend-icon="mdi-close" @click="clearAll"
|
||||
>Clear</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useGalleryStore } from '../../stores/gallery.js'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
|
||||
const store = useGalleryStore()
|
||||
const tagStore = useTagStore()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
|
||||
const SORTS = [
|
||||
{ title: 'Newest first', value: 'newest' },
|
||||
{ title: 'Oldest first', value: 'oldest' },
|
||||
]
|
||||
|
||||
const selected = ref(null)
|
||||
const searchItems = ref([])
|
||||
const searchLoading = ref(false)
|
||||
let debounce = null
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
store.filter.tag_ids.length > 0 ||
|
||||
store.filter.artist_id != null ||
|
||||
store.filter.media_type != null ||
|
||||
store.filter.sort !== 'newest'
|
||||
)
|
||||
|
||||
function iconFor(raw) {
|
||||
if (raw.kind === 'artist') return 'mdi-account'
|
||||
return { character: 'mdi-account-circle', fandom: 'mdi-book-open-page-variant',
|
||||
series: 'mdi-bookshelf' }[raw.kind] || 'mdi-tag'
|
||||
}
|
||||
function chipColor(id) {
|
||||
// Tag chips use the same per-kind palette as the rest of the app; we only
|
||||
// know the kind from the autocomplete pick, so fall back to a neutral tone.
|
||||
return tagStore.colorFor(pickedKind.value[id] || 'general')
|
||||
}
|
||||
const pickedKind = ref({})
|
||||
|
||||
function onSearch(q) {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
if (!q || !q.trim()) { searchItems.value = []; return }
|
||||
debounce = setTimeout(async () => {
|
||||
searchLoading.value = true
|
||||
try {
|
||||
const [tags, artists] = await Promise.all([
|
||||
api.get('/api/tags/autocomplete', { params: { q, limit: 10 } }),
|
||||
api.get('/api/artists/autocomplete', { params: { q, limit: 10 } }),
|
||||
])
|
||||
searchItems.value = [
|
||||
...(artists || []).map((a) => ({
|
||||
kind: 'artist', id: a.id, name: a.name, value: `artist:${a.id}`,
|
||||
})),
|
||||
...(tags || []).map((t) => ({
|
||||
kind: t.kind, id: t.id, name: t.name, value: `tag:${t.id}`,
|
||||
fandom_name: t.fandom_name,
|
||||
})),
|
||||
]
|
||||
} catch {
|
||||
searchItems.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function onPick(value) {
|
||||
const item = searchItems.value.find((i) => i.value === value)
|
||||
selected.value = null
|
||||
searchItems.value = []
|
||||
if (!item) return
|
||||
if (item.kind === 'artist') {
|
||||
store.noteArtistLabel(item.name)
|
||||
pushFilter((n) => { n.artist_id = item.id })
|
||||
} else {
|
||||
store.noteTagLabel(item.id, item.name)
|
||||
pickedKind.value = { ...pickedKind.value, [item.id]: item.kind }
|
||||
pushFilter((n) => { if (!n.tag_ids.includes(item.id)) n.tag_ids.push(item.id) })
|
||||
}
|
||||
}
|
||||
|
||||
function removeTag(id) {
|
||||
pushFilter((n) => { n.tag_ids = n.tag_ids.filter((t) => t !== id) })
|
||||
}
|
||||
function clearArtist() {
|
||||
store.noteArtistLabel(null)
|
||||
pushFilter((n) => { n.artist_id = null })
|
||||
}
|
||||
function setMedia(m) { pushFilter((n) => { n.media_type = m }) }
|
||||
function setSort(s) { pushFilter((n) => { n.sort = s }) }
|
||||
function clearAll() { router.push({ name: 'gallery', query: {} }) }
|
||||
|
||||
// Single write path: clone the current filter, mutate, serialize to the URL.
|
||||
// The route watcher in GalleryView applies it to the store and reloads.
|
||||
function pushFilter(mutate) {
|
||||
const f = store.filter
|
||||
const n = {
|
||||
tag_ids: [...f.tag_ids],
|
||||
artist_id: f.artist_id,
|
||||
media_type: f.media_type,
|
||||
sort: f.sort,
|
||||
}
|
||||
mutate(n)
|
||||
const q = {}
|
||||
if (n.tag_ids.length) q.tag_id = n.tag_ids.join(',')
|
||||
if (n.artist_id) q.artist_id = String(n.artist_id)
|
||||
if (n.media_type) q.media = n.media_type
|
||||
if (n.sort && n.sort !== 'newest') q.sort = n.sort
|
||||
router.push({ name: 'gallery', query: q })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Pinned under the 64px TopNav, matching the app's sticky v-tabs chrome
|
||||
(SettingsView / ArtistHeader / SubscriptionsView). */
|
||||
.fc-filterbar {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 4px;
|
||||
margin-bottom: 12px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
|
||||
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.fc-filterbar__sort { max-width: 150px; }
|
||||
</style>
|
||||
@@ -9,11 +9,16 @@
|
||||
>
|
||||
<div class="fc-gallery-item__media">
|
||||
<img
|
||||
v-if="!isVideo" :src="image.thumbnail_url" :alt="`Image ${image.id}`"
|
||||
loading="lazy" @error="onThumbError"
|
||||
v-if="!isVideo" ref="imgEl" :src="image.thumbnail_url"
|
||||
:alt="`Image ${image.id}`" loading="lazy"
|
||||
:class="{ 'is-loaded': loaded }"
|
||||
@load="loaded = true" @error="onThumbError"
|
||||
>
|
||||
<div v-else class="fc-gallery-item__video-thumb">
|
||||
<img :src="image.thumbnail_url" :alt="`Video ${image.id}`" loading="lazy">
|
||||
<img
|
||||
ref="imgEl" :src="image.thumbnail_url" :alt="`Video ${image.id}`"
|
||||
loading="lazy" :class="{ 'is-loaded': loaded }" @load="loaded = true"
|
||||
>
|
||||
<v-icon class="fc-gallery-item__video-badge" icon="mdi-play-circle" />
|
||||
</div>
|
||||
<div v-if="thumbError" class="fc-gallery-item__placeholder">
|
||||
@@ -38,7 +43,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
|
||||
|
||||
const props = defineProps({ image: { type: Object, required: true } })
|
||||
@@ -46,6 +51,18 @@ const emit = defineEmits(['open'])
|
||||
|
||||
const sel = useGallerySelectionStore()
|
||||
const thumbError = ref(false)
|
||||
|
||||
// Reveal each tile when ITS OWN thumbnail finishes loading (`@load`), not
|
||||
// when its metadata batch lands — so tiles fade/flip in individually in
|
||||
// load order instead of popping in together (operator-flagged 2026-06-04).
|
||||
const loaded = ref(false)
|
||||
const imgEl = ref(null)
|
||||
onMounted(() => {
|
||||
// A cached thumbnail can already be complete before @load binds; reflect
|
||||
// that so the tile reveals instead of sitting invisible at opacity 0.
|
||||
const el = imgEl.value
|
||||
if (el && el.complete && el.naturalWidth > 0) loaded.value = true
|
||||
})
|
||||
const isVideo = computed(
|
||||
() => props.image.mime && props.image.mime.startsWith('video/')
|
||||
)
|
||||
@@ -82,6 +99,30 @@ function onThumbError() { thumbError.value = true }
|
||||
}
|
||||
.fc-gallery-item__media img {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
opacity: 0;
|
||||
}
|
||||
/* Entrance: each thumbnail flips up out of a slight backward tilt and
|
||||
settles into place, played WHEN ITS OWN image finishes loading (the
|
||||
`is-loaded` class) rather than on a batch/timer — so tiles cascade in
|
||||
natural load order instead of popping in together. Mirrors the showcase
|
||||
MasonryGrid entrance styling (operator-flagged 2026-06-04). */
|
||||
.fc-gallery-item__media img.is-loaded {
|
||||
animation: fc-gallery-item-in 0.5s cubic-bezier(0.34, 1.45, 0.64, 1) both;
|
||||
}
|
||||
@keyframes fc-gallery-item-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: perspective(1000px) rotateX(-22deg) translateY(20px) scale(0.96);
|
||||
}
|
||||
55% { opacity: 1; }
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: perspective(1000px) rotateX(0) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fc-gallery-item__media img { opacity: 1; }
|
||||
.fc-gallery-item__media img.is-loaded { animation: none; }
|
||||
}
|
||||
.fc-gallery-item__video-thumb { position: relative; height: 100%; }
|
||||
.fc-gallery-item__video-badge {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="text-body-1">Fandom for “{{ tag.name }}”</v-card-title>
|
||||
<v-card-text>
|
||||
<template v-if="!collision">
|
||||
<v-autocomplete
|
||||
v-model="selectedId"
|
||||
:items="store.fandomCache"
|
||||
:item-title="(f) => f.name" :item-value="(f) => f.id"
|
||||
label="Fandom" clearable density="compact"
|
||||
:hint="selectedId == null
|
||||
? 'No fandom — the character will be unassigned.' : ''"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-divider class="my-3" />
|
||||
<p class="text-caption mb-2">Or create a new fandom:</p>
|
||||
<div class="d-flex" style="gap: 8px;">
|
||||
<v-text-field
|
||||
v-model="newName" placeholder="New fandom name"
|
||||
density="compact" hide-details
|
||||
@keydown.enter.prevent="onCreate"
|
||||
/>
|
||||
<v-btn
|
||||
:disabled="!newName.trim() || busy" rounded="pill"
|
||||
@click="onCreate"
|
||||
>Create</v-btn>
|
||||
</div>
|
||||
<v-alert
|
||||
v-if="error" type="error" variant="tonal" density="compact"
|
||||
class="mt-3"
|
||||
>{{ error }}</v-alert>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
A character named “{{ tag.name }}” already exists in that fandom.
|
||||
</v-alert>
|
||||
<p class="text-body-2">
|
||||
Merge this tag into “{{ collision.target.name }}”?
|
||||
{{ collision.source_image_count }} image
|
||||
association{{ collision.source_image_count === 1 ? '' : 's' }}
|
||||
will move over and this tag will be deleted{{
|
||||
collision.will_alias ? ' (its name kept as a tagger alias)' : '' }}.
|
||||
</p>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<template v-if="!collision">
|
||||
<v-btn variant="text" :disabled="busy" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary" rounded="pill" :loading="busy"
|
||||
:disabled="selectedId === (tag.fandom_id ?? null)"
|
||||
@click="onSave"
|
||||
>Save</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn variant="text" :disabled="busy" @click="collision = null">
|
||||
Back
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning" variant="flat" rounded="pill" :loading="busy"
|
||||
@click="onConfirmMerge"
|
||||
>Merge</v-btn>
|
||||
</template>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
|
||||
const props = defineProps({ tag: { type: Object, required: true } })
|
||||
const emit = defineEmits(['updated', 'cancel'])
|
||||
|
||||
const store = useTagStore()
|
||||
|
||||
const selectedId = ref(props.tag.fandom_id ?? null)
|
||||
const newName = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref(null)
|
||||
const collision = ref(null)
|
||||
|
||||
onMounted(() => { if (store.fandomCache.length === 0) store.loadFandoms() })
|
||||
|
||||
async function onCreate() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
busy.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const f = await store.createFandom(name)
|
||||
selectedId.value = f.id
|
||||
newName.value = ''
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(merge) {
|
||||
busy.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const body = await store.setFandom(props.tag.id, selectedId.value, { merge })
|
||||
emit('updated', body)
|
||||
} catch (e) {
|
||||
// 409 on first attempt → surface the merge confirmation; cross-fandom
|
||||
// collisions can't go through the regular /merge endpoint, so the
|
||||
// resolution is a second setFandom with merge: true.
|
||||
if (!merge && e.status === 409 && e.body && e.body.target) {
|
||||
collision.value = e.body
|
||||
} else {
|
||||
error.value = e.message || String(e)
|
||||
collision.value = null
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSave() { save(false) }
|
||||
function onConfirmMerge() { save(true) }
|
||||
</script>
|
||||
@@ -28,6 +28,11 @@
|
||||
<v-list-item @click="openRename(tag)">
|
||||
<v-list-item-title>Rename…</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="tag.kind === 'character'" @click="openSetFandom(tag)"
|
||||
>
|
||||
<v-list-item-title>Set fandom…</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</span>
|
||||
@@ -57,6 +62,13 @@
|
||||
@renamed="onRenamed" @cancel="renameDialog = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="fandomDialog" max-width="460">
|
||||
<FandomSetDialog
|
||||
v-if="fandomTarget" :tag="fandomTarget"
|
||||
@updated="onFandomUpdated" @cancel="fandomDialog = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -67,6 +79,7 @@ import { useTagStore } from '../../stores/tags.js'
|
||||
import TagAutocomplete from './TagAutocomplete.vue'
|
||||
import SuggestionsPanel from './SuggestionsPanel.vue'
|
||||
import TagRenameDialog from './TagRenameDialog.vue'
|
||||
import FandomSetDialog from './FandomSetDialog.vue'
|
||||
|
||||
const modal = useModalStore()
|
||||
const store = useTagStore()
|
||||
@@ -107,6 +120,18 @@ async function onRenamed() {
|
||||
// Reflect the new name in the modal's current tag list without a full reload.
|
||||
await modal.reloadTags()
|
||||
}
|
||||
|
||||
const fandomDialog = ref(false)
|
||||
const fandomTarget = ref(null)
|
||||
function openSetFandom(tag) {
|
||||
fandomTarget.value = tag
|
||||
fandomDialog.value = true
|
||||
}
|
||||
async function onFandomUpdated() {
|
||||
fandomDialog.value = false
|
||||
// A fandom change can merge the tag away; reload to reflect the new state.
|
||||
await modal.reloadTags()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,12 +3,15 @@ import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
|
||||
// Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one
|
||||
// 50-item request so items render as each batch lands. Total initial
|
||||
// count is unchanged (PAGE * INITIAL_BATCHES = 50). Infinite-scroll also
|
||||
// pulls PAGE per trigger to keep appends progressive.
|
||||
const PAGE = 5
|
||||
const INITIAL_BATCHES = 10
|
||||
// Initial paint is a SINGLE request (INITIAL_LIMIT). The old 10×serial-
|
||||
// batch loop (2026-05-30) only staggered METADATA, which isn't the visual
|
||||
// bottleneck — thumbnails load as independent `<img>` requests, and
|
||||
// GalleryItem now reveals each tile on its own image `@load`. One fetch is
|
||||
// far fewer round-trips and faster to first paint; the reveal-on-load is
|
||||
// what makes appearance progressive. Infinite scroll pulls PAGE per trigger.
|
||||
// Reworked 2026-06-04.
|
||||
const PAGE = 25
|
||||
const INITIAL_LIMIT = 50
|
||||
|
||||
export const useGalleryStore = defineStore('gallery', () => {
|
||||
const api = useApi()
|
||||
@@ -18,7 +21,14 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
const nextCursor = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const filter = ref({ tag_id: null, post_id: null })
|
||||
const filter = ref({
|
||||
tag_ids: [], artist_id: null, media_type: null,
|
||||
sort: 'newest', post_id: null,
|
||||
})
|
||||
// Display names for the active filter chips — resolved by id on deep-link
|
||||
// and pre-noted by the filter bar when a user picks from autocomplete.
|
||||
const tagLabels = ref({}) // tagId -> name
|
||||
const artistLabel = ref(null)
|
||||
|
||||
const timelineBuckets = ref([])
|
||||
const timelineLoading = ref(false)
|
||||
@@ -32,22 +42,16 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
images.value = []
|
||||
dateGroups.value = []
|
||||
nextCursor.value = null
|
||||
// Sequentially fetch INITIAL_BATCHES chunks so items render as each
|
||||
// batch lands rather than blocking on one big response. Stop early
|
||||
// when the backend reports no more pages.
|
||||
for (let i = 0; i < INITIAL_BATCHES; i++) {
|
||||
if (i > 0 && nextCursor.value === null) break
|
||||
await loadMore()
|
||||
}
|
||||
await loadMore(INITIAL_LIMIT)
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
async function loadMore(limit = PAGE) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const t = inflight.claim()
|
||||
try {
|
||||
const params = { limit: PAGE, ...activeFilterParam() }
|
||||
const params = { limit, ...activeFilterParam() }
|
||||
if (nextCursor.value) params.cursor = nextCursor.value
|
||||
const body = await api.get('/api/gallery/scroll', { params })
|
||||
if (!t.isCurrent()) return
|
||||
@@ -89,23 +93,63 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
}
|
||||
|
||||
function activeFilterParam() {
|
||||
if (filter.value.tag_id) return { tag_id: filter.value.tag_id }
|
||||
// post_id is the exclusive post-detail view.
|
||||
if (filter.value.post_id) return { post_id: filter.value.post_id }
|
||||
return {}
|
||||
const p = {}
|
||||
if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.join(',')
|
||||
if (filter.value.artist_id) p.artist_id = filter.value.artist_id
|
||||
if (filter.value.media_type) p.media = filter.value.media_type
|
||||
if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort
|
||||
return p
|
||||
}
|
||||
|
||||
function setTagFilter(tagId) {
|
||||
filter.value.tag_id = tagId
|
||||
filter.value.post_id = null
|
||||
loadInitial()
|
||||
loadTimeline()
|
||||
// URL is the source of truth for filters. GalleryView calls this on mount
|
||||
// and on every route-query change; the filter bar mutates the URL
|
||||
// (router.push) rather than the store directly, so deep-links, the back
|
||||
// button, and bar actions all funnel through one path.
|
||||
async function applyFilterFromQuery(q) {
|
||||
filter.value = {
|
||||
tag_ids: _parseIds(q.tag_id),
|
||||
artist_id: _toId(q.artist_id),
|
||||
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
|
||||
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
|
||||
post_id: _toId(q.post_id),
|
||||
}
|
||||
await loadInitial()
|
||||
await loadTimeline()
|
||||
_resolveLabels()
|
||||
}
|
||||
|
||||
function setPostFilter(postId) {
|
||||
filter.value.post_id = postId
|
||||
filter.value.tag_id = null
|
||||
loadInitial()
|
||||
loadTimeline()
|
||||
function _toId(v) {
|
||||
const n = Number(v)
|
||||
return Number.isInteger(n) && n > 0 ? n : null
|
||||
}
|
||||
function _parseIds(raw) {
|
||||
if (!raw) return []
|
||||
return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0)
|
||||
}
|
||||
|
||||
// Pre-seed a label so a freshly-picked chip shows its name without a
|
||||
// round-trip; the bar calls these before pushing the new URL.
|
||||
function noteTagLabel(id, name) { tagLabels.value = { ...tagLabels.value, [id]: name } }
|
||||
function noteArtistLabel(name) { artistLabel.value = name || null }
|
||||
|
||||
async function _resolveLabels() {
|
||||
for (const id of filter.value.tag_ids) {
|
||||
if (tagLabels.value[id]) continue
|
||||
try {
|
||||
const t = await api.get(`/api/tags/${id}`)
|
||||
tagLabels.value = { ...tagLabels.value, [id]: t.name }
|
||||
} catch { /* chip falls back to #id */ }
|
||||
}
|
||||
if (filter.value.artist_id && !artistLabel.value) {
|
||||
// The filtered set is this artist — derive the chip label from a tile.
|
||||
const hit = images.value.find(
|
||||
(i) => i.artist && i.artist.id === filter.value.artist_id
|
||||
)
|
||||
artistLabel.value = hit?.artist?.name || null
|
||||
}
|
||||
if (!filter.value.artist_id) artistLabel.value = null
|
||||
}
|
||||
|
||||
const hasMore = computed(() => nextCursor.value !== null)
|
||||
@@ -113,8 +157,9 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
|
||||
return {
|
||||
images, dateGroups, hasMore, isEmpty, loading, error,
|
||||
filter, timelineBuckets, timelineLoading,
|
||||
loadInitial, loadMore, loadTimeline, jumpTo, setTagFilter, setPostFilter
|
||||
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
|
||||
loadInitial, loadMore, loadTimeline, jumpTo,
|
||||
applyFilterFromQuery, noteTagLabel, noteArtistLabel,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -49,8 +49,21 @@ export const useTagStore = defineStore('tags', () => {
|
||||
return fandom
|
||||
}
|
||||
|
||||
// Set / change / clear a character tag's fandom. fandomId null clears it.
|
||||
// Throws ApiError (status 409, body.target) on a name collision in the
|
||||
// target fandom; pass { merge: true } to resolve it by merging this tag
|
||||
// into the existing character. Returns the updated/surviving tag.
|
||||
async function setFandom(tagId, fandomId, { merge = false } = {}) {
|
||||
const body = { fandom_id: fandomId ?? null }
|
||||
if (merge) body.merge = true
|
||||
return await api.patch(`/api/tags/${tagId}`, { body })
|
||||
}
|
||||
|
||||
function kindOptions() { return KIND_OPTIONS }
|
||||
function colorFor(kind) { return KIND_COLOR[kind] || 'on-surface' }
|
||||
|
||||
return { fandomCache, autocomplete, loadFandoms, createFandom, kindOptions, colorFor }
|
||||
return {
|
||||
fandomCache, autocomplete, loadFandoms, createFandom, setFandom,
|
||||
kindOptions, colorFor
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<div class="fc-gallery-layout">
|
||||
<div class="fc-gallery-layout__main">
|
||||
<PostInfoHeader />
|
||||
<GalleryFilterBar v-if="store.filter.post_id == null" />
|
||||
<EmptyState v-if="store.isEmpty" />
|
||||
<GalleryGrid v-else @open="openImage" />
|
||||
</div>
|
||||
@@ -28,6 +29,7 @@ import { useRoute } from 'vue-router'
|
||||
import { useGalleryStore } from '../stores/gallery.js'
|
||||
import { useModalStore } from '../stores/modal.js'
|
||||
import GalleryGrid from '../components/gallery/GalleryGrid.vue'
|
||||
import GalleryFilterBar from '../components/gallery/GalleryFilterBar.vue'
|
||||
import TimelineSidebar from '../components/gallery/TimelineSidebar.vue'
|
||||
import EmptyState from '../components/gallery/EmptyState.vue'
|
||||
import PostInfoHeader from '../components/gallery/PostInfoHeader.vue'
|
||||
@@ -39,25 +41,13 @@ const modal = useModalStore()
|
||||
const sel = useGallerySelectionStore()
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(async () => {
|
||||
const postId = parseInt(route.query.post_id, 10)
|
||||
const tagId = parseInt(route.query.tag_id, 10)
|
||||
if (!isNaN(postId)) store.setPostFilter(postId)
|
||||
else if (!isNaN(tagId)) store.setTagFilter(tagId)
|
||||
await store.loadInitial()
|
||||
await store.loadTimeline()
|
||||
})
|
||||
// The URL query is the single source of truth for filters. Apply it on
|
||||
// mount and on any query change (filter bar pushes, back button, deep-link).
|
||||
onMounted(() => store.applyFilterFromQuery(route.query))
|
||||
|
||||
watch(() => route.query.tag_id, (q) => {
|
||||
watch(() => route.query, (q) => {
|
||||
sel.clear() // result set changed — selected ids are no longer valid
|
||||
const tagId = parseInt(q, 10)
|
||||
store.setTagFilter(isNaN(tagId) ? null : tagId)
|
||||
})
|
||||
|
||||
watch(() => route.query.post_id, (q) => {
|
||||
sel.clear() // result set changed — selected ids are no longer valid
|
||||
const postId = parseInt(q, 10)
|
||||
store.setPostFilter(isNaN(postId) ? null : postId)
|
||||
store.applyFilterFromQuery(q)
|
||||
})
|
||||
|
||||
function openImage(id) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
placeholder="Search tags" clearable style="max-width: 320px;"
|
||||
/>
|
||||
<v-chip-group
|
||||
v-model="kind" selected-class="text-accent" mandatory="false"
|
||||
v-model="kind" selected-class="text-accent" :mandatory="false"
|
||||
>
|
||||
<v-chip v-for="k in KINDS" :key="k" :value="k" filter size="small">
|
||||
{{ k }}
|
||||
@@ -28,6 +28,7 @@
|
||||
v-for="c in store.cards" :key="c.id" :card="c"
|
||||
@open="openTag" @rename="onRename" @manage="onManage" @read="onRead"
|
||||
@merge-with="onMergeWith" @delete="onDeleteTag"
|
||||
@set-fandom="onSetFandom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -85,6 +86,13 @@
|
||||
: ''"
|
||||
@confirm="onDeleteTagConfirm"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="fandomDialogOpen" max-width="460">
|
||||
<FandomSetDialog
|
||||
v-if="fandomTarget" :tag="fandomTarget"
|
||||
@updated="onFandomUpdated" @cancel="fandomDialogOpen = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -98,6 +106,7 @@ import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||
import TagCard from '../components/discovery/TagCard.vue'
|
||||
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
||||
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
||||
import FandomSetDialog from '../components/modal/FandomSetDialog.vue'
|
||||
|
||||
// Must stay a subset of the backend TagKind enum (character, fandom,
|
||||
// general, series, archive, post). 'fandom' is this model's
|
||||
@@ -140,6 +149,18 @@ function openTag(tagId) {
|
||||
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
||||
}
|
||||
|
||||
// Character fandom editing (dots-menu → FandomSetDialog).
|
||||
const fandomDialogOpen = ref(false)
|
||||
const fandomTarget = ref(null)
|
||||
function onSetFandom(card) {
|
||||
fandomTarget.value = card
|
||||
fandomDialogOpen.value = true
|
||||
}
|
||||
function onFandomUpdated() {
|
||||
fandomDialogOpen.value = false
|
||||
store.reset() // reload so the card reflects the new fandom (or its removal)
|
||||
}
|
||||
|
||||
function onManage(id) {
|
||||
router.push({ name: 'series-manage', params: { tagId: id } })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import GalleryItem from '../../src/components/gallery/GalleryItem.vue'
|
||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
describe('GalleryItem', () => {
|
||||
const image = {
|
||||
id: 7, sha256: 'a'.repeat(64), mime: 'image/jpeg',
|
||||
width: 100, height: 100,
|
||||
thumbnail_url: '/images/thumbs/aa/abc.jpg', artist: null,
|
||||
}
|
||||
|
||||
it('reveals the thumbnail only once it has loaded, so tiles fade in individually', async () => {
|
||||
const pinia = freshPinia()
|
||||
const w = mountComponent(GalleryItem, { props: { image }, pinia })
|
||||
const img = w.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
|
||||
// Pre-load: the tile must NOT be revealed, so it can fade in when its
|
||||
// own thumbnail lands rather than pop together with its API batch.
|
||||
expect(img.classes()).not.toContain('is-loaded')
|
||||
|
||||
await img.trigger('load')
|
||||
|
||||
expect(img.classes()).toContain('is-loaded')
|
||||
})
|
||||
})
|
||||
@@ -9,44 +9,82 @@ function stubFetch(handler) {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: String(status),
|
||||
text: async () => (body == null ? '' : JSON.stringify(body))
|
||||
text: async () => (body == null ? '' : JSON.stringify(body)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const EMPTY = { images: [], date_groups: [], next_cursor: null }
|
||||
|
||||
describe('gallery store: tag/post filter exclusivity', () => {
|
||||
describe('gallery store: composable filter', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('setPostFilter sets post_id and clears tag_id', () => {
|
||||
it('applyFilterFromQuery parses the query and loadMore sends composable params', async () => {
|
||||
const s = useGalleryStore()
|
||||
stubFetch(() => ({ status: 200, body: EMPTY }))
|
||||
s.setTagFilter(3)
|
||||
expect(s.filter.tag_id).toBe(3)
|
||||
s.setPostFilter(7)
|
||||
expect(s.filter.post_id).toBe(7)
|
||||
expect(s.filter.tag_id).toBe(null)
|
||||
const urls = []
|
||||
stubFetch((url) => {
|
||||
urls.push(url)
|
||||
if (url.includes('/api/tags/')) {
|
||||
return { status: 200, body: { id: 1, name: 'X', kind: 'general' } }
|
||||
}
|
||||
return { status: 200, body: EMPTY }
|
||||
})
|
||||
await s.applyFilterFromQuery({ tag_id: '1,2', artist_id: '5', media: 'video', sort: 'oldest' })
|
||||
expect(s.filter.tag_ids).toEqual([1, 2])
|
||||
expect(s.filter.artist_id).toBe(5)
|
||||
expect(s.filter.media_type).toBe('video')
|
||||
expect(s.filter.sort).toBe('oldest')
|
||||
|
||||
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
|
||||
expect(scroll).toContain('tag_id=1,2')
|
||||
expect(scroll).toContain('artist_id=5')
|
||||
expect(scroll).toContain('media=video')
|
||||
expect(scroll).toContain('sort=oldest')
|
||||
})
|
||||
|
||||
it('setTagFilter clears post_id', () => {
|
||||
const s = useGalleryStore()
|
||||
stubFetch(() => ({ status: 200, body: EMPTY }))
|
||||
s.setPostFilter(7)
|
||||
s.setTagFilter(3)
|
||||
expect(s.filter.tag_id).toBe(3)
|
||||
expect(s.filter.post_id).toBe(null)
|
||||
})
|
||||
|
||||
it('loadMore sends exactly the active filter param', async () => {
|
||||
it('omits sort=newest and sends no filter params when empty', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
|
||||
s.setPostFilter(7)
|
||||
await s.loadMore()
|
||||
const scrollCall = urls.filter(u => u.includes('/api/gallery/scroll')).pop()
|
||||
expect(scrollCall).toContain('post_id=7')
|
||||
expect(scrollCall).not.toContain('tag_id=')
|
||||
await s.applyFilterFromQuery({})
|
||||
const scroll = urls.find((u) => u.includes('/api/gallery/scroll'))
|
||||
expect(scroll).not.toContain('sort=')
|
||||
expect(scroll).not.toContain('tag_id=')
|
||||
expect(scroll).not.toContain('media=')
|
||||
})
|
||||
|
||||
it('treats post_id as the exclusive filter param', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
|
||||
await s.applyFilterFromQuery({ post_id: '7', tag_id: '1' })
|
||||
expect(s.filter.post_id).toBe(7)
|
||||
const scroll = urls.find((u) => u.includes('/api/gallery/scroll'))
|
||||
expect(scroll).toContain('post_id=7')
|
||||
expect(scroll).not.toContain('tag_id=')
|
||||
})
|
||||
|
||||
it('noteTagLabel and noteArtistLabel pre-seed chip labels', () => {
|
||||
const s = useGalleryStore()
|
||||
s.noteTagLabel(9, 'Rukia')
|
||||
expect(s.tagLabels[9]).toBe('Rukia')
|
||||
s.noteArtistLabel('Kubo')
|
||||
expect(s.artistLabel).toBe('Kubo')
|
||||
})
|
||||
|
||||
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
// Non-null cursor: the old 10×serial-batch loop would have fired ten
|
||||
// requests here. The single-fetch path must fire exactly one.
|
||||
stubFetch((url) => {
|
||||
urls.push(url)
|
||||
return { status: 200, body: { images: [], date_groups: [], next_cursor: 'c1' } }
|
||||
})
|
||||
await s.loadInitial()
|
||||
const scrollCalls = urls.filter((u) => u.includes('/api/gallery/scroll'))
|
||||
expect(scrollCalls).toHaveLength(1)
|
||||
expect(scrollCalls[0]).toContain('limit=50')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useTagStore } from '../src/stores/tags.js'
|
||||
|
||||
function stubFetch(handler) {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
const { status, body } = handler(url, init)
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: String(status),
|
||||
text: async () => (body == null ? '' : JSON.stringify(body)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('tags store: setFandom', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('PATCHes fandom_id, and null to clear', async () => {
|
||||
const s = useTagStore()
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, init })
|
||||
return { status: 200, body: { id: 5, name: 'Ichigo', kind: 'character', fandom_id: 9 } }
|
||||
})
|
||||
const body = await s.setFandom(5, 9)
|
||||
expect(body.fandom_id).toBe(9)
|
||||
const last = calls.at(-1)
|
||||
expect(last.url).toContain('/api/tags/5')
|
||||
expect(last.init.method).toBe('PATCH')
|
||||
expect(JSON.parse(last.init.body)).toEqual({ fandom_id: 9 })
|
||||
|
||||
await s.setFandom(5, null)
|
||||
expect(JSON.parse(calls.at(-1).init.body)).toEqual({ fandom_id: null })
|
||||
})
|
||||
|
||||
it('sends merge: true when requested', async () => {
|
||||
const s = useTagStore()
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, init })
|
||||
return { status: 200, body: { id: 2 } }
|
||||
})
|
||||
await s.setFandom(7, 3, { merge: true })
|
||||
expect(JSON.parse(calls.at(-1).init.body)).toEqual({ fandom_id: 3, merge: true })
|
||||
})
|
||||
|
||||
it('throws ApiError carrying the 409 collision body', async () => {
|
||||
const s = useTagStore()
|
||||
stubFetch(() => ({
|
||||
status: 409,
|
||||
body: { error: 'exists', target: { id: 42, name: 'Renji' } },
|
||||
}))
|
||||
await expect(s.setFandom(1, 2)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
body: { target: { id: 42 } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ async def _seed(db, count: int = 3):
|
||||
origin="imported_filesystem", integrity_status="unknown",
|
||||
)
|
||||
r.created_at = base - timedelta(minutes=i)
|
||||
r.effective_date = r.created_at # no post → tracks created_at (0035)
|
||||
db.add(r)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
@@ -58,3 +59,21 @@ async def test_jump_requires_year_month(client):
|
||||
async def test_image_detail_404_when_missing(client):
|
||||
resp = await client.get("/api/gallery/image/99999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_sort_param_reverses(client, db):
|
||||
await _seed(db, 3)
|
||||
newest = await (await client.get("/api/gallery/scroll?limit=10&sort=newest")).get_json()
|
||||
oldest = await (await client.get("/api/gallery/scroll?limit=10&sort=oldest")).get_json()
|
||||
ids_new = [i["id"] for i in newest["images"]]
|
||||
ids_old = [i["id"] for i in oldest["images"]]
|
||||
assert ids_old == list(reversed(ids_new))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_media_param(client, db):
|
||||
await _seed(db, 2) # only image/jpeg seeded
|
||||
resp = await client.get("/api/gallery/scroll?limit=10&media=video")
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["images"] == []
|
||||
|
||||
@@ -3,11 +3,57 @@ import pytest
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def _mk(client, name, kind):
|
||||
r = await client.post("/api/tags", json={"name": name, "kind": kind})
|
||||
async def _mk(client, name, kind, fandom_id=None):
|
||||
body = {"name": name, "kind": kind}
|
||||
if fandom_id is not None:
|
||||
body["fandom_id"] = fandom_id
|
||||
r = await client.post("/api/tags", json=body)
|
||||
return (await r.get_json())["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tag_returns_shape_and_404(client):
|
||||
tid = await _mk(client, "Lookup", "general")
|
||||
resp = await client.get(f"/api/tags/{tid}")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["id"] == tid
|
||||
assert body["name"] == "Lookup"
|
||||
assert body["kind"] == "general"
|
||||
resp = await client.get("/api/tags/99999999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_sets_and_clears_character_fandom(client):
|
||||
fandom = await _mk(client, "Bleach", "fandom")
|
||||
char = await _mk(client, "Ichigo", "character")
|
||||
resp = await client.patch(f"/api/tags/{char}", json={"fandom_id": fandom})
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["fandom_id"] == fandom
|
||||
resp = await client.patch(f"/api/tags/{char}", json={"fandom_id": None})
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["fandom_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_fandom_collision_then_merge(client):
|
||||
f1 = await _mk(client, "Bleach", "fandom")
|
||||
f2 = await _mk(client, "Naruto", "fandom")
|
||||
c1 = await _mk(client, "Renji", "character", fandom_id=f1)
|
||||
c2 = await _mk(client, "Renji", "character", fandom_id=f2)
|
||||
# Moving c1 into f2 collides with c2 → rich 409 (same shape as rename).
|
||||
resp = await client.patch(f"/api/tags/{c1}", json={"fandom_id": f2})
|
||||
assert resp.status_code == 409
|
||||
assert (await resp.get_json())["target"]["id"] == c2
|
||||
# Confirm → merge c1 into c2, c2 survives.
|
||||
resp = await client.patch(
|
||||
f"/api/tags/{c1}", json={"fandom_id": f2, "merge": True}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["id"] == c2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_collision_returns_rich_409(client):
|
||||
tgt = await _mk(client, "Canonical", "general")
|
||||
|
||||
@@ -14,6 +14,7 @@ async def _img(db, n):
|
||||
origin="imported_filesystem", integrity_status="unknown",
|
||||
)
|
||||
rec.created_at = datetime.now(UTC) - timedelta(minutes=n)
|
||||
rec.effective_date = rec.created_at # no post → tracks created_at (0035)
|
||||
db.add(rec)
|
||||
await db.flush()
|
||||
return rec
|
||||
|
||||
@@ -22,6 +22,7 @@ async def _img(db, n):
|
||||
origin="imported_filesystem", integrity_status="unknown",
|
||||
)
|
||||
rec.created_at = base - timedelta(minutes=n)
|
||||
rec.effective_date = rec.created_at # no primary post → tracks created_at (0035)
|
||||
db.add(rec)
|
||||
await db.flush()
|
||||
return rec
|
||||
@@ -80,10 +81,10 @@ async def test_scroll_artist_id_filter(db):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mutually_exclusive_filters_raise(db):
|
||||
async def test_post_id_excludes_other_filters(db):
|
||||
svc = GalleryService(db)
|
||||
with pytest.raises(ValueError):
|
||||
await svc.scroll(cursor=None, limit=10, tag_id=1, post_id=2)
|
||||
await svc.scroll(cursor=None, limit=10, tag_ids=[1], post_id=2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -32,6 +32,8 @@ async def _seed_images(db, count: int, sha_prefix: str = "0") -> list[ImageRecor
|
||||
integrity_status="unknown",
|
||||
)
|
||||
r.created_at = base - timedelta(minutes=i)
|
||||
# No post → effective_date == created_at (alembic 0035 denorm).
|
||||
r.effective_date = r.created_at
|
||||
db.add(r)
|
||||
records.append(r)
|
||||
await db.flush()
|
||||
@@ -85,7 +87,7 @@ async def test_scroll_with_tag_filter(db):
|
||||
)
|
||||
|
||||
svc = GalleryService(db)
|
||||
page = await svc.scroll(cursor=None, limit=10, tag_id=tag.id)
|
||||
page = await svc.scroll(cursor=None, limit=10, tag_ids=[tag.id])
|
||||
assert len(page.images) == 1
|
||||
assert page.images[0].id == images[0].id
|
||||
|
||||
@@ -166,6 +168,9 @@ async def _seed_image_with_post(
|
||||
primary_post_id=post.id,
|
||||
)
|
||||
img.created_at = image_created_at
|
||||
# Mirror the importer's denorm (alembic 0035): a linked post with a date
|
||||
# sets effective_date to that date, else it falls back to created_at.
|
||||
img.effective_date = post_date or image_created_at
|
||||
db.add(img)
|
||||
await db.flush()
|
||||
return img, post
|
||||
@@ -201,6 +206,7 @@ async def test_scroll_sorts_by_post_date_when_available(db):
|
||||
origin="imported_filesystem", integrity_status="unknown",
|
||||
)
|
||||
img_c.created_at = base_import - timedelta(days=5)
|
||||
img_c.effective_date = img_c.created_at # no post → tracks created_at
|
||||
db.add(img_c)
|
||||
await db.flush()
|
||||
|
||||
@@ -266,3 +272,53 @@ async def test_get_image_with_tags_includes_posted_at_when_present(db):
|
||||
assert payload["posted_at"] is not None
|
||||
# Image's own created_at is still surfaced separately.
|
||||
assert payload["created_at"] != payload["posted_at"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_multi_tag_and(db):
|
||||
images = await _seed_images(db, 5)
|
||||
a = Tag(name="aa", kind=TagKind.general)
|
||||
b = Tag(name="bb", kind=TagKind.general)
|
||||
db.add_all([a, b])
|
||||
await db.flush()
|
||||
# images[0] carries BOTH tags; images[1] carries only a.
|
||||
await db.execute(image_tag.insert().values([
|
||||
{"image_record_id": images[0].id, "tag_id": a.id, "source": "manual"},
|
||||
{"image_record_id": images[0].id, "tag_id": b.id, "source": "manual"},
|
||||
{"image_record_id": images[1].id, "tag_id": a.id, "source": "manual"},
|
||||
]))
|
||||
svc = GalleryService(db)
|
||||
both = await svc.scroll(cursor=None, limit=10, tag_ids=[a.id, b.id])
|
||||
assert [i.id for i in both.images] == [images[0].id] # AND: only the both-tagged
|
||||
just_a = await svc.scroll(cursor=None, limit=10, tag_ids=[a.id])
|
||||
assert {i.id for i in just_a.images} == {images[0].id, images[1].id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_media_filter(db):
|
||||
imgs = await _seed_images(db, 2) # image/jpeg
|
||||
vid = ImageRecord(
|
||||
path="/images/test/v.mp4", sha256="v" * 64, size_bytes=1,
|
||||
mime="video/mp4", width=1, height=1,
|
||||
origin="imported_filesystem", integrity_status="unknown",
|
||||
)
|
||||
vid.created_at = _now()
|
||||
vid.effective_date = vid.created_at
|
||||
db.add(vid)
|
||||
await db.flush()
|
||||
svc = GalleryService(db)
|
||||
vids = await svc.scroll(cursor=None, limit=10, media_type="video")
|
||||
assert [i.id for i in vids.images] == [vid.id]
|
||||
pics = await svc.scroll(cursor=None, limit=10, media_type="image")
|
||||
assert {i.id for i in pics.images} == {i.id for i in imgs}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_sort_oldest_reverses_order(db):
|
||||
images = await _seed_images(db, 4) # images[0] newest ... images[3] oldest
|
||||
svc = GalleryService(db)
|
||||
newest = await svc.scroll(cursor=None, limit=10)
|
||||
oldest = await svc.scroll(cursor=None, limit=10, sort="oldest")
|
||||
newest_ids = [i.id for i in newest.images]
|
||||
assert newest_ids[0] == images[0].id
|
||||
assert [i.id for i in oldest.images] == list(reversed(newest_ids))
|
||||
|
||||
@@ -94,6 +94,9 @@ def test_sidecar_creates_provenance(importer, import_layout):
|
||||
prov = importer.session.execute(select(ImageProvenance)).scalar_one()
|
||||
assert prov.image_record_id == rec.id and prov.post_id == post.id
|
||||
assert rec.primary_post_id == post.id
|
||||
# Denormalized gallery sort key (alembic 0035) tracks the primary post's
|
||||
# publish date so /scroll orders off ix_image_record_effective_date.
|
||||
assert rec.effective_date == post.post_date
|
||||
|
||||
|
||||
def test_reimport_same_post_idempotent(importer, import_layout):
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import pytest
|
||||
|
||||
from backend.app.models import TagKind
|
||||
from backend.app.services.tag_service import TagService, TagValidationError
|
||||
from backend.app.services.tag_service import (
|
||||
TagMergeConflict,
|
||||
TagService,
|
||||
TagValidationError,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -101,3 +105,64 @@ async def test_autocomplete_empty_query_returns_nothing(db):
|
||||
svc = TagService(db)
|
||||
await svc.find_or_create("Foo", TagKind.artist)
|
||||
assert await svc.autocomplete("") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_fandom_assigns_changes_and_clears(db):
|
||||
svc = TagService(db)
|
||||
f1 = await svc.find_or_create("Bleach", TagKind.fandom)
|
||||
f2 = await svc.find_or_create("Naruto", TagKind.fandom)
|
||||
char = await svc.find_or_create("Ichigo", TagKind.character)
|
||||
assert char.fandom_id is None
|
||||
assert (await svc.set_fandom(char.id, f1.id)).fandom_id == f1.id
|
||||
assert (await svc.set_fandom(char.id, f2.id)).fandom_id == f2.id
|
||||
assert (await svc.set_fandom(char.id, None)).fandom_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_fandom_rejects_non_character(db):
|
||||
svc = TagService(db)
|
||||
fandom = await svc.find_or_create("Naruto", TagKind.fandom)
|
||||
series = await svc.find_or_create("Arc 1", TagKind.series)
|
||||
with pytest.raises(TagValidationError, match="character"):
|
||||
await svc.set_fandom(series.id, fandom.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_fandom_rejects_non_fandom_reference(db):
|
||||
svc = TagService(db)
|
||||
general = await svc.find_or_create("misc", TagKind.general)
|
||||
char = await svc.find_or_create("Rukia", TagKind.character)
|
||||
with pytest.raises(TagValidationError, match="fandom"):
|
||||
await svc.set_fandom(char.id, general.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_fandom_collision_raises_merge_conflict(db):
|
||||
svc = TagService(db)
|
||||
f1 = await svc.find_or_create("Bleach", TagKind.fandom)
|
||||
f2 = await svc.find_or_create("Naruto", TagKind.fandom)
|
||||
c1 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f1.id)
|
||||
c2 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f2.id)
|
||||
with pytest.raises(TagMergeConflict) as ei:
|
||||
await svc.set_fandom(c1.id, f2.id) # collides with c2 in f2
|
||||
assert ei.value.target_id == c2.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_fandom_merge_resolves_collision(db):
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import Tag
|
||||
|
||||
svc = TagService(db)
|
||||
f1 = await svc.find_or_create("Bleach", TagKind.fandom)
|
||||
f2 = await svc.find_or_create("Naruto", TagKind.fandom)
|
||||
c1 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f1.id)
|
||||
c2 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f2.id)
|
||||
survivor = await svc.set_fandom(c1.id, f2.id, merge=True)
|
||||
assert survivor.id == c2.id # merged INTO the existing character
|
||||
gone = (
|
||||
await db.execute(select(Tag).where(Tag.id == c1.id))
|
||||
).scalar_one_or_none()
|
||||
assert gone is None
|
||||
|
||||
Reference in New Issue
Block a user