Merge pull request 'Tagging & viewing roadmap: tag query surface + hygiene projections + Explore view (Clusters A/B/C)' (#128) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m6s
Build images / build-ml (push) Successful in 2m39s
CI / integration (push) Successful in 3m24s

This commit was merged in pull request #128.
This commit is contained in:
2026-06-23 08:57:34 -04:00
38 changed files with 2221 additions and 91 deletions
+24
View File
@@ -171,6 +171,30 @@ async def tag_merge(dest_id: int):
if not isinstance(source_id, int) or source_id == dest_id:
return _bad("invalid_source_id", detail="source_id must be int and differ from dest")
# dry_run: non-mutating preview (counts + sample) so the operator can
# confirm the target before the irreversible merge (#8, rule 93 parity).
if body.get("dry_run"):
async with get_session() as session:
try:
p = await TagService(session).merge_preview(
source_id=source_id, target_id=dest_id,
)
except TagValidationError as exc:
return _bad("tag_not_found", status=404, detail=str(exc))
return jsonify({
"preview": {
"source_id": p.source_id, "source_name": p.source_name,
"target_id": p.target_id, "target_name": p.target_name,
"compatible": p.compatible,
"images_moving": p.images_moving,
"images_already_on_target": p.images_already_on_target,
"source_total": p.source_total,
"series_pages": p.series_pages,
"will_alias": p.will_alias,
"sample_thumbnails": p.sample_thumbnails,
},
})
async with get_session() as session:
try:
result = await TagService(session).merge(
+25
View File
@@ -20,12 +20,37 @@ async def list_allowlist():
"tag_name": r.tag_name,
"tag_kind": r.tag_kind,
"min_confidence": r.min_confidence,
"applied_count": r.applied_count,
"coverage_count": r.coverage_count,
}
for r in rows
]
)
@allowlist_bp.route("/tags/<int:tag_id>/allowlist/coverage", methods=["GET"])
async def coverage(tag_id: int):
"""Live "at threshold T, a sweep would cover ~N images" projection for the
allowlist tuning dashboard. Defaults to the tag's stored threshold."""
raw = request.args.get("threshold")
async with get_session() as session:
svc = AllowlistService(session)
if raw is not None:
try:
threshold = float(raw)
except ValueError:
return jsonify({"error": "threshold must be a float"}), 400
if not (0 < threshold <= 1):
return jsonify({"error": "threshold must be in (0, 1]"}), 400
else:
row = await session.get(TagAllowlist, tag_id)
if row is None:
return jsonify({"error": "not on allowlist"}), 404
threshold = row.min_confidence
count = await svc.coverage(tag_id, threshold)
return jsonify({"count": count, "threshold": threshold})
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["GET"])
async def get_one(tag_id: int):
async with get_session() as session:
+23 -7
View File
@@ -37,16 +37,30 @@ def _parse_filters():
"""Parse the composable gallery filters from query args, returning
``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates.
`tag_id` accepts a single id or a comma-separated list (AND); `media` is
image|video; `sort` is newest|oldest; `platform` selects one platform
(or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are boolean
flags; `date_from`/`date_to` are inclusive calendar-day bounds (date_to is
widened by a day so the whole day is covered by the service's half-open
`< date_to`)."""
The structured tag filter (#6) is AND-of-OR plus exclusions:
- `tag_id` accepts a single id or a comma-separated list — all ANDed
(the include common case; back-compat).
- `tag_or` is REPEATABLE; each instance is a comma-separated OR-group, and
the image must match at least one tag from EACH group (groups ANDed).
- `tag_not` is a comma-separated exclude list (image must carry none).
`media` is image|video; `sort` is newest|oldest; `platform` selects one
platform (or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are
boolean flags; `date_from`/`date_to` are inclusive calendar-day bounds
(date_to is widened by a day so the whole day is covered by the service's
half-open `< date_to`)."""
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
tag_or_groups = [
grp for raw in request.args.getlist("tag_or")
if (grp := [int(x) for x in raw.split(",") if x.strip()])
] or None
not_raw = request.args.get("tag_not")
tag_exclude = (
[int(x) for x in not_raw.split(",") if x.strip()] if not_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")
@@ -64,7 +78,9 @@ def _parse_filters():
date_to += timedelta(days=1) # inclusive of the date_to calendar day
filters = {
"tag_ids": tag_ids, "post_id": post_id, "artist_id": artist_id,
"media_type": media_type, "platform": platform,
"media_type": media_type,
"tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude,
"platform": platform,
"untagged": untagged, "no_artist": no_artist,
"date_from": date_from, "date_to": date_to,
}
+32 -6
View File
@@ -3,12 +3,31 @@
from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..models import Tag, TagAllowlist
from ..services.ml.allowlist import AllowlistService
from ..services.ml.suggestions import SuggestionService
suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
async def _accept_payload(session, svc, newly_added: bool, tag_id: int) -> dict:
"""Shape the accept/alias response. When accepting newly allowlists a tag,
include the coverage PROJECTION (at the tag's threshold) so the UI can show
a non-blocking "auto-applying to ~N images" toast — the actual apply runs
async via apply_allowlist_tags, so this is an estimate, not a post-hoc
count (#7)."""
payload = {"allowlisted": newly_added}
if newly_added:
tag = await session.get(Tag, tag_id)
row = await session.get(TagAllowlist, tag_id)
payload["tag_id"] = tag_id
payload["tag_name"] = tag.name if tag is not None else None
payload["projected_count"] = await svc.coverage(
tag_id, row.min_confidence if row is not None else 0.90,
)
return payload
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
async def get_suggestions(image_id: int):
# ?min=<float> overrides the configured per-category thresholds so the typed
@@ -60,13 +79,15 @@ async def accept_suggestion(image_id: int):
return jsonify({"error": "tag_id required"}), 400
tag_id = body["tag_id"]
async with get_session() as session:
newly_added = await AllowlistService(session).accept(image_id, tag_id)
svc = AllowlistService(session)
newly_added = await svc.accept(image_id, tag_id)
payload = await _accept_payload(session, svc, newly_added, tag_id)
await session.commit()
if newly_added:
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=tag_id)
return "", 204
return jsonify(payload)
@suggestions_bp.route(
@@ -77,19 +98,24 @@ async def alias_suggestion(image_id: int):
required = {"alias_string", "alias_category", "canonical_tag_id"}
if not body or not required.issubset(body):
return jsonify({"error": f"required: {sorted(required)}"}), 400
canonical_tag_id = body["canonical_tag_id"]
async with get_session() as session:
newly_added = await AllowlistService(session).add_alias_and_accept(
svc = AllowlistService(session)
newly_added = await svc.add_alias_and_accept(
image_id,
body["alias_string"],
body["alias_category"],
body["canonical_tag_id"],
canonical_tag_id,
)
payload = await _accept_payload(
session, svc, newly_added, canonical_tag_id,
)
await session.commit()
if newly_added:
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=body["canonical_tag_id"])
return "", 204
apply_allowlist_tags.delay(tag_id=canonical_tag_id)
return jsonify(payload)
@suggestions_bp.route(
+21
View File
@@ -320,6 +320,27 @@ async def common_tags():
return jsonify({"tags": tags})
@tags_bp.route("/images/cluster/tag-gaps", methods=["POST"])
async def cluster_tag_gaps():
"""Cluster-consensus tag gaps for a visual neighbour set (#94 Explore):
tags on >= threshold of the images but not all. Each gap carries the
laggard image ids (minus rejections) for apply-to-cluster."""
body = await request.get_json()
ids, err = _parse_bulk_ids(body)
if err:
return err
try:
threshold = float(body.get("threshold", 0.6))
except (TypeError, ValueError):
threshold = 0.6
threshold = min(1.0, max(0.0, threshold))
async with get_session() as session:
gaps = await BulkTagService(session).tag_gaps(ids, threshold)
return jsonify(
{"gaps": gaps, "total": len(set(ids)), "threshold": threshold}
)
@tags_bp.route("/images/bulk/tags", methods=["POST"])
async def bulk_add_tag():
body = await request.get_json()
+81
View File
@@ -1,5 +1,7 @@
"""Bulk tag operations over a set of images (Core, set-based, atomic)."""
import math
from sqlalchemy import and_, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,6 +15,85 @@ class BulkTagService:
def __init__(self, session: AsyncSession):
self.session = session
async def tag_gaps(
self, image_ids: list[int], threshold: float
) -> list[dict]:
"""Cluster-consensus tag gaps (#94 Explore): tags applied to at least
`threshold` fraction of the image set but NOT all of it — the
"7 of these 10 share Hatsune Miku; these 3 don't" signal.
For each gap, `missing_image_ids` are the set members that LACK the tag
and have NOT rejected it (TagSuggestionRejection) — so apply-to-cluster
never re-proposes a tag a neighbor explicitly dismissed. A tag on every
image is "common", not a gap; a tag on fewer than 2 isn't consensus.
"""
ids = list({int(i) for i in image_ids})
if len(ids) < 2:
return []
n = len(ids)
min_present = max(2, math.ceil(threshold * n))
if min_present >= n:
return [] # threshold so high only a 100%-common tag could qualify
cnt = func.count(func.distinct(image_tag.c.image_record_id))
gap_rows = (
await self.session.execute(
select(Tag.id, Tag.name, Tag.kind)
.join(image_tag, image_tag.c.tag_id == Tag.id)
.where(image_tag.c.image_record_id.in_(ids))
.group_by(Tag.id, Tag.name, Tag.kind)
.having(and_(cnt >= min_present, cnt < n))
.order_by(cnt.desc(), Tag.name.asc())
)
).all()
if not gap_rows:
return []
gap_ids = [r.id for r in gap_rows]
present: dict[int, set[int]] = {}
for tid, iid in (
await self.session.execute(
select(image_tag.c.tag_id, image_tag.c.image_record_id).where(
image_tag.c.tag_id.in_(gap_ids),
image_tag.c.image_record_id.in_(ids),
)
)
).all():
present.setdefault(tid, set()).add(iid)
rejected: dict[int, set[int]] = {}
for tid, iid in (
await self.session.execute(
select(
TagSuggestionRejection.tag_id,
TagSuggestionRejection.image_record_id,
).where(
TagSuggestionRejection.tag_id.in_(gap_ids),
TagSuggestionRejection.image_record_id.in_(ids),
)
)
).all():
rejected.setdefault(tid, set()).add(iid)
id_set = set(ids)
gaps = []
for r in gap_rows:
have = present.get(r.id, set())
missing = id_set - have - rejected.get(r.id, set())
if not missing:
continue # every laggard rejected it — nothing to apply
gaps.append(
{
"tag_id": r.id,
"name": r.name,
"kind": r.kind.value if hasattr(r.kind, "value") else str(r.kind),
"present_count": len(have),
"total": n,
"missing_image_ids": sorted(missing),
}
)
return gaps
async def common_tags(self, image_ids: list[int]) -> list[dict]:
"""Tags present on EVERY image in image_ids."""
if not image_ids:
+64 -11
View File
@@ -136,11 +136,15 @@ def image_url(path: str) -> str:
return f"/images/{quote(rel, safe='/')}"
def _require_single_filter(tag_ids, post_id, artist_id) -> None:
def _require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups=None, tag_exclude=None,
) -> None:
"""post_id is the post-detail view — it can't combine with the
composable filters. tag_ids + artist_id (+ media_type) compose freely
(AND)."""
if post_id is not None and (tag_ids or artist_id is not None):
composable filters. tag_ids / tag_or_groups / tag_exclude + artist_id
(+ media_type) compose freely (AND)."""
if post_id is not None and (
tag_ids or artist_id is not None or tag_or_groups or tag_exclude
):
raise ValueError(
"post_id cannot be combined with tag or artist filters"
)
@@ -148,6 +152,7 @@ def _require_single_filter(tag_ids, post_id, artist_id) -> None:
def _apply_scope(
stmt, *, tag_ids, post_id, artist_id, media_type,
tag_or_groups=None, tag_exclude=None,
platform=None, untagged=False, no_artist=False,
date_from=None, date_to=None,
):
@@ -158,7 +163,14 @@ def _apply_scope(
be present on `stmt` (the artist/platform paths alias Post/Source inside
their own EXISTS).
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag.
Tag filtering is one structured model (#6): AND-of-OR plus exclusions.
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag
(the AND-of-singletons "include" common case; light editor + back-compat).
- tag_or_groups: list of OR-groups; the image must carry AT LEAST ONE tag
from EACH group — one EXISTS(tag_id IN group) per group, AND'd across
groups. (advanced editor)
- tag_exclude: image must carry NONE of these — a single NOT EXISTS(tag_id
IN exclude). (light "exclude" chips + advanced NOT)
- post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded
by _require_single_filter).
- media_type: 'image' | 'video' narrows by mime prefix.
@@ -175,6 +187,22 @@ def _apply_scope(
image_tag.c.tag_id == tid,
)
)
for group in tag_or_groups or []:
if not group:
continue # an empty OR-group would match nothing; treat as absent
stmt = stmt.where(
exists().where(
image_tag.c.image_record_id == ImageRecord.id,
image_tag.c.tag_id.in_(group),
)
)
if tag_exclude:
stmt = stmt.where(
~exists().where(
image_tag.c.image_record_id == ImageRecord.id,
image_tag.c.tag_id.in_(tag_exclude),
)
)
prov = _provenance_clause(post_id, artist_id)
if prov is not None:
stmt = stmt.where(prov)
@@ -296,6 +324,8 @@ class GalleryService:
artist_id: int | None = None,
media_type: str | None = None,
sort: str = "newest",
tag_or_groups: list[list[int]] | None = None,
tag_exclude: list[int] | None = None,
platform: str | None = None,
untagged: bool = False,
no_artist: bool = False,
@@ -304,7 +334,9 @@ class GalleryService:
) -> GalleryPage:
if limit < 1 or limit > 200:
raise ValueError("limit must be between 1 and 200")
_require_single_filter(tag_ids, post_id, artist_id)
_require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
)
eff = _effective_date_col()
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
@@ -312,6 +344,7 @@ class GalleryService:
stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type,
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to,
)
@@ -359,6 +392,8 @@ class GalleryService:
post_id: int | None = None,
artist_id: int | None = None,
media_type: str | None = None,
tag_or_groups: list[list[int]] | None = None,
tag_exclude: list[int] | None = None,
platform: str | None = None,
untagged: bool = False,
no_artist: bool = False,
@@ -372,10 +407,13 @@ class GalleryService:
year_col, month_col, func.count(ImageRecord.id).label("cnt")
)
stmt = _outer_join_primary_post(stmt)
_require_single_filter(tag_ids, post_id, artist_id)
_require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
)
stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type,
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to,
)
@@ -387,6 +425,8 @@ class GalleryService:
self, year: int, month: int, tag_ids: list[int] | None = None,
post_id: int | None = None, artist_id: int | None = None,
media_type: str | None = None, sort: str = "newest",
tag_or_groups: list[list[int]] | None = None,
tag_exclude: list[int] | None = None,
platform: str | None = None, untagged: bool = False,
no_artist: bool = False, date_from: datetime | None = None,
date_to: datetime | None = None,
@@ -403,10 +443,13 @@ class GalleryService:
extract("month", eff) == month,
)
stmt = _outer_join_primary_post(stmt)
_require_single_filter(tag_ids, post_id, artist_id)
_require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
)
stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type,
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to,
)
@@ -427,7 +470,10 @@ class GalleryService:
async def facets(
self, *, tag_ids: list[int] | None = None,
post_id: int | None = None, artist_id: int | None = None,
media_type: str | None = None, platform: str | None = None,
media_type: str | None = None,
tag_or_groups: list[list[int]] | None = None,
tag_exclude: list[int] | None = None,
platform: str | None = None,
untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None,
) -> GalleryFacets:
@@ -437,10 +483,13 @@ class GalleryService:
No outer join is needed — every clause is a correlated EXISTS or a
column predicate on ImageRecord.
"""
_require_single_filter(tag_ids, post_id, artist_id)
_require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
)
common = {
"tag_ids": tag_ids, "post_id": post_id,
"artist_id": artist_id, "media_type": media_type,
"tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude,
}
# total — the full active filter (the headline result count).
@@ -515,7 +564,10 @@ class GalleryService:
async def similar(
self, image_id: int, limit: int = 100, *,
tag_ids: list[int] | None = None, artist_id: int | None = None,
media_type: str | None = None, platform: str | None = None,
media_type: str | None = None,
tag_or_groups: list[list[int]] | None = None,
tag_exclude: list[int] | None = None,
platform: str | None = None,
untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None,
) -> list[GalleryImage] | None:
@@ -547,6 +599,7 @@ class GalleryService:
stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=None,
artist_id=artist_id, media_type=media_type,
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to,
)
+78 -10
View File
@@ -5,11 +5,18 @@ image_tag AND to tag_allowlist; per-image removal/dismiss writes a rejection.
from collections.abc import Sequence
from dataclasses import dataclass
from sqlalchemy import delete, select
from sqlalchemy import and_, delete, distinct, func, or_, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import MLSettings, Tag, TagAllowlist, TagSuggestionRejection
from ...models import (
ImagePrediction,
MLSettings,
Tag,
TagAlias,
TagAllowlist,
TagSuggestionRejection,
)
from ...models.tag import image_tag
from .aliases import AliasService
@@ -20,6 +27,8 @@ class AllowlistRow:
tag_name: str
tag_kind: str
min_confidence: float
applied_count: int # image_tag rows currently carrying this tag
coverage_count: int # images a sweep WOULD cover at min_confidence
class AllowlistService:
@@ -116,6 +125,44 @@ class AllowlistService:
delete(TagAllowlist).where(TagAllowlist.tag_id == tag_id)
)
async def _coverage_match(self, tag: Tag):
"""The predicate over image_prediction rows that resolve to `tag`,
mirroring tasks.ml._confidence_for_tag's resolution: a prediction whose
raw_name equals the tag name (any category), OR an alias maps
(raw_name, category) -> this tag. Returns a SQLAlchemy boolean clause.
"""
alias_rows = (
await self.session.execute(
select(TagAlias.alias_string, TagAlias.alias_category).where(
TagAlias.canonical_tag_id == tag.id
)
)
).all()
name_clause = ImagePrediction.raw_name == tag.name
alias_clauses = [
and_(
ImagePrediction.raw_name == a,
ImagePrediction.category == c,
)
for a, c in alias_rows
]
return or_(name_clause, *alias_clauses) if alias_clauses else name_clause
async def coverage(self, tag_id: int, threshold: float) -> int:
"""How many distinct images a sweep WOULD cover for this tag at
`threshold`: images with a resolving prediction scoring >= threshold.
The gross candidate pool (NOT minus already-applied/rejected) — it's
the tuning signal for "lower the threshold and ~N more images qualify".
"""
tag = await self.session.get(Tag, tag_id)
if tag is None:
return 0
match = await self._coverage_match(tag)
stmt = select(
func.count(distinct(ImagePrediction.image_record_id))
).where(ImagePrediction.score >= threshold, match)
return (await self.session.execute(stmt)).scalar_one()
async def list_all(self) -> Sequence[AllowlistRow]:
stmt = (
select(
@@ -128,12 +175,33 @@ class AllowlistService:
.order_by(Tag.name.asc())
)
rows = (await self.session.execute(stmt)).all()
return [
AllowlistRow(
tag_id=r[0],
tag_name=r[1],
tag_kind=r[2].value if hasattr(r[2], "value") else str(r[2]),
min_confidence=r[3],
tag_ids = [r[0] for r in rows]
# Applied counts in ONE grouped query (vs N per-row counts).
applied: dict[int, int] = {}
if tag_ids:
applied = dict(
(
await self.session.execute(
select(image_tag.c.tag_id, func.count())
.where(image_tag.c.tag_id.in_(tag_ids))
.group_by(image_tag.c.tag_id)
)
).all()
)
for r in rows
]
result = []
for r in rows:
# Coverage is per-tag (alias set differs); allowlist is small.
cov = await self.coverage(r[0], r[3])
result.append(
AllowlistRow(
tag_id=r[0],
tag_name=r[1],
tag_kind=r[2].value if hasattr(r[2], "value") else str(r[2]),
min_confidence=r[3],
applied_count=applied.get(r[0], 0),
coverage_count=cov,
)
)
return result
+107
View File
@@ -79,6 +79,23 @@ class MergeResult:
source_deleted: bool
@dataclass(frozen=True)
class MergePreview:
"""Non-mutating projection of what merge(source→target) would do (#8).
Shares the apply's predicates (rule 93) so the preview can't drift."""
source_id: int
source_name: str
target_id: int
target_name: str
compatible: bool # same kind + fandom — would apply succeed?
images_moving: int # source links that move (image lacks target)
images_already_on_target: int # source links dropped (image has target)
source_total: int # images carrying source
series_pages: int # series pages repointed
will_alias: bool # source name kept as a protective alias
sample_thumbnails: list[str] # a few of the moving images
class TagService:
def __init__(self, session: AsyncSession):
self.session = session
@@ -376,6 +393,96 @@ class TagService:
await self.session.flush()
return tag
async def merge_preview(
self, source_id: int, target_id: int
) -> MergePreview:
"""Read-only dry-run of merge(source→target): the same counts the apply
would produce, plus a thumbnail sample of the images that move. Raises
TagValidationError only for missing/self-merge (so the UI can render a
kind/fandom-mismatch warning rather than a hard error)."""
if source_id == target_id:
raise TagValidationError("Cannot merge a tag into itself")
source = await self.session.get(Tag, source_id)
target = await self.session.get(Tag, target_id)
if source is None or target is None:
raise TagValidationError("Tag not found")
compatible = source.kind == target.kind and (
(source.fandom_id or 0) == (target.fandom_id or 0)
)
# Mirrors _repoint_image_tags: links whose image already has target are
# dropped (already_on_target); the rest move.
target_images = select(image_tag.c.image_record_id).where(
image_tag.c.tag_id == target_id
)
moving = await self.session.scalar(
select(func.count())
.select_from(image_tag)
.where(
image_tag.c.tag_id == source_id,
image_tag.c.image_record_id.notin_(target_images),
)
)
already = await self.session.scalar(
select(func.count())
.select_from(image_tag)
.where(
image_tag.c.tag_id == source_id,
image_tag.c.image_record_id.in_(target_images),
)
)
from ..models.series_page import SeriesPage
series_pages = await self.session.scalar(
select(func.count())
.select_from(SeriesPage)
.where(SeriesPage.series_tag_id == source_id)
)
will_alias = await self._keep_as_alias(source_id)
sample = await self._merge_sample_thumbnails(source_id, target_images)
return MergePreview(
source_id=source_id,
source_name=source.name,
target_id=target_id,
target_name=target.name,
compatible=compatible,
images_moving=moving or 0,
images_already_on_target=already or 0,
source_total=(moving or 0) + (already or 0),
series_pages=series_pages or 0,
will_alias=will_alias,
sample_thumbnails=sample,
)
async def _merge_sample_thumbnails(
self, source_id: int, target_images
) -> list[str]:
"""Up to 6 thumbnails of the images that WOULD move (carry source, not
target) — a sanity check that the merge targets the right content."""
from ..models import ImageRecord
from .gallery_service import thumbnail_url
rows = (
await self.session.execute(
select(
ImageRecord.thumbnail_path,
ImageRecord.sha256,
ImageRecord.mime,
)
.join(image_tag, image_tag.c.image_record_id == ImageRecord.id)
.where(
image_tag.c.tag_id == source_id,
image_tag.c.image_record_id.notin_(target_images),
)
.limit(6)
)
).all()
return [thumbnail_url(tp, sha, mime) for tp, sha, mime in rows]
async def merge(self, source_id: int, target_id: int) -> MergeResult:
"""Transactionally repoint every FK from source→target, optionally
keep source's name as a tagger alias, delete source. Atomic: any
@@ -0,0 +1,68 @@
<template>
<!-- Thin debounced tag-search autocomplete: emits `pick` with the chosen tag
and self-clears. Shared by the gallery's advanced tag-query builder; the
filter bar's own inline search predates this and also folds in artists. -->
<v-autocomplete
v-model="selected"
:items="items"
:loading="loading"
item-title="name" item-value="id"
no-filter hide-details density="compact" variant="outlined"
:placeholder="placeholder"
prepend-inner-icon="mdi-tag-plus-outline"
:aria-label="placeholder"
@update:search="onSearch"
@update:model-value="onPick"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :title="item.raw.name">
<template #subtitle>
{{ item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind }}
</template>
</v-list-item>
</template>
<template #no-data>
<v-list-item :title="searchedOnce ? 'No tags match' : 'Type to search tags'" />
</template>
</v-autocomplete>
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import { useApi } from '../../composables/useApi.js'
defineProps({ placeholder: { type: String, default: 'Add tag…' } })
const emit = defineEmits(['pick'])
const api = useApi()
const selected = ref(null)
const items = ref([])
const loading = ref(false)
const searchedOnce = ref(false)
let debounce = null
function onSearch (q) {
if (debounce) clearTimeout(debounce)
if (!q || !q.trim()) { items.value = []; return }
debounce = setTimeout(async () => {
loading.value = true
try {
items.value = (await api.get('/api/tags/autocomplete', { params: { q, limit: 10 } })) || []
} catch {
items.value = []
} finally {
loading.value = false
searchedOnce.value = true
}
}, 250)
}
function onPick (id) {
const tag = items.value.find((i) => i.id === id)
selected.value = null
items.value = []
if (tag) emit('pick', tag)
}
onBeforeUnmount(() => { if (debounce) clearTimeout(debounce) })
</script>
@@ -0,0 +1,159 @@
<template>
<div class="fc-gaps">
<h3 class="fc-gaps__title">Tag-coverage gaps</h3>
<p class="fc-gaps__hint fc-muted">
Tags most of this cluster shares but some images lack. Apply to close the
gap across the stragglers.
</p>
<div class="fc-gaps__threshold">
<label class="text-caption fc-muted">
Consensus {{ Math.round(threshold * 100) }}%
</label>
<v-slider
v-model="threshold"
:min="0.3" :max="0.95" :step="0.05"
density="compact" hide-details color="accent"
aria-label="Consensus threshold"
@update:model-value="onThresholdInput"
/>
</div>
<div v-if="loading" class="fc-gaps__state">
<v-progress-circular indeterminate size="22" width="2" color="accent" />
</div>
<v-alert v-else-if="error" type="error" variant="tonal" density="compact" class="mt-2">
{{ error }}
</v-alert>
<p v-else-if="!store.clusterIds.length" class="fc-gaps__state fc-muted text-body-2">
Anchor on an image with visual neighbours to see gaps.
</p>
<p v-else-if="!gaps.length" class="fc-gaps__state fc-muted text-body-2">
No gaps at this threshold this cluster is consistently tagged.
</p>
<div v-else class="fc-gaps__list">
<div v-for="g in gaps" :key="g.tag_id" class="fc-gap">
<div class="fc-gap__head">
<span class="fc-gap__name">{{ g.name }}</span>
<span class="fc-gap__count fc-muted" :title="`${g.present_count} of ${g.total} images have this tag`">
{{ g.present_count }}/{{ g.total }}
</span>
</div>
<div class="fc-gap__missing">
<img
v-for="iid in g.missing_image_ids.slice(0, MISSING_PREVIEW)" :key="iid"
:src="store.thumbById[iid]" alt="" loading="lazy"
/>
<span
v-if="g.missing_image_ids.length > MISSING_PREVIEW"
class="fc-muted text-caption"
>+{{ g.missing_image_ids.length - MISSING_PREVIEW }}</span>
</div>
<v-btn
size="small" color="accent" variant="tonal" rounded="pill"
:loading="applyingId === g.tag_id"
prepend-icon="mdi-tag-plus"
@click="applyGap(g)"
>Apply to {{ g.missing_image_ids.length }} missing</v-btn>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useExploreStore } from '../../stores/explore.js'
import { useApi } from '../../composables/useApi.js'
import { useInflightToken } from '../../composables/useInflightToken.js'
import { toast } from '../../utils/toast.js'
const MISSING_PREVIEW = 8
const store = useExploreStore()
const api = useApi()
const threshold = ref(0.6)
const gaps = ref([])
const loading = ref(false)
const error = ref(null)
const applyingId = ref(null)
const inflight = useInflightToken()
let debounce = null
async function fetchGaps () {
const ids = store.clusterIds
if (ids.length < 2) { gaps.value = []; return }
inflight.cancel()
const t = inflight.claim()
loading.value = true
error.value = null
try {
const body = await api.post('/api/images/cluster/tag-gaps', {
body: { image_ids: ids, threshold: threshold.value },
})
if (!t.isCurrent()) return
gaps.value = body.gaps || []
} catch (e) {
if (t.isCurrent()) { error.value = e.message; gaps.value = [] }
} finally {
if (t.isCurrent()) loading.value = false
}
}
function onThresholdInput () {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(fetchGaps, 350)
}
// Re-evaluate gaps whenever the cluster changes (new anchor / new neighbours).
watch(() => store.clusterIds.join(','), fetchGaps, { immediate: true })
async function applyGap (g) {
applyingId.value = g.tag_id
try {
const res = await api.post('/api/tags/images/bulk/tags', {
body: { image_ids: g.missing_image_ids, tag_id: g.tag_id, source: 'manual' },
})
toast({
text: `Applied “${g.name}” to ${res.added_count ?? g.missing_image_ids.length} image(s)`,
type: 'success',
})
await fetchGaps() // the gap shrinks/closes — refresh
} catch (e) {
toast({ text: `Couldn't apply “${g.name}”: ${e.message}`, type: 'error' })
} finally {
applyingId.value = null
}
}
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-gaps {
padding: 14px; border-radius: 12px;
background: rgba(var(--v-theme-on-surface), 0.03);
border: 1px solid rgba(var(--v-theme-on-surface), 0.10);
}
.fc-gaps__title {
font-family: 'Fraunces', Georgia, serif; font-size: 16px; font-weight: 500;
margin-bottom: 4px;
}
.fc-gaps__hint { font-size: 12px; margin-bottom: 10px; }
.fc-gaps__threshold { margin-bottom: 8px; }
.fc-gaps__state { display: flex; justify-content: center; padding: 20px 0; }
.fc-gaps__list { display: flex; flex-direction: column; gap: 12px; }
.fc-gap {
padding: 10px; border-radius: 10px;
background: rgba(var(--v-theme-on-surface), 0.04);
}
.fc-gap__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.fc-gap__name { font-weight: 600; }
.fc-gap__count { font-variant-numeric: tabular-nums; font-size: 12px; }
.fc-gap__missing { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
.fc-gap__missing img {
width: 36px; height: 36px; object-fit: cover; border-radius: 5px;
background: rgb(var(--v-theme-surface-light));
}
</style>
@@ -27,11 +27,34 @@
</v-autocomplete>
<div class="fc-filterbar__chips">
<!-- Include tag chips: click the body to flip to exclude, to remove
(#6 light editor one model, two editors). -->
<v-chip
v-for="id in store.filter.tag_ids" :key="`t${id}`"
size="small" closable :color="chipColor(id)" variant="tonal"
class="fc-filterbar__tagchip"
title="Click to exclude this tag instead"
@click="toggleTagPolarity(id)"
@click:close="removeTag(id)"
>{{ store.tagLabels[id] || `#${id}` }}</v-chip>
<!-- Exclude tag chips (red, minus). Click body to flip back to include. -->
<v-chip
v-for="id in store.filter.tag_exclude" :key="`x${id}`"
size="small" closable color="error" variant="tonal"
class="fc-filterbar__tagchip"
title="Excluded — click to include instead"
@click="toggleTagPolarity(id)"
@click:close="removeExclude(id)"
><v-icon start size="x-small">mdi-minus</v-icon>{{ store.tagLabels[id] || `#${id}` }}</v-chip>
<!-- Advanced OR-groups can't render as flat chips; one affordance opens
the builder where they live. -->
<v-chip
v-if="orGroupCount"
size="small" color="accent" variant="tonal"
prepend-icon="mdi-filter-cog-outline"
title="Open the advanced tag filter"
@click="advancedOpen = true"
>{{ orGroupCount }} OR-group{{ orGroupCount > 1 ? 's' : '' }}</v-chip>
<v-chip
v-if="store.filter.artist_id"
size="small" closable color="accent" variant="tonal"
@@ -68,6 +91,13 @@
@update:model-value="setSort"
/>
<v-btn
:color="advancedActive ? 'accent' : undefined"
:variant="advancedActive ? 'tonal' : 'text'"
size="small" prepend-icon="mdi-filter-cog-outline"
@click="advancedOpen = true"
>Advanced{{ advancedCount ? ` (${advancedCount})` : '' }}</v-btn>
<v-btn
:color="refineOpen ? 'accent' : undefined"
:variant="refineOpen || hasRefineFilters ? 'tonal' : 'text'"
@@ -83,6 +113,18 @@
</div>
<GalleryFacetPanel v-if="refineOpen" />
<v-dialog v-model="advancedOpen" max-width="640" scrollable>
<TagQueryBuilder
v-if="advancedOpen"
:include="store.filter.tag_ids"
:or-groups="store.filter.tag_or"
:exclude="store.filter.tag_exclude"
:labels="store.tagLabels"
@apply="onAdvancedApply"
@close="advancedOpen = false"
/>
</v-dialog>
</div>
</template>
@@ -93,6 +135,7 @@ import { useApi } from '../../composables/useApi.js'
import { cloneFilter, filterToQuery, useGalleryStore } from '../../stores/gallery.js'
import { useTagStore } from '../../stores/tags.js'
import GalleryFacetPanel from './GalleryFacetPanel.vue'
import TagQueryBuilder from './TagQueryBuilder.vue'
const store = useGalleryStore()
const tagStore = useTagStore()
@@ -117,8 +160,19 @@ const refineCount = computed(() => {
})
const hasRefineFilters = computed(() => refineCount.value > 0)
// The structured tag filter beyond plain includes (OR-groups + excludes) —
// drives the Advanced button's active state + its count badge.
const advancedOpen = ref(false)
const orGroupCount = computed(() => store.filter.tag_or.length)
const advancedCount = computed(
() => store.filter.tag_or.length + store.filter.tag_exclude.length,
)
const advancedActive = computed(() => advancedOpen.value || advancedCount.value > 0)
const hasActiveFilters = computed(() =>
store.filter.tag_ids.length > 0 ||
store.filter.tag_exclude.length > 0 ||
store.filter.tag_or.length > 0 ||
store.filter.artist_id != null ||
store.filter.media_type != null ||
store.filter.sort !== 'newest' ||
@@ -195,6 +249,34 @@ function onPick(value) {
function removeTag(id) {
pushFilter((n) => { n.tag_ids = n.tag_ids.filter((t) => t !== id) })
}
function removeExclude(id) {
pushFilter((n) => { n.tag_exclude = n.tag_exclude.filter((t) => t !== id) })
}
// Flip a tag between include and exclude in place (light-editor toggle). A tag
// is only ever in one of the two lists.
function toggleTagPolarity(id) {
pushFilter((n) => {
if (n.tag_ids.includes(id)) {
n.tag_ids = n.tag_ids.filter((t) => t !== id)
if (!n.tag_exclude.includes(id)) n.tag_exclude.push(id)
} else {
n.tag_exclude = n.tag_exclude.filter((t) => t !== id)
if (!n.tag_ids.includes(id)) n.tag_ids.push(id)
}
})
}
// The advanced builder hands back the whole tag model at once.
function onAdvancedApply({ tag_ids, tag_or, tag_exclude, labels }) {
for (const [id, name] of Object.entries(labels || {})) {
store.noteTagLabel(Number(id), name)
}
pushFilter((n) => {
n.tag_ids = tag_ids
n.tag_or = tag_or
n.tag_exclude = tag_exclude
})
advancedOpen.value = false
}
function clearArtist() {
store.noteArtistLabel(null)
pushFilter((n) => { n.artist_id = null })
@@ -257,6 +339,12 @@ function pushFilter(mutate) {
}
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */
.fc-filterbar__tagchip { cursor: pointer; }
.fc-filterbar__tagchip:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 1px;
}
.fc-filterbar__sort { max-width: 150px; }
/* Phones: the search's 200px min-width jams the wrapping bar. Give search its
@@ -0,0 +1,236 @@
<template>
<v-card class="fc-tqb">
<v-card-title class="fc-tqb__head">
<v-icon icon="mdi-filter-cog-outline" size="small" class="mr-2" />
Advanced tag filter
<v-spacer />
<v-btn
icon="mdi-close" variant="text" size="small"
aria-label="Close advanced tag filter" @click="$emit('close')"
/>
</v-card-title>
<v-card-text>
<p class="text-body-2 fc-tqb__hint">
An image must match <strong>every</strong> group below, and a group
matches when the image carries <strong>any</strong> tag in it
(AND of ORs). Excluded tags are never shown.
</p>
<!-- AND-of-OR groups -->
<div class="fc-tqb__section">
<div class="fc-tqb__section-title">Must match all of</div>
<p v-if="!groups.length" class="fc-tqb__empty">
No groups yet add a tag below to start.
</p>
<template v-for="(g, gi) in groups" :key="gi">
<div v-if="gi > 0" class="fc-tqb__and">AND</div>
<div class="fc-tqb__group">
<div class="fc-tqb__group-chips">
<template v-for="(id, ci) in g" :key="id">
<span v-if="ci > 0" class="fc-tqb__or">or</span>
<v-chip
size="small" closable variant="tonal"
:color="kindColor(id)"
@click:close="removeFromGroup(gi, id)"
>{{ label(id) }}</v-chip>
</template>
<span v-if="!g.length" class="fc-tqb__empty">empty</span>
</div>
<div class="fc-tqb__group-actions">
<TagPicker
placeholder="or…" class="fc-tqb__picker"
@pick="(t) => addToGroup(gi, t)"
/>
<v-btn
icon="mdi-delete-outline" variant="text" size="small"
aria-label="Remove this group" @click="removeGroup(gi)"
/>
</div>
</div>
</template>
<div class="fc-tqb__add">
<TagPicker
placeholder="Add a tag (new group)…" class="fc-tqb__picker"
@pick="addNewGroup"
/>
</div>
</div>
<v-divider class="my-4" />
<!-- exclude -->
<div class="fc-tqb__section">
<div class="fc-tqb__section-title">Exclude (must NOT have)</div>
<div class="fc-tqb__group-chips">
<v-chip
v-for="id in excludeIds" :key="id"
size="small" closable color="error" variant="tonal"
@click:close="removeExclude(id)"
>
<v-icon start size="x-small">mdi-minus</v-icon>{{ label(id) }}
</v-chip>
<span v-if="!excludeIds.length" class="fc-tqb__empty">none</span>
</div>
<div class="fc-tqb__add">
<TagPicker
placeholder="Exclude a tag…" class="fc-tqb__picker"
@pick="addExclude"
/>
</div>
</div>
</v-card-text>
<v-card-actions class="fc-tqb__actions">
<v-btn
variant="text" size="small" :disabled="!isDirty && isEmpty"
@click="clearAll"
>Clear</v-btn>
<v-spacer />
<v-btn variant="text" @click="$emit('close')">Cancel</v-btn>
<v-btn color="accent" variant="flat" rounded="pill" @click="apply">
Apply filter
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useTagStore } from '../../stores/tags.js'
import TagPicker from '../common/TagPicker.vue'
// One editor for the whole structured tag model. It works purely on "AND-of-OR
// groups + exclude": singleton includes (tag_ids) and OR-groups (tag_or) are
// unified into one `groups` list here, then split back apart on Apply so the
// URL stays compact (singletons serialize to tag_id, multi-tag groups to
// tag_or). Writes the SAME model the light chips edit — two editors, one model.
const props = defineProps({
include: { type: Array, default: () => [] }, // tag_ids
orGroups: { type: Array, default: () => [] }, // tag_or
exclude: { type: Array, default: () => [] }, // tag_exclude
labels: { type: Object, default: () => ({}) }, // id -> name (best effort)
})
const emit = defineEmits(['apply', 'close'])
const tagStore = useTagStore()
// Seed local draft from props (the dialog is recreated on each open, so a
// setup-time seed is the current filter every time).
const groups = ref([
...props.include.map((id) => [id]),
...props.orGroups.map((g) => [...g]),
])
const excludeIds = ref([...props.exclude])
// Names/kinds learned as tags are picked, layered over the labels passed in.
const nameMap = ref({ ...props.labels })
const kindMap = ref({})
function label (id) { return nameMap.value[id] || `#${id}` }
function kindColor (id) { return tagStore.colorFor(kindMap.value[id] || 'general') }
function _note (tag) {
nameMap.value = { ...nameMap.value, [tag.id]: tag.name }
kindMap.value = { ...kindMap.value, [tag.id]: tag.kind }
}
function _dropFromGroups (id) {
groups.value = groups.value
.map((g) => g.filter((t) => t !== id))
.filter((g) => g.length)
}
function addToGroup (gi, tag) {
_note(tag)
excludeIds.value = excludeIds.value.filter((t) => t !== tag.id) // can't be both
if (!groups.value[gi].includes(tag.id)) groups.value[gi].push(tag.id)
}
function addNewGroup (tag) {
_note(tag)
excludeIds.value = excludeIds.value.filter((t) => t !== tag.id)
groups.value.push([tag.id])
}
function removeFromGroup (gi, id) {
groups.value[gi] = groups.value[gi].filter((t) => t !== id)
if (!groups.value[gi].length) groups.value.splice(gi, 1)
}
function removeGroup (gi) { groups.value.splice(gi, 1) }
function addExclude (tag) {
_note(tag)
_dropFromGroups(tag.id) // a tag can't be both required and excluded
if (!excludeIds.value.includes(tag.id)) excludeIds.value.push(tag.id)
}
function removeExclude (id) {
excludeIds.value = excludeIds.value.filter((t) => t !== id)
}
function clearAll () { groups.value = []; excludeIds.value = [] }
const isEmpty = computed(() => !groups.value.length && !excludeIds.value.length)
const isDirty = computed(() =>
JSON.stringify(groups.value) !== JSON.stringify(props.orGroups.length || props.include.length
? [...props.include.map((id) => [id]), ...props.orGroups]
: []) ||
JSON.stringify(excludeIds.value) !== JSON.stringify(props.exclude),
)
function apply () {
const clean = groups.value.filter((g) => g.length)
emit('apply', {
tag_ids: clean.filter((g) => g.length === 1).map((g) => g[0]),
tag_or: clean.filter((g) => g.length > 1),
tag_exclude: [...excludeIds.value],
labels: nameMap.value, // so freshly-picked chips show their name on return
})
}
</script>
<style scoped>
.fc-tqb__head { display: flex; align-items: center; }
.fc-tqb__hint {
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 16px;
}
.fc-tqb__section { margin-bottom: 4px; }
.fc-tqb__section-title {
font-size: 12px; font-weight: 600; letter-spacing: 0.04em;
text-transform: uppercase;
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 8px;
}
.fc-tqb__and {
font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
color: rgb(var(--v-theme-accent));
margin: 6px 0 6px 2px;
}
.fc-tqb__group {
display: flex; align-items: center; gap: 12px;
flex-wrap: wrap;
padding: 8px 10px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
background: rgba(var(--v-theme-on-surface), 0.03);
}
.fc-tqb__group-chips {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
flex: 1 1 220px; min-width: 0;
}
.fc-tqb__or {
font-size: 11px; font-style: italic;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-tqb__group-actions {
display: flex; align-items: center; gap: 4px;
}
.fc-tqb__picker { min-width: 180px; max-width: 240px; }
.fc-tqb__add { margin-top: 10px; max-width: 280px; }
.fc-tqb__empty {
font-size: 12px; font-style: italic;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-tqb__actions { padding: 8px 16px 16px; }
</style>
@@ -0,0 +1,96 @@
<template>
<section v-if="img" class="fc-meta" aria-label="Image details">
<!-- #4a: dimensions / size / type already on the payload, shown nowhere
until now. -->
<dl class="fc-meta__grid">
<div v-if="img.width && img.height" class="fc-meta__item">
<dt>Dimensions</dt><dd>{{ img.width }} × {{ img.height }}</dd>
</div>
<div v-if="img.size_bytes" class="fc-meta__item">
<dt>Size</dt><dd>{{ humanSize(img.size_bytes) }}</dd>
</div>
<div v-if="img.mime" class="fc-meta__item">
<dt>Type</dt><dd>{{ shortType(img.mime) }}</dd>
</div>
</dl>
<!-- #4b: split Download default Download, chevron Copy link. Image-to-
clipboard is OFF the table on the plain-HTTP origin (rule 95), so the
secondary action copies the link TEXT, which works everywhere. -->
<v-btn-group divided density="comfortable" variant="tonal" color="accent" rounded="pill">
<v-btn prepend-icon="mdi-download" @click="download">Download</v-btn>
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn v-bind="props" icon="mdi-chevron-down" aria-label="More download options" />
</template>
<v-list density="compact">
<v-list-item prepend-icon="mdi-link-variant" title="Copy link" @click="copyLink" />
<v-list-item prepend-icon="mdi-download" title="Download" @click="download" />
</v-list>
</v-menu>
</v-btn-group>
</section>
</template>
<script setup>
import { computed } from 'vue'
import { useModalStore } from '../../stores/modal.js'
import { copyText } from '../../utils/clipboard.js'
import { toast } from '../../utils/toast.js'
const modal = useModalStore()
const img = computed(() => modal.current)
function humanSize (bytes) {
const units = ['B', 'KB', 'MB', 'GB']
let n = bytes
let i = 0
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`
}
function shortType (mime) { return mime.split('/')[1]?.toUpperCase() || mime }
function absoluteUrl () {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return origin + img.value.image_url
}
function download () {
// Same-origin /images/* link — the download attribute lets the browser save
// the original (filename derived from the path) instead of navigating to it.
const a = document.createElement('a')
a.href = img.value.image_url
a.setAttribute('download', '')
document.body.appendChild(a)
a.click()
a.remove()
}
async function copyLink () {
try {
await copyText(absoluteUrl())
toast({ text: 'Link copied to clipboard', type: 'success' })
} catch {
toast({ text: 'Could not copy the link', type: 'error' })
}
}
</script>
<style scoped>
.fc-meta {
padding: 14px 16px 0;
display: flex; flex-direction: column; gap: 10px;
}
.fc-meta__grid {
display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 0;
}
.fc-meta__item { display: flex; flex-direction: column; }
.fc-meta__item dt {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-meta__item dd {
margin: 0; font-size: 13px; font-variant-numeric: tabular-nums;
color: rgb(var(--v-theme-on-surface));
}
</style>
@@ -73,6 +73,7 @@
</div>
<aside v-if="modal.current" class="fc-viewer__side">
<ImageMetaBar />
<ProvenancePanel />
<TagPanel />
<!-- Non-blocking: fetches its own similar set after the modal is up;
@@ -90,6 +91,7 @@ import { useModalStore } from '../../stores/modal.js'
import { arrowNavAllowed, isTextEntry } from '../../utils/textEntry.js'
import ImageCanvas from './ImageCanvas.vue'
import VideoCanvas from './VideoCanvas.vue'
import ImageMetaBar from './ImageMetaBar.vue'
import TagPanel from './TagPanel.vue'
import ProvenancePanel from './ProvenancePanel.vue'
import RelatedStrip from './RelatedStrip.vue'
+21 -5
View File
@@ -5,11 +5,18 @@
<div v-if="show" class="fc-related">
<div class="fc-related__head">
<span class="fc-related__title">Related</span>
<v-btn
size="x-small" variant="text" color="accent"
:disabled="loading || !results.length"
@click="seeAll"
>See all similar</v-btn>
<div class="fc-related__actions">
<v-btn
size="x-small" variant="text" color="accent"
prepend-icon="mdi-compass-outline"
@click="explore"
>Explore</v-btn>
<v-btn
size="x-small" variant="text" color="accent"
:disabled="loading || !results.length"
@click="seeAll"
>See all similar</v-btn>
</div>
</div>
<div class="fc-related__row">
<template v-if="loading">
@@ -94,6 +101,14 @@ function seeAll() {
modal.close()
router.push({ name: 'gallery', query: { similar_to: String(id) } })
}
// #94: leave the modal and open the dedicated Explore walk anchored here.
function explore() {
const id = modal.current?.id
if (!id) return
modal.close()
router.push({ name: 'explore', params: { imageId: String(id) } })
}
</script>
<style scoped>
@@ -105,6 +120,7 @@ function seeAll() {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.fc-related__actions { display: flex; align-items: center; gap: 2px; }
.fc-related__title {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-on-surface-variant));
+12 -1
View File
@@ -3,6 +3,10 @@
<v-chip
size="small" closable
:color="store.colorFor(tag.kind)" variant="tonal"
class="fc-tag-chip__nav"
role="link"
:title="`Browse images tagged “${tag.name}”`"
@click="$emit('navigate', tag)"
@click:close="$emit('remove', tag.id)"
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
@@ -30,7 +34,7 @@ import { useTagStore } from '../../stores/tags.js'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ tag: { type: Object, required: true } })
defineEmits(['remove', 'rename', 'set-fandom'])
defineEmits(['remove', 'rename', 'set-fandom', 'navigate'])
const store = useTagStore()
@@ -53,6 +57,13 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
<style scoped>
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; }
/* The chip body navigates to the filtered gallery (#5); signal it's clickable.
The close ✕ (remove) and the sibling kebab stay as the explicit controls. */
.fc-tag-chip__nav { cursor: pointer; }
.fc-tag-chip__nav:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 1px;
}
.fc-tag-chip__kebab { opacity: 0.7; }
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
@@ -6,6 +6,7 @@
v-for="tag in modal.current?.tags || []"
:key="tag.id" :tag="tag"
@remove="onRemove" @rename="openRename" @set-fandom="openSetFandom"
@navigate="onNavigate"
/>
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
</div>
@@ -51,6 +52,7 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useModalStore } from '../../stores/modal.js'
import { useSuggestionsStore } from '../../stores/suggestions.js'
import TagChip from './TagChip.vue'
@@ -61,9 +63,18 @@ import FandomSetDialog from './FandomSetDialog.vue'
const modal = useModalStore()
const suggestions = useSuggestionsStore()
const router = useRouter()
const errorMsg = ref(null)
const tagInputRef = ref(null)
// #5: clicking a tag chip's body leaves the modal and opens the gallery
// filtered for that single tag (a fresh filter — the obvious "show me more
// like this tag" move). Rename/set-fandom (kebab) and remove (✕) stay put.
async function onNavigate(tag) {
await modal.close()
router.push({ name: 'gallery', query: { tag_id: String(tag.id) } })
}
// Return focus to the tag input after a suggestion is accepted (from the
// Suggestions panel or the autocomplete dropdown) so the operator can keep
// typing the next tag without re-clicking the field (operator-asked 2026-06-08).
@@ -2,33 +2,60 @@
<MaintenanceTile
icon="mdi-playlist-check"
:title="`Allowlisted tags (${store.rows.length})`"
blurb="Tags auto-applied to images that score above their threshold."
blurb="Tags auto-applied to images that score above their threshold. Tune the
threshold and see how many images it would cover."
>
<v-data-table-virtual
:headers="headers" :items="store.rows" :loading="store.loading"
height="360" density="compact"
no-data-text="No tags on the allowlist yet accept a suggestion to add one."
>
<template #item.min_confidence="{ item }">
<v-text-field
:model-value="item.min_confidence" type="number"
density="compact" hide-details style="max-width: 100px;"
:min="floor" max="1" step="0.05"
@update:model-value="(v) => onThreshold(item.tag_id, v)"
/>
<template #item.applied_count="{ item }">
<span class="fc-num">{{ item.applied_count ?? '—' }}</span>
</template>
<template #item.min_confidence="{ item }">
<div class="fc-thr">
<v-text-field
:model-value="item.min_confidence" type="number"
density="compact" hide-details style="max-width: 100px;"
:min="floor" max="1" step="0.05"
:aria-label="`Auto-apply threshold for ${item.tag_name}`"
@update:model-value="(v) => onThreshold(item, v)"
/>
<span
v-if="proj[item.tag_id]"
class="fc-thr__proj"
:class="{ 'fc-thr__proj--loading': proj[item.tag_id].loading }"
:title="`At ${proj[item.tag_id].threshold}, a sweep would cover this many images`"
>≈ {{ proj[item.tag_id].count }} at {{ proj[item.tag_id].threshold }}</span>
</div>
</template>
<template #item.coverage_count="{ item }">
<span class="fc-num" :title="`Images a sweep covers at ${item.min_confidence}`">
{{ item.coverage_count ?? '—' }}
</span>
</template>
<template #item.actions="{ item }">
<v-btn
icon="mdi-delete" size="x-small" variant="text" color="error"
:aria-label="`Remove ${item.tag_name} from the allowlist`"
@click="store.remove(item.tag_id)"
/>
</template>
</v-data-table-virtual>
<p class="fc-muted text-caption mt-2">
<strong>Applied</strong> = images currently carrying the tag.
<strong>Covers</strong> = images a sweep would auto-apply it to at the
current threshold. Lower the threshold to cover more (less certain) images.
</p>
</MaintenanceTile>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { computed, onMounted, reactive } from 'vue'
import { useAllowlistStore } from '../../stores/allowlist.js'
import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
@@ -41,21 +68,53 @@ const ml = useMLStore()
const floor = computed(() => ml.settings?.tagger_store_floor ?? 0.70)
const headers = [
{ title: 'Tag', key: 'tag_name', sortable: true },
{ title: 'Kind', key: 'tag_kind', sortable: true, width: 110 },
{ title: 'Min confidence', key: 'min_confidence', sortable: false, width: 140 },
{ title: '', key: 'actions', sortable: false, width: 60 }
{ title: 'Kind', key: 'tag_kind', sortable: true, width: 100 },
{ title: 'Applied', key: 'applied_count', sortable: true, width: 90 },
{ title: 'Min confidence', key: 'min_confidence', sortable: false, width: 220 },
{ title: 'Covers', key: 'coverage_count', sortable: true, width: 90 },
{ title: '', key: 'actions', sortable: false, width: 56 }
]
// Per-row live projection while the operator drags a threshold:
// proj[tagId] = { threshold, count, loading }
const proj = reactive({})
onMounted(() => {
store.load()
if (!ml.settings) ml.loadSettings()
})
let debounce = null
function onThreshold(tagId, value) {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => {
const v = Math.max(parseFloat(value), floor.value)
if (v > 0 && v <= 1) store.updateThreshold(tagId, v)
const debounces = {}
function onThreshold(item, value) {
const tagId = item.tag_id
const v = Math.max(parseFloat(value), floor.value)
if (!(v > 0 && v <= 1)) return
const shown = Number(v.toFixed(2))
// Optimistic live projection box (loading until the count returns).
proj[tagId] = { threshold: shown, count: proj[tagId]?.count ?? '…', loading: true }
if (debounces[tagId]) clearTimeout(debounces[tagId])
debounces[tagId] = setTimeout(async () => {
try {
const { count } = await store.coverage(tagId, v)
proj[tagId] = { threshold: shown, count, loading: false }
} catch {
delete proj[tagId] // drop the projection rather than show a wrong number
}
// Commit the new threshold (also refreshes the row's stored coverage_count).
store.updateThreshold(tagId, v)
}, 500)
}
</script>
<style scoped>
.fc-num { font-variant-numeric: tabular-nums; }
.fc-thr { display: flex; align-items: center; gap: 10px; }
.fc-thr__proj {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: rgb(var(--v-theme-accent));
white-space: nowrap;
}
.fc-thr__proj--loading { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
+10 -1
View File
@@ -15,7 +15,16 @@ async function request(method, url, { body, params, signal } = {}) {
if (params) {
const search = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null) search.set(k, String(v))
if (v === undefined || v === null) continue
// Array values serialize as a REPEATED key (k=a&k=b) — e.g. the
// gallery's tag_or OR-groups, read server-side via request.args.getlist.
if (Array.isArray(v)) {
for (const item of v) {
if (item !== undefined && item !== null) search.append(k, String(item))
}
} else {
search.set(k, String(v))
}
}
const qs = search.toString()
if (qs) fullUrl += (url.includes('?') ? '&' : '?') + qs
+5
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
import SettingsView from './views/SettingsView.vue'
import GalleryView from './views/GalleryView.vue'
import ShowcaseView from './views/ShowcaseView.vue'
import ExploreView from './views/ExploreView.vue'
import BrowseView from './views/BrowseView.vue'
import ArtistView from './views/ArtistView.vue'
import SeriesView from './views/SeriesView.vue'
@@ -20,6 +21,10 @@ const routes = [
// FC-2: image backbone
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase' } },
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery' } },
// Explore: anchor on an image, walk its visual neighbours, close tag-coverage
// gaps across the cluster (#94). Optional anchor param — bare /explore (the
// nav entry) lands on an empty state that points you at an image.
{ path: '/explore/:imageId?', name: 'explore', component: ExploreView, meta: { title: 'Explore' } },
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
// the three "browse the library by an axis" surfaces. One nav entry; the old
// standalone paths redirect into the matching tab (below).
+3 -2
View File
@@ -65,10 +65,11 @@ export const useAdminStore = defineStore('admin', () => {
return _guard(() => api.delete(`/api/admin/tags/${tagId}`))
}
function mergeTags(destId, sourceId) {
// dryRun → {preview: {...}} (non-mutating counts + sample); else {result}.
function mergeTags(destId, sourceId, { dryRun = false } = {}) {
return _guard(() => api.post(
`/api/admin/tags/${destId}/merge`,
{ body: { source_id: sourceId } },
{ body: { source_id: sourceId, dry_run: dryRun } },
))
}
+16 -2
View File
@@ -18,7 +18,21 @@ export const useAllowlistStore = defineStore('allowlist', () => {
body: { min_confidence: minConfidence }
})
const r = rows.value.find(x => x.tag_id === tagId)
if (r) r.min_confidence = minConfidence
if (r) {
r.min_confidence = minConfidence
// The committed threshold changed the covered pool — refresh the row's
// coverage so the table stays truthful after a save.
try { r.coverage_count = (await coverage(tagId, minConfidence)).count }
catch { /* leave the stale count rather than blank it */ }
}
}
// Live "at threshold T, a sweep would cover ~N images" projection for the
// tuning dashboard. Returns { count, threshold }.
async function coverage(tagId, threshold) {
return api.get(`/api/tags/${tagId}/allowlist/coverage`, {
params: { threshold }
})
}
async function remove(tagId) {
@@ -26,5 +40,5 @@ export const useAllowlistStore = defineStore('allowlist', () => {
rows.value = rows.value.filter(x => x.tag_id !== tagId)
}
return { rows, loading, load, updateThreshold, remove }
return { rows, loading, load, updateThreshold, coverage, remove }
})
+92
View File
@@ -0,0 +1,92 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useInflightToken } from '../composables/useInflightToken.js'
// Explore (#94): anchor on an image, walk its visual neighbours (pgvector
// SigLIP via /api/gallery/similar), keep an in-memory breadcrumb of the walked
// path, and surface cluster-consensus tag gaps for the current set. The ROUTE
// (`/explore/:imageId`) is the source of truth for the anchor; the view calls
// anchorOn() on every param change and the store reconciles the trail.
const NEIGHBOR_LIMIT = 24
export const useExploreStore = defineStore('explore', () => {
const api = useApi()
const anchor = ref(null) // /api/gallery/image/<id> payload
const neighbors = ref([]) // [{id, thumbnail_url, ...}]
const breadcrumb = ref([]) // [{id, thumbnail_url}] walked path
const loading = ref(false)
const error = ref(null)
const inflight = useInflightToken()
async function anchorOn (id) {
const numId = Number(id)
if (!Number.isInteger(numId) || numId <= 0) return
inflight.cancel()
const t = inflight.claim()
loading.value = true
error.value = null
try {
const detail = await api.get(`/api/gallery/image/${numId}`)
if (!t.isCurrent()) return
anchor.value = detail
_reconcileTrail(numId, detail.thumbnail_url)
// Videos / not-yet-embedded images have no neighbours — leave the grid
// empty and let the view explain why (anchor.has_embedding === false).
if (detail.has_embedding) {
const body = await api.get('/api/gallery/similar', {
params: { similar_to: numId, limit: NEIGHBOR_LIMIT },
})
if (!t.isCurrent()) return
neighbors.value = body.images || []
} else {
neighbors.value = []
}
} catch (e) {
if (t.isCurrent()) { error.value = e.message; neighbors.value = [] }
} finally {
if (t.isCurrent()) loading.value = false
}
}
// Forward walk appends; navigating to an id already in the trail (a
// breadcrumb click, or a loop back) TRIMS to it — so the route stays the
// single source of truth and the crumb bar never grows stale branches.
function _reconcileTrail (id, thumbnailUrl) {
const idx = breadcrumb.value.findIndex((c) => c.id === id)
if (idx >= 0) breadcrumb.value = breadcrumb.value.slice(0, idx + 1)
else breadcrumb.value = [...breadcrumb.value, { id, thumbnail_url: thumbnailUrl }]
}
function reset () {
inflight.cancel()
anchor.value = null
neighbors.value = []
breadcrumb.value = []
error.value = null
loading.value = false
}
// The consensus set = the anchor plus its neighbours.
const clusterIds = computed(() => {
const ids = anchor.value ? [anchor.value.id] : []
return ids.concat(neighbors.value.map((n) => n.id))
})
// Quick id→thumbnail lookup so the gap panel can render the "missing"
// images without another fetch (they're already in the cluster).
const thumbById = computed(() => {
const map = {}
if (anchor.value) map[anchor.value.id] = anchor.value.thumbnail_url
for (const n of neighbors.value) map[n.id] = n.thumbnail_url
return map
})
return {
anchor, neighbors, breadcrumb, loading, error,
clusterIds, thumbById, NEIGHBOR_LIMIT,
anchorOn, reset,
}
})
+44 -2
View File
@@ -24,6 +24,10 @@ export const useGalleryStore = defineStore('gallery', () => {
const filter = ref({
tag_ids: [], artist_id: null, media_type: null,
sort: 'newest', post_id: null,
// #6 structured tag filter (AND-of-OR + exclude). tag_ids are the AND
// "include" singletons (light editor + back-compat); tag_or is a list of
// OR-groups (each group ORs, groups AND); tag_exclude is the NOT set.
tag_or: [], tag_exclude: [],
// Phase-2 faceted refine params.
platform: null, untagged: false, no_artist: false,
date_from: null, date_to: null,
@@ -102,6 +106,9 @@ export const useGalleryStore = defineStore('gallery', () => {
const f = filter.value
const params = { similar_to: f.similar_to, limit: 100 }
if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',')
const orParam = _orGroupsParam(f.tag_or)
if (orParam.length) params.tag_or = orParam
if (f.tag_exclude.length) params.tag_not = f.tag_exclude.join(',')
if (f.artist_id) params.artist_id = f.artist_id
if (f.media_type) params.media = f.media_type
if (f.platform) params.platform = f.platform
@@ -142,6 +149,9 @@ export const useGalleryStore = defineStore('gallery', () => {
if (filter.value.post_id) return { post_id: filter.value.post_id }
const p = {}
if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.join(',')
const orParam = _orGroupsParam(filter.value.tag_or)
if (orParam.length) p.tag_or = orParam // array → repeated tag_or= key
if (filter.value.tag_exclude.length) p.tag_not = filter.value.tag_exclude.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
@@ -177,6 +187,8 @@ export const useGalleryStore = defineStore('gallery', () => {
async function applyFilterFromQuery(q) {
filter.value = {
tag_ids: _parseIds(q.tag_id),
tag_or: _parseGroups(q.tag_or),
tag_exclude: _parseIds(q.tag_not),
artist_id: _toId(q.artist_id),
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
@@ -211,6 +223,14 @@ export const useGalleryStore = defineStore('gallery', () => {
if (!raw) return []
return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0)
}
// tag_or arrives from the URL as a string (one group) or string[] (many);
// each value is a comma-separated OR-group. Normalize to ids-of-ids, dropping
// empties so a stray `tag_or=` can't become a match-nothing group.
function _parseGroups(raw) {
if (raw == null) return []
const values = Array.isArray(raw) ? raw : [raw]
return values.map(_parseIds).filter((g) => g.length)
}
// 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.
@@ -218,7 +238,14 @@ export const useGalleryStore = defineStore('gallery', () => {
function noteArtistLabel(name) { artistLabel.value = name || null }
async function _resolveLabels() {
for (const id of filter.value.tag_ids) {
// Every tag id referenced anywhere in the structured filter needs a chip
// label — includes, every OR-group member, and excludes.
const allTagIds = new Set([
...filter.value.tag_ids,
...filter.value.tag_or.flat(),
...filter.value.tag_exclude,
])
for (const id of allTagIds) {
if (tagLabels.value[id]) continue
try {
const t = await api.get(`/api/tags/${id}`)
@@ -252,7 +279,10 @@ export const useGalleryStore = defineStore('gallery', () => {
// the exclusive post-detail view.
export function cloneFilter(f) {
return {
tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type,
tag_ids: [...f.tag_ids],
tag_or: (f.tag_or || []).map((g) => [...g]), // deep-clone the groups
tag_exclude: [...(f.tag_exclude || [])],
artist_id: f.artist_id, media_type: f.media_type,
sort: f.sort, platform: f.platform, untagged: f.untagged,
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
similar_to: f.similar_to,
@@ -262,6 +292,9 @@ export function cloneFilter(f) {
export function filterToQuery(f) {
const q = {}
if (f.tag_ids?.length) q.tag_id = f.tag_ids.join(',')
const orParam = _orGroupsParam(f.tag_or)
if (orParam.length) q.tag_or = orParam // array → repeated query key
if (f.tag_exclude?.length) q.tag_not = f.tag_exclude.join(',')
if (f.artist_id) q.artist_id = String(f.artist_id)
if (f.media_type) q.media = f.media_type
if (f.sort && f.sort !== 'newest') q.sort = f.sort
@@ -274,6 +307,15 @@ export function filterToQuery(f) {
return q
}
// Serialize OR-groups to repeated-param form: [[1,2],[3]] → ['1,2','3'],
// dropping empty groups. Shared by the store's activeFilterParam and the
// router-facing filterToQuery so both write tag_or the same way.
function _orGroupsParam(groups) {
return (groups || [])
.map((g) => (g || []).join(','))
.filter((s) => s.length)
}
function mergeGroups(existing, incoming) {
// Merge sequential groups with the same (year, month) instead of duplicating.
const merged = [...existing]
+20 -10
View File
@@ -100,7 +100,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
})
tagId = created.id
}
await api.post(`/api/images/${imageId}/suggestions/accept`, {
const res = await api.post(`/api/images/${imageId}/suggestions/accept`, {
body: { tag_id: tagId }
})
// Only drop from THIS image's category list — if the user navigated,
@@ -108,10 +108,23 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
if (currentImageId === imageId) {
_dropEverywhere(suggestion)
}
toast({
text: `Tagged: ${suggestion.display_name}`,
type: 'success'
})
_acceptToast('Tagged', suggestion.display_name, res)
}
// One non-blocking toast for accept/alias. When the accept newly allowlisted
// the tag, surface the coverage PROJECTION (#7) so the operator sees the
// auto-apply reach without a blocking pre-accept preview — the apply itself
// runs async, hence "~N".
function _acceptToast(verb, displayName, res) {
if (res?.allowlisted) {
const n = res.projected_count
toast({
text: `${verb}: ${displayName} — allowlisted, auto-applying to ~${n} image${n === 1 ? '' : 's'}`,
type: 'success'
})
} else {
toast({ text: `${verb}: ${displayName}`, type: 'success' })
}
}
async function aliasAccept(suggestion, canonicalTagId) {
@@ -123,7 +136,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
// reappearing unaliased. raw_name is null only for centroid hits, which
// can't be aliased (the UI hides the action for them).
const aliasString = suggestion.raw_name ?? suggestion.display_name
await api.post(`/api/images/${imageId}/suggestions/alias`, {
const res = await api.post(`/api/images/${imageId}/suggestions/alias`, {
body: {
alias_string: aliasString,
alias_category: suggestion.category,
@@ -133,10 +146,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
if (currentImageId === imageId) {
_dropEverywhere(suggestion)
}
toast({
text: `Aliased & tagged: ${suggestion.display_name}`,
type: 'success'
})
_acceptToast('Aliased & tagged', suggestion.display_name, res)
}
// Remove the alias behind an aliased suggestion (the raw prediction reverts to
+202
View File
@@ -0,0 +1,202 @@
<template>
<v-container fluid class="pt-2 pb-8 fc-explore">
<!-- Empty state: no anchor (the bare /explore nav entry). -->
<div v-if="!anchorId" class="fc-explore__empty">
<v-icon size="48" color="accent">mdi-compass-outline</v-icon>
<h2 class="fc-explore__empty-title">Explore by visual similarity</h2>
<p class="fc-explore__empty-body">
Open any image and choose <strong>Explore similar</strong>, or pick one
from the gallery to start walking its visual neighbours and closing
tag-coverage gaps across the cluster.
</p>
<v-btn color="accent" variant="flat" rounded="pill" :to="{ name: 'gallery' }">
Browse the gallery
</v-btn>
</div>
<template v-else>
<!-- Breadcrumb of the walked path. -->
<nav v-if="store.breadcrumb.length > 1" class="fc-explore__trail" aria-label="Explore path">
<button
v-for="(c, i) in store.breadcrumb" :key="c.id"
class="fc-explore__crumb"
:class="{ 'fc-explore__crumb--current': i === store.breadcrumb.length - 1 }"
type="button"
:aria-current="i === store.breadcrumb.length - 1 ? 'page' : undefined"
:title="`Step ${i + 1}`"
@click="goTo(c.id)"
>
<img :src="c.thumbnail_url" alt="" loading="lazy" />
</button>
</nav>
<v-alert v-if="store.error" type="error" variant="tonal" class="my-4">
Failed to load: {{ store.error }}
</v-alert>
<div class="fc-explore__layout">
<div class="fc-explore__main">
<!-- Anchor -->
<section v-if="store.anchor" class="fc-explore__anchor">
<img
:src="store.anchor.thumbnail_url" :alt="`Anchor image ${store.anchor.id}`"
class="fc-explore__anchor-img"
/>
<div class="fc-explore__anchor-meta">
<div class="fc-explore__anchor-title">
{{ store.anchor.artist?.name || 'Unknown artist' }}
</div>
<div class="fc-explore__chips">
<v-chip
v-for="t in store.anchor.tags || []" :key="t.id"
size="x-small" variant="tonal" :color="tagStore.colorFor(t.kind)"
>{{ t.name }}</v-chip>
<span v-if="!store.anchor.tags?.length" class="fc-muted text-caption">No tags yet.</span>
</div>
<v-btn
size="small" variant="text" color="accent"
prepend-icon="mdi-image-outline" @click="openInViewer(store.anchor.id)"
>Open in viewer</v-btn>
</div>
</section>
<!-- Neighbour grid -->
<h3 class="fc-explore__section-title">
Visual neighbours
<span v-if="store.neighbors.length" class="fc-muted">({{ store.neighbors.length }})</span>
</h3>
<div v-if="store.loading && !store.neighbors.length" class="fc-explore__grid">
<div v-for="n in 12" :key="n" class="fc-explore__skel" />
</div>
<div
v-else-if="store.anchor && !store.anchor.has_embedding"
class="fc-explore__note fc-muted"
>
This image has no visual embedding yet (videos and not-yet-processed
images), so there are no neighbours to walk.
</div>
<div v-else-if="!store.neighbors.length" class="fc-explore__note fc-muted">
No visual neighbours found.
</div>
<div v-else class="fc-explore__grid">
<button
v-for="img in store.neighbors" :key="img.id"
class="fc-explore__tile" type="button"
:title="`Walk to image ${img.id}`"
@click="goTo(img.id)"
>
<img :src="img.thumbnail_url" :alt="`Neighbour ${img.id}`" loading="lazy" />
</button>
</div>
</div>
<!-- Right rail: cluster tag-gap closing (#94c). -->
<aside class="fc-explore__rail">
<TagGapPanel />
</aside>
</div>
</template>
</v-container>
</template>
<script setup>
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useExploreStore } from '../stores/explore.js'
import { useTagStore } from '../stores/tags.js'
import { useModalStore } from '../stores/modal.js'
import TagGapPanel from '../components/explore/TagGapPanel.vue'
const route = useRoute()
const router = useRouter()
const store = useExploreStore()
const tagStore = useTagStore()
const modal = useModalStore()
const anchorId = computed(() => route.params.imageId || null)
// The route is the source of truth — anchor on whatever the URL says, on mount
// and on every param change (forward walk + breadcrumb backtrack both push the
// route). Reset the walk when we leave the anchored state entirely.
watch(
anchorId,
(id) => { if (id) store.anchorOn(id); else store.reset() },
{ immediate: true },
)
function goTo (id) {
if (Number(id) === Number(anchorId.value)) return
router.push({ name: 'explore', params: { imageId: String(id) } })
}
function openInViewer (id) { modal.open(id) }
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-explore__empty {
max-width: 520px; margin: 64px auto; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.fc-explore__empty-title { font-family: 'Fraunces', Georgia, serif; font-weight: 500; }
.fc-explore__empty-body { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-explore__trail {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
padding: 4px 0 12px;
}
.fc-explore__crumb {
width: 44px; height: 44px; border-radius: 8px; overflow: hidden;
border: 2px solid transparent; cursor: pointer; padding: 0;
background: rgb(var(--v-theme-surface-light)); flex: 0 0 auto;
}
.fc-explore__crumb img { width: 100%; height: 100%; object-fit: cover; }
.fc-explore__crumb--current { border-color: rgb(var(--v-theme-accent)); }
.fc-explore__crumb:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
.fc-explore__layout { display: flex; gap: 20px; align-items: flex-start; }
.fc-explore__main { flex: 1 1 auto; min-width: 0; }
.fc-explore__rail { flex: 0 0 320px; position: sticky; top: 80px; }
@media (max-width: 960px) {
.fc-explore__layout { flex-direction: column; }
.fc-explore__rail { flex-basis: auto; width: 100%; position: static; }
}
.fc-explore__anchor {
display: flex; gap: 16px; margin-bottom: 20px;
padding: 12px; border-radius: 12px;
background: rgba(var(--v-theme-on-surface), 0.03);
border: 1px solid rgba(var(--v-theme-on-surface), 0.10);
}
.fc-explore__anchor-img {
width: 160px; height: 160px; object-fit: cover; border-radius: 8px;
background: rgb(var(--v-theme-surface-light)); flex: 0 0 auto;
}
.fc-explore__anchor-meta { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.fc-explore__anchor-title { font-weight: 600; }
.fc-explore__chips { display: flex; flex-wrap: wrap; gap: 4px; }
.fc-explore__section-title {
font-size: 14px; font-weight: 600; margin: 4px 0 10px;
}
.fc-explore__grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
}
.fc-explore__tile {
aspect-ratio: 1; border-radius: 8px; overflow: hidden; padding: 0;
border: 2px solid transparent; cursor: pointer;
background: rgb(var(--v-theme-surface-light));
}
.fc-explore__tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-explore__tile:hover { border-color: rgba(var(--v-theme-accent), 0.6); }
.fc-explore__tile:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
.fc-explore__skel {
aspect-ratio: 1; border-radius: 8px;
background: rgb(var(--v-theme-surface-light)); animation: fc-pulse 1.4s ease-in-out infinite;
}
.fc-explore__note { padding: 24px 0; }
@keyframes fc-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.9; } }
</style>
+88 -7
View File
@@ -45,10 +45,9 @@
<v-card>
<v-card-title>Merge “{{ mergeSource?.name }}” into…</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3" style="opacity: 0.7;">
Pick the target tag. Source tag will be deleted; all its
image associations will move to the target. Must be same
kind ({{ mergeSource?.kind }}).
<p class="fc-muted text-body-2 mb-3">
Pick the target tag. Source tag's image associations move to the
target. Must be same kind ({{ mergeSource?.kind }}).
</p>
<v-autocomplete
v-model="mergeTargetId"
@@ -60,15 +59,59 @@
@update:search="onMergeSearch"
@keydown.enter.capture="onMergeEnter"
/>
<v-alert
v-if="adminStore.lastError"
type="warning" variant="tonal" density="compact" class="mt-3"
>{{ adminStore.lastError }}</v-alert>
<!-- #8: dry-run preview of what the merge will move, before commit. -->
<div v-if="mergeTargetId" class="fc-merge-preview mt-3">
<div v-if="mergePreviewing" class="d-flex align-center ga-2">
<v-progress-circular indeterminate size="18" width="2" color="accent" />
<span class="text-body-2 fc-muted">Checking what would move</span>
</div>
<div v-else-if="mergePreview">
<p class="text-body-2 mb-1">
Moves <strong>{{ mergePreview.images_moving }}</strong>
image(s) onto <strong>{{ mergePreview.target_name }}</strong>.
<span v-if="mergePreview.images_already_on_target" class="fc-muted">
({{ mergePreview.images_already_on_target }} already on it.)
</span>
</p>
<p v-if="mergePreview.series_pages" class="text-body-2 mb-1">
{{ mergePreview.series_pages }} series page(s) repointed.
</p>
<p class="text-body-2 mb-1">
<template v-if="mergePreview.will_alias">
{{ mergePreview.source_name }} kept as an alias the tagger may still predict it.
</template>
<template v-else>
{{ mergePreview.source_name }} will be permanently deleted.
</template>
</p>
<v-alert
v-if="!mergePreview.compatible"
type="error" variant="tonal" density="compact" class="mb-2"
>Different kind or fandom this merge would be rejected.</v-alert>
<div v-if="mergePreview.sample_thumbnails?.length" class="fc-merge-thumbs">
<img
v-for="(t, i) in mergePreview.sample_thumbnails" :key="i"
:src="t" alt="" loading="lazy"
/>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="mergePickerOpen = false">Cancel</v-btn>
<v-btn
color="warning" variant="flat" rounded="pill"
:disabled="!mergeTargetId"
:disabled="!mergePreview || !mergePreview.compatible"
:loading="merging"
@click="onMergeConfirm"
>Merge</v-btn>
>Merge{{ mergePreview ? ` ${mergePreview.images_moving} image(s)` : '' }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -114,6 +157,7 @@ import { useAdminStore } from '../stores/admin.js'
import { useApi } from '../composables/useApi.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
import { useAcceptOnEnter } from '../composables/useAcceptOnEnter.js'
import { usePreviewCommit } from '../composables/usePreviewCommit.js'
import TagCard from '../components/discovery/TagCard.vue'
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
@@ -207,10 +251,32 @@ const mergeHits = ref([])
const mergeLoading = ref(false)
let mergeDebounce = null
// #8: dry-run preview (counts + sample of what moves) before the irreversible
// merge — same usePreviewCommit primitive the maintenance tiles use.
const {
previewData: mergePreview, previewing: mergePreviewing, committing: merging,
runPreview: runMergePreview, runCommit: runMergeCommit,
} = usePreviewCommit({
preview: async () =>
(await adminStore.mergeTags(
mergeTargetId.value, mergeSource.value.id, { dryRun: true },
)).preview,
commit: () => adminStore.mergeTags(
mergeTargetId.value, mergeSource.value.id, { dryRun: false },
),
})
// Re-preview whenever the chosen target changes (and clear it when unset).
watch(mergeTargetId, (id) => {
if (id && mergeSource.value) runMergePreview()
else mergePreview.value = null
})
function onMergeWith(card) {
mergeSource.value = card
mergeTargetId.value = null
mergeHits.value = []
mergePreview.value = null
mergePickerOpen.value = true
}
@@ -234,8 +300,9 @@ function onMergeSearch(q) {
async function onMergeConfirm() {
if (!mergeSource.value || !mergeTargetId.value) return
if (!mergePreview.value || !mergePreview.value.compatible) return
try {
await adminStore.mergeTags(mergeTargetId.value, mergeSource.value.id)
await runMergeCommit()
mergePickerOpen.value = false
store.reset()
} catch {
@@ -282,4 +349,18 @@ async function onDeleteTagConfirm() {
.fc-tags__sentinel {
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
}
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-merge-preview {
padding: 10px 12px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
background: rgba(var(--v-theme-on-surface), 0.03);
}
.fc-merge-thumbs {
display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px;
}
.fc-merge-thumbs img {
width: 56px; height: 56px; object-fit: cover; border-radius: 6px;
background: rgb(var(--v-theme-surface-light));
}
</style>
+54
View File
@@ -144,6 +144,40 @@ describe('gallery store: composable filter', () => {
expect(urls.some((u) => u.includes('/api/gallery/timeline'))).toBe(false)
})
it('parses tag_or OR-groups + tag_not and forwards them (incl. repeated tag_or)', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => {
urls.push(url)
if (url.includes('/api/tags/')) {
return { status: 200, body: { id: 1, name: 'X', kind: 'general' } }
}
return { status: 200, body: EMPTY }
})
// tag_or arrives as an array (two groups); tag_not as a csv exclude list.
await s.applyFilterFromQuery({ tag_or: ['1,2', '3'], tag_not: '4,5' })
expect(s.filter.tag_or).toEqual([[1, 2], [3]])
expect(s.filter.tag_exclude).toEqual([4, 5])
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
expect(scroll).toContain('tag_or=1,2') // first OR-group
expect(scroll).toContain('tag_or=3') // second OR-group (repeated key)
expect(scroll).toContain('tag_not=4,5')
})
it('normalizes a single-string tag_or and drops empty groups', async () => {
const s = useGalleryStore()
stubFetch((url) => {
if (url.includes('/api/tags/')) {
return { status: 200, body: { id: 1, name: 'X', kind: 'general' } }
}
return { status: 200, body: EMPTY }
})
await s.applyFilterFromQuery({ tag_or: '7,8' }) // single string → one group
expect(s.filter.tag_or).toEqual([[7, 8]])
await s.applyFilterFromQuery({ tag_or: ['', '9'] }) // empty group dropped
expect(s.filter.tag_or).toEqual([[9]])
})
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
const s = useGalleryStore()
const urls = []
@@ -181,6 +215,26 @@ describe('filterToQuery / cloneFilter', () => {
expect(filterToQuery({ tag_ids: [], similar_to: null }).similar_to).toBeUndefined()
})
it('serializes tag_or OR-groups (repeated key) + tag_not, dropping empties', () => {
const q = filterToQuery({
tag_ids: [], tag_or: [[1, 2], [3], []], tag_exclude: [4, 5],
})
expect(q.tag_or).toEqual(['1,2', '3']) // empty group dropped → array
expect(q.tag_not).toBe('4,5')
const empty = filterToQuery({ tag_ids: [], tag_or: [], tag_exclude: [] })
expect(empty.tag_or).toBeUndefined()
expect(empty.tag_not).toBeUndefined()
})
it('cloneFilter deep-clones OR-groups + exclude', () => {
const orig = { tag_ids: [], tag_or: [[1, 2]], tag_exclude: [3] }
const c = cloneFilter(orig)
c.tag_or[0].push(9)
c.tag_exclude.push(8)
expect(orig.tag_or).toEqual([[1, 2]]) // group detached
expect(orig.tag_exclude).toEqual([3]) // exclude detached
})
it('cloneFilter copies refine fields and detaches tag_ids', () => {
const orig = {
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
+35
View File
@@ -270,6 +270,41 @@ async def test_tag_merge_succeeds_for_same_kind(client, db):
assert body["result"]["target_id"] == dest_id
@pytest.mark.asyncio
async def test_tag_merge_dry_run_previews_without_mutating(client, db):
from backend.app.models import ImageRecord
from backend.app.models.tag import image_tag
src = Tag(name="dry-src", kind=TagKind.general)
dest = Tag(name="dry-dest", kind=TagKind.general)
db.add_all([src, dest])
await db.flush()
img = ImageRecord(
path="/images/dry.jpg", sha256="dry" + "0" * 61, size_bytes=1,
mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
db.add(img)
await db.flush()
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=src.id, source="manual",
))
src_id, dest_id = src.id, dest.id
await db.commit()
resp = await client.post(
f"/api/admin/tags/{dest_id}/merge",
json={"source_id": src_id, "dry_run": True},
)
assert resp.status_code == 200
p = (await resp.get_json())["preview"]
assert p["compatible"] is True
assert p["images_moving"] == 1
assert p["source_total"] == 1
# No mutation: source still exists.
assert await db.get(Tag, src_id) is not None
@pytest.mark.asyncio
async def test_tag_merge_rejects_self_merge(client, db):
t = Tag(name="x", kind=TagKind.general)
+48 -1
View File
@@ -1,6 +1,6 @@
import pytest
from backend.app.models import TagAllowlist, TagKind
from backend.app.models import ImagePrediction, ImageRecord, TagAllowlist, TagKind
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
@@ -39,3 +39,50 @@ async def test_patch_rejects_out_of_range(client, db):
f"/api/tags/{tag.id}/allowlist", json={"min_confidence": 1.5}
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_coverage_endpoint(client, db):
tag = await TagService(db).find_or_create("Cover", TagKind.general)
db.add(TagAllowlist(tag_id=tag.id, min_confidence=0.90))
for i, score in enumerate((0.95, 0.60)):
img = ImageRecord(
path=f"/images/cov{i}.jpg", sha256=f"cv{i:062d}", size_bytes=1,
mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
db.add(img)
await db.flush()
db.add(ImagePrediction(
image_record_id=img.id, raw_name="Cover",
category="general", score=score,
))
await db.commit()
# Explicit threshold.
resp = await client.get(
f"/api/tags/{tag.id}/allowlist/coverage?threshold=0.90"
)
assert resp.status_code == 200
assert (await resp.get_json())["count"] == 1
# Lower what-if threshold widens coverage.
resp = await client.get(
f"/api/tags/{tag.id}/allowlist/coverage?threshold=0.50"
)
assert (await resp.get_json())["count"] == 2
# No threshold → uses the stored min_confidence (0.90).
resp = await client.get(f"/api/tags/{tag.id}/allowlist/coverage")
body = await resp.get_json()
assert body["count"] == 1
assert body["threshold"] == pytest.approx(0.90)
@pytest.mark.asyncio
async def test_coverage_rejects_bad_threshold(client, db):
tag = await TagService(db).find_or_create("Cover2", TagKind.general)
db.add(TagAllowlist(tag_id=tag.id))
await db.commit()
resp = await client.get(
f"/api/tags/{tag.id}/allowlist/coverage?threshold=2.0"
)
assert resp.status_code == 400
+39
View File
@@ -87,3 +87,42 @@ async def test_bulk_remove_route_requires_ids(client):
"/api/images/bulk/tags/remove", json={"tag_id": 1}
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_cluster_tag_gaps_requires_list(client):
resp = await client.post("/api/images/cluster/tag-gaps", json={})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_cluster_tag_gaps_route_reports_gap(client, db):
from backend.app.models import ImageRecord, TagKind
from backend.app.services.tag_service import TagService
tags = TagService(db)
gap = await tags.find_or_create("GapRoute", TagKind.general)
imgs = []
for i in range(4):
rec = ImageRecord(
path=f"/tmp/gaproute_{i}.png", sha256=f"g{i:063d}",
size_bytes=1, mime="image/png", origin="uploaded",
)
db.add(rec)
await db.flush()
imgs.append(rec.id)
for i in imgs[:3]:
await tags.add_to_image(i, gap.id) # on 3/4 → a gap at 0.6
await db.commit()
resp = await client.post(
"/api/images/cluster/tag-gaps",
json={"image_ids": imgs, "threshold": 0.6},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["total"] == 4
assert body["threshold"] == 0.6
g = next(x for x in body["gaps"] if x["tag_id"] == gap.id)
assert g["present_count"] == 3
assert g["missing_image_ids"] == [imgs[3]]
+61 -1
View File
@@ -2,7 +2,8 @@ from datetime import UTC, datetime, timedelta
import pytest
from backend.app.models import ImageRecord
from backend.app.models import ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag
pytestmark = pytest.mark.integration
@@ -114,3 +115,62 @@ async def test_scroll_date_from_param(client, db):
resp = await client.get("/api/gallery/scroll?date_from=2099-01-01&limit=10")
assert resp.status_code == 200
assert (await resp.get_json())["images"] == []
async def _seed_tagged(db):
"""3 images: [0]→a, [1]→b, [2]→x. Returns (records, {a,b,x} tag ids)."""
records = []
base = datetime.now(UTC)
for i in range(3):
r = ImageRecord(
path=f"/images/t/{i}.jpg", sha256=f"t{i:063d}",
size_bytes=1, mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
r.created_at = base - timedelta(minutes=i)
r.effective_date = r.created_at
db.add(r)
records.append(r)
a = Tag(name="apia", kind=TagKind.general)
b = Tag(name="apib", kind=TagKind.general)
x = Tag(name="apix", kind=TagKind.general)
db.add_all([a, b, x])
await db.flush()
await db.execute(image_tag.insert().values([
{"image_record_id": records[0].id, "tag_id": a.id, "source": "manual"},
{"image_record_id": records[1].id, "tag_id": b.id, "source": "manual"},
{"image_record_id": records[2].id, "tag_id": x.id, "source": "manual"},
]))
await db.commit()
return records, {"a": a.id, "b": b.id, "x": x.id}
@pytest.mark.asyncio
async def test_scroll_tag_or_param(client, db):
# ?tag_or=a,b is one OR-group → images carrying a OR b (not x-only).
records, ids = await _seed_tagged(db)
resp = await client.get(f"/api/gallery/scroll?tag_or={ids['a']},{ids['b']}&limit=10")
assert resp.status_code == 200
got = {i["id"] for i in (await resp.get_json())["images"]}
assert got == {records[0].id, records[1].id}
@pytest.mark.asyncio
async def test_scroll_tag_not_param(client, db):
# ?tag_not=x excludes the x-tagged image.
records, ids = await _seed_tagged(db)
resp = await client.get(f"/api/gallery/scroll?tag_not={ids['x']}&limit=10")
assert resp.status_code == 200
got = {i["id"] for i in (await resp.get_json())["images"]}
assert got == {records[0].id, records[1].id}
@pytest.mark.asyncio
async def test_scroll_repeated_tag_or_groups_anded(client, db):
# Two ?tag_or= groups AND together: (a) AND (b) → no single image has both.
records, ids = await _seed_tagged(db)
resp = await client.get(
f"/api/gallery/scroll?tag_or={ids['a']}&tag_or={ids['b']}&limit=10"
)
assert resp.status_code == 200
assert (await resp.get_json())["images"] == []
+29 -4
View File
@@ -14,11 +14,11 @@ def eager():
celery.conf.task_always_eager = False
async def _img(db, preds):
async def _img(db, preds, sha="s" * 64):
from tests._prediction_helpers import seed_predictions
img = ImageRecord(
path="/images/s.jpg", sha256="s" * 64, size_bytes=1,
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1,
mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
@@ -57,7 +57,31 @@ async def test_accept_then_applied(client, db):
resp = await client.post(
f"/api/images/{img.id}/suggestions/accept", json={"tag_id": tag.id}
)
assert resp.status_code == 204
assert resp.status_code == 200
body = await resp.get_json()
# #7b: a fresh accept newly-allowlists → projection payload for the toast.
assert body["allowlisted"] is True
assert body["tag_id"] == tag.id
assert body["tag_name"] == "AcceptMe"
assert "projected_count" in body
@pytest.mark.asyncio
async def test_accept_already_allowlisted_reports_not_new(client, db):
img1 = await _img(db, {}, sha="c" * 64)
img2 = await _img(db, {}, sha="d" * 64)
tag = await TagService(db).find_or_create("Twice", TagKind.character)
await db.commit()
first = await client.post(
f"/api/images/{img1.id}/suggestions/accept", json={"tag_id": tag.id}
)
assert (await first.get_json())["allowlisted"] is True
second = await client.post(
f"/api/images/{img2.id}/suggestions/accept", json={"tag_id": tag.id}
)
body = await second.get_json()
assert body["allowlisted"] is False # already on the allowlist
assert "projected_count" not in body
@pytest.mark.asyncio
@@ -127,7 +151,8 @@ async def test_alias_roundtrip_resolves_by_raw_key(client, db):
"canonical_tag_id": canonical.id,
},
)
assert resp.status_code == 204
assert resp.status_code == 200
assert (await resp.get_json())["allowlisted"] is True
# (b) A DIFFERENT image with the same prediction now resolves via the alias
# (image A's tag is applied, so it's filtered there). Had the alias been
+56
View File
@@ -120,3 +120,59 @@ async def test_bulk_remove_rejection_is_idempotent(db):
# Second remove: nothing to delete, rejection already present, no error.
removed = await svc.bulk_remove([i1], tag.id)
assert removed == 0
@pytest.mark.asyncio
async def test_tag_gaps_finds_consensus_excludes_common(db):
tags = TagService(db)
gap = await tags.find_or_create("Miku", TagKind.character)
common = await tags.find_or_create("Solo", TagKind.general)
imgs = [await _img(db) for _ in range(5)]
for i in imgs:
await tags.add_to_image(i, common.id) # on all 5 → not a gap
for i in imgs[:4]:
await tags.add_to_image(i, gap.id) # on 4/5 → a gap
svc = BulkTagService(db)
gaps = await svc.tag_gaps(imgs, threshold=0.6)
assert [g["tag_id"] for g in gaps] == [gap.id] # the common tag is excluded
g = gaps[0]
assert g["present_count"] == 4
assert g["total"] == 5
assert g["missing_image_ids"] == [imgs[4]]
@pytest.mark.asyncio
async def test_tag_gaps_excludes_rejected_laggard(db):
tags = TagService(db)
gap = await tags.find_or_create("Rin", TagKind.character)
imgs = [await _img(db) for _ in range(6)]
for i in imgs[:4]:
await tags.add_to_image(i, gap.id) # on 4/6 (min_present=ceil(3.6)=4)
# imgs[4], imgs[5] lack it; imgs[4] explicitly rejected it.
db.add(TagSuggestionRejection(image_record_id=imgs[4], tag_id=gap.id))
await db.flush()
svc = BulkTagService(db)
gaps = await svc.tag_gaps(imgs, threshold=0.6)
g = next(x for x in gaps if x["tag_id"] == gap.id)
assert g["missing_image_ids"] == [imgs[5]] # rejected laggard excluded
@pytest.mark.asyncio
async def test_tag_gaps_drops_tag_when_all_laggards_rejected(db):
tags = TagService(db)
gap = await tags.find_or_create("Len", TagKind.character)
imgs = [await _img(db) for _ in range(5)]
for i in imgs[:4]:
await tags.add_to_image(i, gap.id) # on 4/5, sole laggard imgs[4]
db.add(TagSuggestionRejection(image_record_id=imgs[4], tag_id=gap.id))
await db.flush()
svc = BulkTagService(db)
gaps = await svc.tag_gaps(imgs, threshold=0.6)
assert all(g["tag_id"] != gap.id for g in gaps) # nothing to apply → dropped
@pytest.mark.asyncio
async def test_tag_gaps_needs_at_least_two_images(db):
svc = BulkTagService(db)
assert await svc.tag_gaps([], 0.6) == []
assert await svc.tag_gaps([await _img(db)], 0.6) == []
+87
View File
@@ -305,6 +305,93 @@ async def test_scroll_multi_tag_and(db):
assert {i.id for i in just_a.images} == {images[0].id, images[1].id}
@pytest.mark.asyncio
async def test_scroll_tag_or_group(db):
# #6: one OR-group — match images carrying AT LEAST ONE of the group's tags.
images = await _seed_images(db, 3)
a = Tag(name="ora", kind=TagKind.general)
b = Tag(name="orb", kind=TagKind.general)
db.add_all([a, b])
await db.flush()
await db.execute(image_tag.insert().values([
{"image_record_id": images[0].id, "tag_id": a.id, "source": "manual"},
{"image_record_id": images[1].id, "tag_id": b.id, "source": "manual"},
# images[2] carries neither.
]))
svc = GalleryService(db)
page = await svc.scroll(cursor=None, limit=10, tag_or_groups=[[a.id, b.id]])
assert {i.id for i in page.images} == {images[0].id, images[1].id}
@pytest.mark.asyncio
async def test_scroll_tag_or_groups_anded(db):
# Two OR-groups AND together: image must satisfy (a OR b) AND (c).
images = await _seed_images(db, 3)
a = Tag(name="aaa", kind=TagKind.general)
b = Tag(name="bbb", kind=TagKind.general)
c = Tag(name="ccc", kind=TagKind.general)
db.add_all([a, b, c])
await db.flush()
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": c.id, "source": "manual"},
{"image_record_id": images[1].id, "tag_id": b.id, "source": "manual"},
{"image_record_id": images[2].id, "tag_id": a.id, "source": "manual"},
]))
svc = GalleryService(db)
page = await svc.scroll(
cursor=None, limit=10, tag_or_groups=[[a.id, b.id], [c.id]],
)
# Only images[0] has both an (a-or-b) tag AND c.
assert [i.id for i in page.images] == [images[0].id]
@pytest.mark.asyncio
async def test_scroll_tag_exclude(db):
# #6: exclude — drop images carrying ANY excluded tag.
images = await _seed_images(db, 3)
x = Tag(name="xxx", kind=TagKind.general)
db.add(x)
await db.flush()
await db.execute(image_tag.insert().values(
image_record_id=images[0].id, tag_id=x.id, source="manual",
))
svc = GalleryService(db)
page = await svc.scroll(cursor=None, limit=10, tag_exclude=[x.id])
assert {i.id for i in page.images} == {images[1].id, images[2].id}
@pytest.mark.asyncio
async def test_scroll_or_and_exclude_compose(db):
# The structured model composes: (a OR b) AND NOT x.
images = await _seed_images(db, 3)
a = Tag(name="ca", kind=TagKind.general)
b = Tag(name="cb", kind=TagKind.general)
x = Tag(name="cx", kind=TagKind.general)
db.add_all([a, b, x])
await db.flush()
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": x.id, "source": "manual"},
{"image_record_id": images[1].id, "tag_id": b.id, "source": "manual"},
]))
svc = GalleryService(db)
page = await svc.scroll(
cursor=None, limit=10, tag_or_groups=[[a.id, b.id]], tag_exclude=[x.id],
)
# images[0] matches the OR-group but is excluded by x; images[1] survives.
assert [i.id for i in page.images] == [images[1].id]
@pytest.mark.asyncio
async def test_require_single_filter_rejects_post_id_with_or_or_exclude(db):
svc = GalleryService(db)
with pytest.raises(ValueError, match="post_id cannot be combined"):
await svc.scroll(cursor=None, limit=10, post_id=5, tag_or_groups=[[1]])
with pytest.raises(ValueError, match="post_id cannot be combined"):
await svc.scroll(cursor=None, limit=10, post_id=5, tag_exclude=[1])
@pytest.mark.asyncio
async def test_scroll_media_filter(db):
imgs = await _seed_images(db, 2) # image/jpeg
+65 -3
View File
@@ -1,7 +1,13 @@
import pytest
from sqlalchemy import select
from backend.app.models import TagAllowlist, TagKind, TagSuggestionRejection
from backend.app.models import (
ImagePrediction,
TagAlias,
TagAllowlist,
TagKind,
TagSuggestionRejection,
)
from backend.app.models.tag import image_tag
from backend.app.services.ml.allowlist import AllowlistService
from backend.app.services.tag_service import TagService
@@ -9,10 +15,12 @@ from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
async def _make_image(db):
async def _make_image(db, sha: str = "x" * 64):
from backend.app.models import ImageRecord
img = ImageRecord(
path="/images/x.jpg", sha256="x" * 64, size_bytes=1,
# Full sha in the path — the first 8 chars collide for sequential
# shas like c{i:063d}, and path is UNIQUE (uq_image_record_path).
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1,
mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
@@ -21,6 +29,14 @@ async def _make_image(db):
return img
async def _add_pred(db, image_id, raw_name, score, category="general"):
db.add(ImagePrediction(
image_record_id=image_id, raw_name=raw_name,
category=category, score=score,
))
await db.flush()
@pytest.mark.asyncio
async def test_accept_applies_and_allowlists(db):
img = await _make_image(db)
@@ -106,6 +122,52 @@ async def test_update_threshold_and_remove(db):
assert await db.get(TagAllowlist, tag.id) is None
@pytest.mark.asyncio
async def test_coverage_by_threshold_direct_name(db):
tag = await TagService(db).find_or_create("Cov", TagKind.general)
svc = AllowlistService(db)
for i, score in enumerate((0.95, 0.80, 0.60)):
img = await _make_image(db, sha=f"c{i:063d}")
await _add_pred(db, img.id, "Cov", score)
assert await svc.coverage(tag.id, 0.90) == 1
assert await svc.coverage(tag.id, 0.70) == 2
assert await svc.coverage(tag.id, 0.50) == 3
@pytest.mark.asyncio
async def test_coverage_via_alias_respects_category(db):
tag = await TagService(db).find_or_create("Aliased", TagKind.character)
db.add(TagAlias(
alias_string="model_key", alias_category="character",
canonical_tag_id=tag.id,
))
await db.flush()
svc = AllowlistService(db)
hit = await _make_image(db, sha=f"a{0:063d}")
await _add_pred(db, hit.id, "model_key", 0.92, category="character")
# Same alias string but wrong category must NOT resolve to the tag.
miss = await _make_image(db, sha=f"a{1:063d}")
await _add_pred(db, miss.id, "model_key", 0.99, category="general")
assert await svc.coverage(tag.id, 0.90) == 1
@pytest.mark.asyncio
async def test_list_all_reports_applied_and_coverage(db):
tag = await TagService(db).find_or_create("Both", TagKind.general)
svc = AllowlistService(db)
applied_img = await _make_image(db, sha=f"b{0:063d}")
await svc.accept(applied_img.id, tag.id) # applies + allowlists
await _add_pred(db, applied_img.id, "Both", 0.95)
# A second image only has a qualifying prediction (covered, not applied).
cov_img = await _make_image(db, sha=f"b{1:063d}")
await _add_pred(db, cov_img.id, "Both", 0.95)
rows = await svc.list_all()
row = next(r for r in rows if r.tag_id == tag.id)
assert row.applied_count == 1 # only the accepted image
assert row.coverage_count == 2 # both have a ≥threshold pred
@pytest.mark.asyncio
async def test_update_threshold_clamped_to_store_floor(db):
# A min_confidence below the store floor (default 0.70) is clamped up —
+42
View File
@@ -56,6 +56,48 @@ async def test_merge_conflict_is_a_tag_validation_error(db):
assert issubclass(TagMergeConflict, TagValidationError)
@pytest.mark.asyncio
async def test_merge_preview_matches_apply(db):
# #8 rule-93 parity: the preview's moving-count is exactly what the apply
# moves, and already-on-target links are the ones dropped.
svc = TagService(db)
target = await svc.find_or_create("MTarget", TagKind.general)
source = await svc.find_or_create("MSource", TagKind.general)
a, b, c = await _img(db), await _img(db), await _img(db)
for img in (a, b, c):
await svc.add_to_image(img, source.id, source="manual")
await svc.add_to_image(a, target.id, source="manual") # a already on target
preview = await svc.merge_preview(source.id, target.id)
assert preview.compatible is True
assert preview.images_moving == 2 # b, c
assert preview.images_already_on_target == 1 # a
assert preview.source_total == 3
assert preview.will_alias is False # purely manual
result = await svc.merge(source.id, target.id)
assert result.merged_count == preview.images_moving # parity
@pytest.mark.asyncio
async def test_merge_preview_flags_incompatible_without_raising(db):
svc = TagService(db)
target = await svc.find_or_create("CharT", TagKind.character)
source = await svc.find_or_create("GenS", TagKind.general)
preview = await svc.merge_preview(source.id, target.id)
assert preview.compatible is False # kind mismatch surfaced, not raised
@pytest.mark.asyncio
async def test_merge_preview_self_and_missing_raise(db):
svc = TagService(db)
t = await svc.find_or_create("Solo", TagKind.general)
with pytest.raises(TagValidationError):
await svc.merge_preview(t.id, t.id)
with pytest.raises(TagValidationError):
await svc.merge_preview(t.id, 999999)
@pytest.mark.asyncio
async def test_will_alias_true_when_machine_sourced(db):
svc = TagService(db)