diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index b2a9709..53781a9 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -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( diff --git a/backend/app/api/allowlist.py b/backend/app/api/allowlist.py index 53f38bb..31241de 100644 --- a/backend/app/api/allowlist.py +++ b/backend/app/api/allowlist.py @@ -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//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//allowlist", methods=["GET"]) async def get_one(tag_id: int): async with get_session() as session: diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 4e35bab..678ab4a 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -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, } diff --git a/backend/app/api/suggestions.py b/backend/app/api/suggestions.py index a8a9cf4..46fa2e5 100644 --- a/backend/app/api/suggestions.py +++ b/backend/app/api/suggestions.py @@ -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//suggestions", methods=["GET"]) async def get_suggestions(image_id: int): # ?min= 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( diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 0556ed9..31aee4e 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -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() diff --git a/backend/app/services/bulk_tag_service.py b/backend/app/services/bulk_tag_service.py index b573ef9..57d673c 100644 --- a/backend/app/services/bulk_tag_service.py +++ b/backend/app/services/bulk_tag_service.py @@ -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: diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index ac5a975..0618d75 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -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, ) diff --git a/backend/app/services/ml/allowlist.py b/backend/app/services/ml/allowlist.py index 08a6ac7..8f7d14e 100644 --- a/backend/app/services/ml/allowlist.py +++ b/backend/app/services/ml/allowlist.py @@ -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 diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 93b3ce7..495875a 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -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 diff --git a/frontend/src/components/common/TagPicker.vue b/frontend/src/components/common/TagPicker.vue new file mode 100644 index 0000000..8e96aa0 --- /dev/null +++ b/frontend/src/components/common/TagPicker.vue @@ -0,0 +1,68 @@ + + + diff --git a/frontend/src/components/explore/TagGapPanel.vue b/frontend/src/components/explore/TagGapPanel.vue new file mode 100644 index 0000000..f44546c --- /dev/null +++ b/frontend/src/components/explore/TagGapPanel.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/frontend/src/components/gallery/GalleryFilterBar.vue b/frontend/src/components/gallery/GalleryFilterBar.vue index 39c86ac..e13949a 100644 --- a/frontend/src/components/gallery/GalleryFilterBar.vue +++ b/frontend/src/components/gallery/GalleryFilterBar.vue @@ -27,11 +27,34 @@
+ {{ store.tagLabels[id] || `#${id}` }} + + mdi-minus{{ store.tagLabels[id] || `#${id}` }} + + {{ orGroupCount }} OR-group{{ orGroupCount > 1 ? 's' : '' }} + Advanced{{ advancedCount ? ` (${advancedCount})` : '' }} + + + + +
@@ -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 diff --git a/frontend/src/components/gallery/TagQueryBuilder.vue b/frontend/src/components/gallery/TagQueryBuilder.vue new file mode 100644 index 0000000..f674112 --- /dev/null +++ b/frontend/src/components/gallery/TagQueryBuilder.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/frontend/src/components/modal/ImageMetaBar.vue b/frontend/src/components/modal/ImageMetaBar.vue new file mode 100644 index 0000000..b9629ad --- /dev/null +++ b/frontend/src/components/modal/ImageMetaBar.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/frontend/src/components/modal/ImageViewer.vue b/frontend/src/components/modal/ImageViewer.vue index b766665..7831206 100644 --- a/frontend/src/components/modal/ImageViewer.vue +++ b/frontend/src/components/modal/ImageViewer.vue @@ -73,6 +73,7 @@