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 Code per-user local overrides (shared .claude/settings.json is OK to commit)
|
||||||
.claude/settings.local.json
|
.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 / DB scratch
|
||||||
alembic/versions/__pycache__/
|
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")
|
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"])
|
@gallery_bp.route("/scroll", methods=["GET"])
|
||||||
async def scroll():
|
async def scroll():
|
||||||
cursor = request.args.get("cursor") or None
|
cursor = request.args.get("cursor") or None
|
||||||
try:
|
try:
|
||||||
limit = int(request.args.get("limit", "50"))
|
limit = int(request.args.get("limit", "50"))
|
||||||
|
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": "limit must be an integer"}), 400
|
return jsonify({"error": "invalid filter or limit parameter"}), 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:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
page = await svc.scroll(
|
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,
|
post_id=post_id, artist_id=artist_id,
|
||||||
|
media_type=media_type, sort=sort,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
@@ -58,17 +74,16 @@ async def scroll():
|
|||||||
|
|
||||||
@gallery_bp.route("/timeline", methods=["GET"])
|
@gallery_bp.route("/timeline", methods=["GET"])
|
||||||
async def timeline():
|
async def timeline():
|
||||||
tag_id_raw = request.args.get("tag_id")
|
try:
|
||||||
tag_id = int(tag_id_raw) if tag_id_raw else None
|
tag_ids, post_id, artist_id, media_type, _sort = _parse_filters()
|
||||||
post_id_raw = request.args.get("post_id")
|
except ValueError:
|
||||||
post_id = int(post_id_raw) if post_id_raw else None
|
return jsonify({"error": "invalid filter parameter"}), 400
|
||||||
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:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
buckets = await svc.timeline(
|
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:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
@@ -82,20 +97,16 @@ async def jump():
|
|||||||
try:
|
try:
|
||||||
year = int(request.args["year"])
|
year = int(request.args["year"])
|
||||||
month = int(request.args["month"])
|
month = int(request.args["month"])
|
||||||
|
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
return jsonify({"error": "year and month query params required"}), 400
|
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:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
cursor = await svc.jump_cursor(
|
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,
|
post_id=post_id, artist_id=artist_id,
|
||||||
|
media_type=media_type, sort=sort,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
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
|
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"])
|
@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
|
||||||
async def rename_tag(tag_id: int):
|
async def update_tag(tag_id: int):
|
||||||
body = await request.get_json()
|
"""Rename and/or re-fandom a tag. Body may carry `name` and/or
|
||||||
if not body or "name" not in body:
|
`fandom_id` (a fandom tag id, or null to clear — character tags only).
|
||||||
return jsonify({"error": "name required"}), 400
|
`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:
|
async with get_session() as session:
|
||||||
svc = TagService(session)
|
svc = TagService(session)
|
||||||
try:
|
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:
|
except TagMergeConflict as exc:
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
@@ -219,7 +250,12 @@ async def rename_tag(tag_id: int):
|
|||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return jsonify(
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
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(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@@ -43,16 +43,17 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]:
|
|||||||
|
|
||||||
|
|
||||||
def _effective_date_col():
|
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
|
Canonical sort/group/filter key across the gallery so images attached
|
||||||
images backfilled with primary_post_id (e.g. via tag_apply phase 4)
|
to a post surface at their original publish date, not their FC import
|
||||||
surface at their original publish date, not their FC import date.
|
date — and, now that it's a single indexed column rather than a
|
||||||
Images without a Post (or with Post.post_date NULL) fall back to
|
COALESCE across the Post outer join, the cursor scroll is an index
|
||||||
image_record.created_at and still order coherently against
|
range scan instead of a full re-sort per page.
|
||||||
post-attached ones.
|
|
||||||
"""
|
"""
|
||||||
return func.coalesce(Post.post_date, ImageRecord.created_at)
|
return ImageRecord.effective_date
|
||||||
|
|
||||||
|
|
||||||
def _outer_join_primary_post(stmt: Select) -> Select:
|
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}"
|
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
|
||||||
|
|
||||||
|
|
||||||
def _require_single_filter(tag_id, post_id, artist_id) -> None:
|
def _require_single_filter(tag_ids, post_id, artist_id) -> None:
|
||||||
if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1:
|
"""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(
|
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):
|
def _provenance_clause(post_id, artist_id):
|
||||||
"""Correlated EXISTS clause (NOT a join) so an image with multiple
|
"""Correlated EXISTS clause (NOT a join) so an image with multiple
|
||||||
matching provenance rows is returned exactly once and the
|
matching provenance rows is returned exactly once and the
|
||||||
@@ -179,35 +210,43 @@ class GalleryService:
|
|||||||
self,
|
self,
|
||||||
cursor: str | None,
|
cursor: str | None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
tag_id: int | None = None,
|
tag_ids: list[int] | None = None,
|
||||||
post_id: int | None = None,
|
post_id: int | None = None,
|
||||||
artist_id: int | None = None,
|
artist_id: int | None = None,
|
||||||
|
media_type: str | None = None,
|
||||||
|
sort: str = "newest",
|
||||||
) -> GalleryPage:
|
) -> GalleryPage:
|
||||||
if limit < 1 or limit > 200:
|
if limit < 1 or limit > 200:
|
||||||
raise ValueError("limit must be between 1 and 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()
|
eff = _effective_date_col()
|
||||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||||
stmt = _outer_join_primary_post(stmt)
|
stmt = _outer_join_primary_post(stmt)
|
||||||
if tag_id is not None:
|
stmt = _apply_scope(
|
||||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||||
image_tag.c.tag_id == tag_id
|
artist_id=artist_id, media_type=media_type,
|
||||||
)
|
)
|
||||||
prov = _provenance_clause(post_id, artist_id)
|
|
||||||
if prov is not None:
|
|
||||||
stmt = stmt.where(prov)
|
|
||||||
|
|
||||||
|
descending = sort != "oldest"
|
||||||
if cursor:
|
if cursor:
|
||||||
cur_ts, cur_id = decode_cursor(cursor)
|
cur_ts, cur_id = decode_cursor(cursor)
|
||||||
stmt = stmt.where(
|
# The cursor is just (last eff, last id); the request's sort
|
||||||
or_(
|
# decides which side of it the next page lies on.
|
||||||
eff < cur_ts,
|
if descending:
|
||||||
and_(eff == cur_ts, ImageRecord.id < cur_id),
|
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()
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
|
||||||
next_cursor = None
|
next_cursor = None
|
||||||
@@ -243,9 +282,10 @@ class GalleryService:
|
|||||||
|
|
||||||
async def timeline(
|
async def timeline(
|
||||||
self,
|
self,
|
||||||
tag_id: int | None = None,
|
tag_ids: list[int] | None = None,
|
||||||
post_id: int | None = None,
|
post_id: int | None = None,
|
||||||
artist_id: int | None = None,
|
artist_id: int | None = None,
|
||||||
|
media_type: str | None = None,
|
||||||
) -> list[TimelineBucket]:
|
) -> list[TimelineBucket]:
|
||||||
eff = _effective_date_col()
|
eff = _effective_date_col()
|
||||||
year_col = func.date_part("year", eff).label("yr")
|
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")
|
year_col, month_col, func.count(ImageRecord.id).label("cnt")
|
||||||
)
|
)
|
||||||
stmt = _outer_join_primary_post(stmt)
|
stmt = _outer_join_primary_post(stmt)
|
||||||
_require_single_filter(tag_id, post_id, artist_id)
|
_require_single_filter(tag_ids, post_id, artist_id)
|
||||||
if tag_id is not None:
|
stmt = _apply_scope(
|
||||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||||
image_tag.c.tag_id == tag_id
|
artist_id=artist_id, media_type=media_type,
|
||||||
)
|
)
|
||||||
prov = _provenance_clause(post_id, artist_id)
|
|
||||||
if prov is not None:
|
|
||||||
stmt = stmt.where(prov)
|
|
||||||
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
|
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
|
||||||
rows = (await self.session.execute(stmt)).all()
|
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]
|
return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows]
|
||||||
|
|
||||||
async def jump_cursor(
|
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,
|
post_id: int | None = None, artist_id: int | None = None,
|
||||||
|
media_type: str | None = None, sort: str = "newest",
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Returns a cursor that, when passed to scroll(), positions at the
|
"""Returns a cursor that, when passed to scroll() with the same sort,
|
||||||
first image of the given year-month (by effective_date, not
|
positions at the first image of the given year-month. None if the
|
||||||
created_at). None if the bucket is empty.
|
bucket is empty.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import extract
|
from sqlalchemy import extract
|
||||||
|
|
||||||
@@ -282,22 +320,24 @@ class GalleryService:
|
|||||||
extract("month", eff) == month,
|
extract("month", eff) == month,
|
||||||
)
|
)
|
||||||
stmt = _outer_join_primary_post(stmt)
|
stmt = _outer_join_primary_post(stmt)
|
||||||
_require_single_filter(tag_id, post_id, artist_id)
|
_require_single_filter(tag_ids, post_id, artist_id)
|
||||||
if tag_id is not None:
|
stmt = _apply_scope(
|
||||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||||
image_tag.c.tag_id == tag_id
|
artist_id=artist_id, media_type=media_type,
|
||||||
)
|
)
|
||||||
prov = _provenance_clause(post_id, artist_id)
|
descending = sort != "oldest"
|
||||||
if prov is not None:
|
if descending:
|
||||||
stmt = stmt.where(prov)
|
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
|
||||||
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(1)
|
else:
|
||||||
first = (await self.session.execute(stmt)).first()
|
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
|
||||||
|
first = (await self.session.execute(stmt.limit(1))).first()
|
||||||
if first is None:
|
if first is None:
|
||||||
return None
|
return None
|
||||||
record, eff_date = first
|
record, eff_date = first
|
||||||
# Cursor is exclusive; we encode a cursor with id+1 so the row itself
|
# Cursor is exclusive; nudge the id one past the boundary row (in the
|
||||||
# is the first result in the next scroll().
|
# scan direction) so the row itself is the first result of scroll().
|
||||||
return encode_cursor(eff_date, record.id + 1)
|
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:
|
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
||||||
record = await self.session.get(ImageRecord, image_id)
|
record = await self.session.get(ImageRecord, image_id)
|
||||||
@@ -357,17 +397,10 @@ class GalleryService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def _neighbors(self, record: ImageRecord) -> dict:
|
async def _neighbors(self, record: ImageRecord) -> dict:
|
||||||
# Compute the boundary image's effective_date in Python (one query
|
# The boundary image's sort key is materialized on the row now
|
||||||
# below + the SELECT we already have on `record`) and use it for
|
# (alembic 0035) — read it directly instead of re-deriving COALESCE
|
||||||
# the neighbor comparison. Cheaper than re-deriving in SQL via
|
# via an extra Post lookup.
|
||||||
# correlated subquery.
|
boundary_eff = record.effective_date
|
||||||
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
|
|
||||||
|
|
||||||
eff = _effective_date_col()
|
eff = _effective_date_col()
|
||||||
prev_stmt = _outer_join_primary_post(
|
prev_stmt = _outer_join_primary_post(
|
||||||
|
|||||||
@@ -960,6 +960,14 @@ class Importer:
|
|||||||
sp.rollback()
|
sp.rollback()
|
||||||
if record.primary_post_id is None:
|
if record.primary_post_id is None:
|
||||||
record.primary_post_id = post.id
|
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()
|
self.session.flush()
|
||||||
|
|
||||||
def _copy_to_library(
|
def _copy_to_library(
|
||||||
|
|||||||
@@ -280,6 +280,69 @@ class TagService:
|
|||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return tag
|
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:
|
async def merge(self, source_id: int, target_id: int) -> MergeResult:
|
||||||
"""Transactionally repoint every FK from source→target, optionally
|
"""Transactionally repoint every FK from source→target, optionally
|
||||||
keep source's name as a tagger alias, delete source. Atomic: any
|
keep source's name as a tagger alias, delete source. Atomic: any
|
||||||
@@ -298,7 +361,14 @@ class TagService:
|
|||||||
raise TagValidationError(
|
raise TagValidationError(
|
||||||
"Tags must be the same kind and fandom to merge"
|
"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)
|
keep_as_alias = await self._keep_as_alias(source_id)
|
||||||
source_name = source.name
|
source_name = source.name
|
||||||
source_kind = source.kind
|
source_kind = source.kind
|
||||||
|
|||||||
@@ -55,6 +55,12 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<v-list density="compact">
|
<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
|
<v-list-item
|
||||||
title="Merge with…"
|
title="Merge with…"
|
||||||
prepend-icon="mdi-call-merge"
|
prepend-icon="mdi-call-merge"
|
||||||
@@ -78,7 +84,9 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({ card: { type: Object, required: true } })
|
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 editing = ref(false)
|
||||||
const draft = ref('')
|
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">
|
<div class="fc-gallery-item__media">
|
||||||
<img
|
<img
|
||||||
v-if="!isVideo" :src="image.thumbnail_url" :alt="`Image ${image.id}`"
|
v-if="!isVideo" ref="imgEl" :src="image.thumbnail_url"
|
||||||
loading="lazy" @error="onThumbError"
|
:alt="`Image ${image.id}`" loading="lazy"
|
||||||
|
:class="{ 'is-loaded': loaded }"
|
||||||
|
@load="loaded = true" @error="onThumbError"
|
||||||
>
|
>
|
||||||
<div v-else class="fc-gallery-item__video-thumb">
|
<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" />
|
<v-icon class="fc-gallery-item__video-badge" icon="mdi-play-circle" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="thumbError" class="fc-gallery-item__placeholder">
|
<div v-if="thumbError" class="fc-gallery-item__placeholder">
|
||||||
@@ -38,7 +43,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
|
import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
|
||||||
|
|
||||||
const props = defineProps({ image: { type: Object, required: true } })
|
const props = defineProps({ image: { type: Object, required: true } })
|
||||||
@@ -46,6 +51,18 @@ const emit = defineEmits(['open'])
|
|||||||
|
|
||||||
const sel = useGallerySelectionStore()
|
const sel = useGallerySelectionStore()
|
||||||
const thumbError = ref(false)
|
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(
|
const isVideo = computed(
|
||||||
() => props.image.mime && props.image.mime.startsWith('video/')
|
() => props.image.mime && props.image.mime.startsWith('video/')
|
||||||
)
|
)
|
||||||
@@ -82,6 +99,30 @@ function onThumbError() { thumbError.value = true }
|
|||||||
}
|
}
|
||||||
.fc-gallery-item__media img {
|
.fc-gallery-item__media img {
|
||||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
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-thumb { position: relative; height: 100%; }
|
||||||
.fc-gallery-item__video-badge {
|
.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 @click="openRename(tag)">
|
||||||
<v-list-item-title>Rename…</v-list-item-title>
|
<v-list-item-title>Rename…</v-list-item-title>
|
||||||
</v-list-item>
|
</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-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</span>
|
</span>
|
||||||
@@ -57,6 +62,13 @@
|
|||||||
@renamed="onRenamed" @cancel="renameDialog = false"
|
@renamed="onRenamed" @cancel="renameDialog = false"
|
||||||
/>
|
/>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog v-model="fandomDialog" max-width="460">
|
||||||
|
<FandomSetDialog
|
||||||
|
v-if="fandomTarget" :tag="fandomTarget"
|
||||||
|
@updated="onFandomUpdated" @cancel="fandomDialog = false"
|
||||||
|
/>
|
||||||
|
</v-dialog>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -67,6 +79,7 @@ import { useTagStore } from '../../stores/tags.js'
|
|||||||
import TagAutocomplete from './TagAutocomplete.vue'
|
import TagAutocomplete from './TagAutocomplete.vue'
|
||||||
import SuggestionsPanel from './SuggestionsPanel.vue'
|
import SuggestionsPanel from './SuggestionsPanel.vue'
|
||||||
import TagRenameDialog from './TagRenameDialog.vue'
|
import TagRenameDialog from './TagRenameDialog.vue'
|
||||||
|
import FandomSetDialog from './FandomSetDialog.vue'
|
||||||
|
|
||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
const store = useTagStore()
|
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.
|
// Reflect the new name in the modal's current tag list without a full reload.
|
||||||
await modal.reloadTags()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { ref, computed } from 'vue'
|
|||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
|
|
||||||
// Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one
|
// Initial paint is a SINGLE request (INITIAL_LIMIT). The old 10×serial-
|
||||||
// 50-item request so items render as each batch lands. Total initial
|
// batch loop (2026-05-30) only staggered METADATA, which isn't the visual
|
||||||
// count is unchanged (PAGE * INITIAL_BATCHES = 50). Infinite-scroll also
|
// bottleneck — thumbnails load as independent `<img>` requests, and
|
||||||
// pulls PAGE per trigger to keep appends progressive.
|
// GalleryItem now reveals each tile on its own image `@load`. One fetch is
|
||||||
const PAGE = 5
|
// far fewer round-trips and faster to first paint; the reveal-on-load is
|
||||||
const INITIAL_BATCHES = 10
|
// 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', () => {
|
export const useGalleryStore = defineStore('gallery', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -18,7 +21,14 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
const nextCursor = ref(null)
|
const nextCursor = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
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 timelineBuckets = ref([])
|
||||||
const timelineLoading = ref(false)
|
const timelineLoading = ref(false)
|
||||||
@@ -32,22 +42,16 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
images.value = []
|
images.value = []
|
||||||
dateGroups.value = []
|
dateGroups.value = []
|
||||||
nextCursor.value = null
|
nextCursor.value = null
|
||||||
// Sequentially fetch INITIAL_BATCHES chunks so items render as each
|
await loadMore(INITIAL_LIMIT)
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore(limit = PAGE) {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
const t = inflight.claim()
|
const t = inflight.claim()
|
||||||
try {
|
try {
|
||||||
const params = { limit: PAGE, ...activeFilterParam() }
|
const params = { limit, ...activeFilterParam() }
|
||||||
if (nextCursor.value) params.cursor = nextCursor.value
|
if (nextCursor.value) params.cursor = nextCursor.value
|
||||||
const body = await api.get('/api/gallery/scroll', { params })
|
const body = await api.get('/api/gallery/scroll', { params })
|
||||||
if (!t.isCurrent()) return
|
if (!t.isCurrent()) return
|
||||||
@@ -89,23 +93,63 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function activeFilterParam() {
|
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 }
|
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) {
|
// URL is the source of truth for filters. GalleryView calls this on mount
|
||||||
filter.value.tag_id = tagId
|
// and on every route-query change; the filter bar mutates the URL
|
||||||
filter.value.post_id = null
|
// (router.push) rather than the store directly, so deep-links, the back
|
||||||
loadInitial()
|
// button, and bar actions all funnel through one path.
|
||||||
loadTimeline()
|
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) {
|
function _toId(v) {
|
||||||
filter.value.post_id = postId
|
const n = Number(v)
|
||||||
filter.value.tag_id = null
|
return Number.isInteger(n) && n > 0 ? n : null
|
||||||
loadInitial()
|
}
|
||||||
loadTimeline()
|
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)
|
const hasMore = computed(() => nextCursor.value !== null)
|
||||||
@@ -113,8 +157,9 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
images, dateGroups, hasMore, isEmpty, loading, error,
|
images, dateGroups, hasMore, isEmpty, loading, error,
|
||||||
filter, timelineBuckets, timelineLoading,
|
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
|
||||||
loadInitial, loadMore, loadTimeline, jumpTo, setTagFilter, setPostFilter
|
loadInitial, loadMore, loadTimeline, jumpTo,
|
||||||
|
applyFilterFromQuery, noteTagLabel, noteArtistLabel,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,21 @@ export const useTagStore = defineStore('tags', () => {
|
|||||||
return fandom
|
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 kindOptions() { return KIND_OPTIONS }
|
||||||
function colorFor(kind) { return KIND_COLOR[kind] || 'on-surface' }
|
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">
|
||||||
<div class="fc-gallery-layout__main">
|
<div class="fc-gallery-layout__main">
|
||||||
<PostInfoHeader />
|
<PostInfoHeader />
|
||||||
|
<GalleryFilterBar v-if="store.filter.post_id == null" />
|
||||||
<EmptyState v-if="store.isEmpty" />
|
<EmptyState v-if="store.isEmpty" />
|
||||||
<GalleryGrid v-else @open="openImage" />
|
<GalleryGrid v-else @open="openImage" />
|
||||||
</div>
|
</div>
|
||||||
@@ -28,6 +29,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { useGalleryStore } from '../stores/gallery.js'
|
import { useGalleryStore } from '../stores/gallery.js'
|
||||||
import { useModalStore } from '../stores/modal.js'
|
import { useModalStore } from '../stores/modal.js'
|
||||||
import GalleryGrid from '../components/gallery/GalleryGrid.vue'
|
import GalleryGrid from '../components/gallery/GalleryGrid.vue'
|
||||||
|
import GalleryFilterBar from '../components/gallery/GalleryFilterBar.vue'
|
||||||
import TimelineSidebar from '../components/gallery/TimelineSidebar.vue'
|
import TimelineSidebar from '../components/gallery/TimelineSidebar.vue'
|
||||||
import EmptyState from '../components/gallery/EmptyState.vue'
|
import EmptyState from '../components/gallery/EmptyState.vue'
|
||||||
import PostInfoHeader from '../components/gallery/PostInfoHeader.vue'
|
import PostInfoHeader from '../components/gallery/PostInfoHeader.vue'
|
||||||
@@ -39,25 +41,13 @@ const modal = useModalStore()
|
|||||||
const sel = useGallerySelectionStore()
|
const sel = useGallerySelectionStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
onMounted(async () => {
|
// The URL query is the single source of truth for filters. Apply it on
|
||||||
const postId = parseInt(route.query.post_id, 10)
|
// mount and on any query change (filter bar pushes, back button, deep-link).
|
||||||
const tagId = parseInt(route.query.tag_id, 10)
|
onMounted(() => store.applyFilterFromQuery(route.query))
|
||||||
if (!isNaN(postId)) store.setPostFilter(postId)
|
|
||||||
else if (!isNaN(tagId)) store.setTagFilter(tagId)
|
|
||||||
await store.loadInitial()
|
|
||||||
await store.loadTimeline()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => route.query.tag_id, (q) => {
|
watch(() => route.query, (q) => {
|
||||||
sel.clear() // result set changed — selected ids are no longer valid
|
sel.clear() // result set changed — selected ids are no longer valid
|
||||||
const tagId = parseInt(q, 10)
|
store.applyFilterFromQuery(q)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function openImage(id) {
|
function openImage(id) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
placeholder="Search tags" clearable style="max-width: 320px;"
|
placeholder="Search tags" clearable style="max-width: 320px;"
|
||||||
/>
|
/>
|
||||||
<v-chip-group
|
<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">
|
<v-chip v-for="k in KINDS" :key="k" :value="k" filter size="small">
|
||||||
{{ k }}
|
{{ k }}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
v-for="c in store.cards" :key="c.id" :card="c"
|
v-for="c in store.cards" :key="c.id" :card="c"
|
||||||
@open="openTag" @rename="onRename" @manage="onManage" @read="onRead"
|
@open="openTag" @rename="onRename" @manage="onManage" @read="onRead"
|
||||||
@merge-with="onMergeWith" @delete="onDeleteTag"
|
@merge-with="onMergeWith" @delete="onDeleteTag"
|
||||||
|
@set-fandom="onSetFandom"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,6 +86,13 @@
|
|||||||
: ''"
|
: ''"
|
||||||
@confirm="onDeleteTagConfirm"
|
@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>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -98,6 +106,7 @@ import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
|||||||
import TagCard from '../components/discovery/TagCard.vue'
|
import TagCard from '../components/discovery/TagCard.vue'
|
||||||
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
||||||
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.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,
|
// Must stay a subset of the backend TagKind enum (character, fandom,
|
||||||
// general, series, archive, post). 'fandom' is this model's
|
// general, series, archive, post). 'fandom' is this model's
|
||||||
@@ -140,6 +149,18 @@ function openTag(tagId) {
|
|||||||
router.push({ name: 'gallery', query: { tag_id: 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) {
|
function onManage(id) {
|
||||||
router.push({ name: 'series-manage', params: { tagId: 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,
|
ok: status >= 200 && status < 300,
|
||||||
status,
|
status,
|
||||||
statusText: String(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 }
|
const EMPTY = { images: [], date_groups: [], next_cursor: null }
|
||||||
|
|
||||||
describe('gallery store: tag/post filter exclusivity', () => {
|
describe('gallery store: composable filter', () => {
|
||||||
beforeEach(() => setActivePinia(createPinia()))
|
beforeEach(() => setActivePinia(createPinia()))
|
||||||
afterEach(() => vi.restoreAllMocks())
|
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()
|
const s = useGalleryStore()
|
||||||
stubFetch(() => ({ status: 200, body: EMPTY }))
|
const urls = []
|
||||||
s.setTagFilter(3)
|
stubFetch((url) => {
|
||||||
expect(s.filter.tag_id).toBe(3)
|
urls.push(url)
|
||||||
s.setPostFilter(7)
|
if (url.includes('/api/tags/')) {
|
||||||
expect(s.filter.post_id).toBe(7)
|
return { status: 200, body: { id: 1, name: 'X', kind: 'general' } }
|
||||||
expect(s.filter.tag_id).toBe(null)
|
}
|
||||||
|
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', () => {
|
it('omits sort=newest and sends no filter params when empty', async () => {
|
||||||
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 () => {
|
|
||||||
const s = useGalleryStore()
|
const s = useGalleryStore()
|
||||||
const urls = []
|
const urls = []
|
||||||
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
|
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
|
||||||
s.setPostFilter(7)
|
await s.applyFilterFromQuery({})
|
||||||
await s.loadMore()
|
const scroll = urls.find((u) => u.includes('/api/gallery/scroll'))
|
||||||
const scrollCall = urls.filter(u => u.includes('/api/gallery/scroll')).pop()
|
expect(scroll).not.toContain('sort=')
|
||||||
expect(scrollCall).toContain('post_id=7')
|
expect(scroll).not.toContain('tag_id=')
|
||||||
expect(scrollCall).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",
|
origin="imported_filesystem", integrity_status="unknown",
|
||||||
)
|
)
|
||||||
r.created_at = base - timedelta(minutes=i)
|
r.created_at = base - timedelta(minutes=i)
|
||||||
|
r.effective_date = r.created_at # no post → tracks created_at (0035)
|
||||||
db.add(r)
|
db.add(r)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -58,3 +59,21 @@ async def test_jump_requires_year_month(client):
|
|||||||
async def test_image_detail_404_when_missing(client):
|
async def test_image_detail_404_when_missing(client):
|
||||||
resp = await client.get("/api/gallery/image/99999")
|
resp = await client.get("/api/gallery/image/99999")
|
||||||
assert resp.status_code == 404
|
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
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
async def _mk(client, name, kind):
|
async def _mk(client, name, kind, fandom_id=None):
|
||||||
r = await client.post("/api/tags", json={"name": name, "kind": kind})
|
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"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_rename_collision_returns_rich_409(client):
|
async def test_rename_collision_returns_rich_409(client):
|
||||||
tgt = await _mk(client, "Canonical", "general")
|
tgt = await _mk(client, "Canonical", "general")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ async def _img(db, n):
|
|||||||
origin="imported_filesystem", integrity_status="unknown",
|
origin="imported_filesystem", integrity_status="unknown",
|
||||||
)
|
)
|
||||||
rec.created_at = datetime.now(UTC) - timedelta(minutes=n)
|
rec.created_at = datetime.now(UTC) - timedelta(minutes=n)
|
||||||
|
rec.effective_date = rec.created_at # no post → tracks created_at (0035)
|
||||||
db.add(rec)
|
db.add(rec)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return rec
|
return rec
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async def _img(db, n):
|
|||||||
origin="imported_filesystem", integrity_status="unknown",
|
origin="imported_filesystem", integrity_status="unknown",
|
||||||
)
|
)
|
||||||
rec.created_at = base - timedelta(minutes=n)
|
rec.created_at = base - timedelta(minutes=n)
|
||||||
|
rec.effective_date = rec.created_at # no primary post → tracks created_at (0035)
|
||||||
db.add(rec)
|
db.add(rec)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return rec
|
return rec
|
||||||
@@ -80,10 +81,10 @@ async def test_scroll_artist_id_filter(db):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mutually_exclusive_filters_raise(db):
|
async def test_post_id_excludes_other_filters(db):
|
||||||
svc = GalleryService(db)
|
svc = GalleryService(db)
|
||||||
with pytest.raises(ValueError):
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ async def _seed_images(db, count: int, sha_prefix: str = "0") -> list[ImageRecor
|
|||||||
integrity_status="unknown",
|
integrity_status="unknown",
|
||||||
)
|
)
|
||||||
r.created_at = base - timedelta(minutes=i)
|
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)
|
db.add(r)
|
||||||
records.append(r)
|
records.append(r)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -85,7 +87,7 @@ async def test_scroll_with_tag_filter(db):
|
|||||||
)
|
)
|
||||||
|
|
||||||
svc = GalleryService(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 len(page.images) == 1
|
||||||
assert page.images[0].id == images[0].id
|
assert page.images[0].id == images[0].id
|
||||||
|
|
||||||
@@ -166,6 +168,9 @@ async def _seed_image_with_post(
|
|||||||
primary_post_id=post.id,
|
primary_post_id=post.id,
|
||||||
)
|
)
|
||||||
img.created_at = image_created_at
|
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)
|
db.add(img)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return img, post
|
return img, post
|
||||||
@@ -201,6 +206,7 @@ async def test_scroll_sorts_by_post_date_when_available(db):
|
|||||||
origin="imported_filesystem", integrity_status="unknown",
|
origin="imported_filesystem", integrity_status="unknown",
|
||||||
)
|
)
|
||||||
img_c.created_at = base_import - timedelta(days=5)
|
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)
|
db.add(img_c)
|
||||||
await db.flush()
|
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
|
assert payload["posted_at"] is not None
|
||||||
# Image's own created_at is still surfaced separately.
|
# Image's own created_at is still surfaced separately.
|
||||||
assert payload["created_at"] != payload["posted_at"]
|
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()
|
prov = importer.session.execute(select(ImageProvenance)).scalar_one()
|
||||||
assert prov.image_record_id == rec.id and prov.post_id == post.id
|
assert prov.image_record_id == rec.id and prov.post_id == post.id
|
||||||
assert rec.primary_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):
|
def test_reimport_same_post_idempotent(importer, import_layout):
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from backend.app.models import TagKind
|
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
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
@@ -101,3 +105,64 @@ async def test_autocomplete_empty_query_returns_nothing(db):
|
|||||||
svc = TagService(db)
|
svc = TagService(db)
|
||||||
await svc.find_or_create("Foo", TagKind.artist)
|
await svc.find_or_create("Foo", TagKind.artist)
|
||||||
assert await svc.autocomplete("") == []
|
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