Tagging & viewing roadmap: tag query surface + hygiene projections + Explore view (Clusters A/B/C) #128
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 } },
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user