From bf20eeab5cb8d7710b7f38cdc1fecf9919ac806c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 11 Feb 2026 19:53:02 -0500 Subject: [PATCH] character-fandom tag association system Link character tags to fandom tags via fandom_id FK with auto-apply behavior. Adding character:Saber (Fate) auto-creates fandom:Fate and applies it to the image. Renaming a fandom cascades to all linked characters. Includes set-fandom endpoint for retroactive association and fandom selector dropdown in the tag editor UI. Co-Authored-By: Claude Opus 4.6 --- app/main.py | 194 +++++++++++++++++- app/models.py | 2 + app/static/js/tag-editor.js | 122 ++++++++++- app/templates/tags_list.html | 14 ++ .../f26021101_add_fandom_id_to_tag.py | 30 +++ summary.md | 18 +- 6 files changed, 362 insertions(+), 18 deletions(-) create mode 100644 migrations/versions/f26021101_add_fandom_id_to_tag.py diff --git a/app/main.py b/app/main.py index 1c156a7..cc4036b 100644 --- a/app/main.py +++ b/app/main.py @@ -10,11 +10,31 @@ from datetime import datetime from app.models import ImageRecord, Tag, ArchiveRecord, image_tags, ImportTask, ImportBatch, AppSettings from app import db import os +import re import shutil main = Blueprint('main', __name__) +def _parse_character_fandom(name_after_prefix): + """Parse 'Name (Fandom)' -> ('Name', 'Fandom') or ('Name', None).""" + m = re.match(r'^(.+?)\s*\((.+)\)$', name_after_prefix) + if m: + return m.group(1).strip(), m.group(2).strip() + return name_after_prefix, None + + +def _ensure_fandom_tag(fandom_name): + """Find or create a fandom tag by name. Returns the Tag object.""" + full_name = f"fandom:{fandom_name}" + fandom_tag = Tag.query.filter_by(name=full_name).first() + if not fandom_tag: + fandom_tag = Tag(name=full_name, kind="fandom") + db.session.add(fandom_tag) + db.session.flush() + return fandom_tag + + @main.route('/') def index(): """ @@ -627,13 +647,30 @@ def search_tags(): .all() ) - return jsonify(tags=[{"id": t.id, "name": t.name, "kind": t.kind} for t in tags]) + return jsonify(tags=[ + { + "id": t.id, + "name": t.name, + "kind": t.kind, + "fandom_id": t.fandom_id if t.kind == "character" else None, + "fandom_name": (t.fandom.name.split(":", 1)[1] if t.fandom else None) if t.kind == "character" else None + } + for t in tags + ]) @main.get("/image//tags") def list_tags(image_id): img = ImageRecord.query.get_or_404(image_id) - return jsonify(ok=True, tags=[{"name": t.name, "kind": t.kind} for t in img.tags]) + return jsonify(ok=True, tags=[ + { + "name": t.name, + "kind": t.kind, + "fandom_id": t.fandom_id if t.kind == "character" else None, + "fandom_name": (t.fandom.name.split(":", 1)[1] if t.fandom else None) if t.kind == "character" else None + } + for t in img.tags + ]) @main.post("/image//tags/add") @@ -641,6 +678,9 @@ def add_tag(image_id): """ Minimal JSON endpoint to add a tag to an image. Accepts either 'artist:NAME' / 'archive:...' or a bare freeform tag ('mytag'). + + For character tags with a fandom suffix (e.g. 'character:Saber (Fate)'), + automatically creates/finds the fandom tag and adds it to the image. """ name = (request.form.get("name") or "").strip() if not name: @@ -656,14 +696,37 @@ def add_tag(image_id): img = ImageRecord.query.get_or_404(image_id) tag = Tag.query.filter_by(name=tag_name).first() + + fandom_tag = None + if not tag: tag = Tag(name=tag_name, kind=kind) + # For new character tags, parse and link fandom + if kind == "character": + char_part = tag_name.split(":", 1)[1] + char_name, fandom_name = _parse_character_fandom(char_part) + if fandom_name: + fandom_tag = _ensure_fandom_tag(fandom_name) + tag.fandom_id = fandom_tag.id db.session.add(tag) db.session.flush() + elif tag.kind == "character" and tag.fandom_id: + # Existing character tag with a fandom — grab the fandom tag for auto-apply + fandom_tag = Tag.query.get(tag.fandom_id) + if tag not in img.tags: img.tags.append(tag) - db.session.commit() - return jsonify(ok=True, tag={"name": tag.name, "kind": tag.kind}) + + # Auto-apply fandom tag to the image + if fandom_tag and fandom_tag not in img.tags: + img.tags.append(fandom_tag) + + db.session.commit() + + result = {"name": tag.name, "kind": tag.kind} + if fandom_tag: + result["fandom_tag"] = {"name": fandom_tag.name, "kind": fandom_tag.kind} + return jsonify(ok=True, tag=result) @main.post("/image//tags/remove") @@ -690,11 +753,76 @@ def get_tag(tag_id): tag = Tag.query.get_or_404(tag_id) # Get display name (without prefix) display_name = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name - return jsonify(ok=True, tag={ + result = { "id": tag.id, "name": tag.name, "display_name": display_name, - "kind": tag.kind + "kind": tag.kind, + "fandom_id": tag.fandom_id, + "fandom_name": tag.fandom.name.split(":", 1)[1] if tag.fandom else None + } + return jsonify(ok=True, tag=result) + + +@main.post("/api/tag//set-fandom") +def set_tag_fandom(tag_id): + """ + Set or clear a character tag's fandom association. + Updates the tag name to include/remove the (Fandom) suffix. + + Form params: + fandom_id: ID of an existing fandom tag (optional, clears if absent) + fandom_name: Name to find/create a fandom tag (used if fandom_id not given) + """ + tag = Tag.query.get_or_404(tag_id) + + if tag.kind != "character": + return jsonify(ok=False, error="Only character tags can have fandoms"), 400 + + fandom_id = request.form.get("fandom_id", type=int) + fandom_name = (request.form.get("fandom_name") or "").strip() + + # Get the character's base name (strip existing fandom suffix) + char_part = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name + char_base, _ = _parse_character_fandom(char_part) + + if fandom_id: + fandom_tag = Tag.query.get(fandom_id) + if not fandom_tag or fandom_tag.kind != "fandom": + return jsonify(ok=False, error="Invalid fandom tag"), 400 + fandom_display = fandom_tag.name.split(":", 1)[1] if ":" in fandom_tag.name else fandom_tag.name + elif fandom_name: + fandom_tag = _ensure_fandom_tag(fandom_name) + fandom_display = fandom_name + else: + # Clear fandom + tag.fandom_id = None + tag.name = f"character:{char_base}" + db.session.commit() + return jsonify(ok=True, tag={ + "id": tag.id, + "name": tag.name, + "kind": tag.kind, + "fandom_id": None, + "fandom_name": None + }) + + # Check for name conflict before renaming + new_name = f"character:{char_base} ({fandom_display})" + conflict = Tag.query.filter(Tag.name == new_name, Tag.id != tag_id).first() + if conflict: + return jsonify(ok=False, error=f"A tag named '{new_name}' already exists"), 409 + + tag.fandom_id = fandom_tag.id + tag.name = new_name + db.session.commit() + + return jsonify(ok=True, tag={ + "id": tag.id, + "name": tag.name, + "kind": tag.kind, + "fandom_id": tag.fandom_id, + "fandom_name": fandom_display }) @@ -832,15 +960,43 @@ def update_tag(tag_id): "kind": existing.kind }) + # Handle fandom-related updates + old_kind = tag.kind + old_name = tag.name + # No conflict - just update the tag tag.name = full_name tag.kind = new_kind + + # If editing a character tag, handle fandom from the name suffix + if new_kind == "character": + char_part = full_name.split(":", 1)[1] + char_name, fandom_name = _parse_character_fandom(char_part) + if fandom_name: + fandom_tag = _ensure_fandom_tag(fandom_name) + tag.fandom_id = fandom_tag.id + else: + tag.fandom_id = None + + # If renaming a fandom tag, cascade rename to all character tags that reference it + if new_kind == "fandom" and old_kind == "fandom" and full_name != old_name: + new_fandom_display = full_name.split(":", 1)[1] + old_fandom_display = old_name.split(":", 1)[1] if ":" in old_name else old_name + # Find all character tags linked to this fandom + linked_chars = Tag.query.filter_by(fandom_id=tag.id, kind="character").all() + for char_tag in linked_chars: + # Replace old fandom suffix with new one + char_part = char_tag.name.split(":", 1)[1] if ":" in char_tag.name else char_tag.name + char_base, _ = _parse_character_fandom(char_part) + char_tag.name = f"character:{char_base} ({new_fandom_display})" + db.session.commit() return jsonify(ok=True, tag={ "id": tag.id, "name": tag.name, - "kind": tag.kind + "kind": tag.kind, + "fandom_id": tag.fandom_id }) @@ -1935,6 +2091,8 @@ def bulk_add_tag(): """ Add a tag to multiple images at once. Expects JSON body with 'image_ids' array and 'tag_name' string. + + For character tags with a fandom, automatically adds the fandom tag too. """ data = request.get_json() or {} image_ids = data.get('image_ids', []) @@ -1954,12 +2112,23 @@ def bulk_add_tag(): # Get or create the tag tag = Tag.query.filter_by(name=tag_name).first() + fandom_tag = None + if not tag: tag = Tag(name=tag_name, kind=kind) + # For new character tags, parse and link fandom + if kind == 'character': + char_part = tag_name.split(':', 1)[1] + char_name, fandom_name = _parse_character_fandom(char_part) + if fandom_name: + fandom_tag = _ensure_fandom_tag(fandom_name) + tag.fandom_id = fandom_tag.id db.session.add(tag) db.session.flush() + elif tag.kind == 'character' and tag.fandom_id: + fandom_tag = Tag.query.get(tag.fandom_id) - # Get images that don't already have this tag + # Get images images = ImageRecord.query.filter( ImageRecord.id.in_(image_ids) ).all() @@ -1969,13 +2138,20 @@ def bulk_add_tag(): if tag not in img.tags: img.tags.append(tag) added_count += 1 + # Auto-apply fandom tag + if fandom_tag and fandom_tag not in img.tags: + img.tags.append(fandom_tag) db.session.commit() + result = {'id': tag.id, 'name': tag.name, 'kind': tag.kind} + if fandom_tag: + result['fandom_tag'] = {'id': fandom_tag.id, 'name': fandom_tag.name, 'kind': fandom_tag.kind} + return jsonify({ 'ok': True, 'added_count': added_count, - 'tag': {'id': tag.id, 'name': tag.name, 'kind': tag.kind} + 'tag': result }) diff --git a/app/models.py b/app/models.py index fa95455..70fe09f 100644 --- a/app/models.py +++ b/app/models.py @@ -43,6 +43,8 @@ class Tag(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(255), unique=True, nullable=False) kind = db.Column(db.String(64), nullable=True) + fandom_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True) + fandom = db.relationship("Tag", remote_side="Tag.id", foreign_keys=[fandom_id]) images = db.relationship( "ImageRecord", secondary=image_tags, diff --git a/app/static/js/tag-editor.js b/app/static/js/tag-editor.js index 91548e0..a0824f7 100644 --- a/app/static/js/tag-editor.js +++ b/app/static/js/tag-editor.js @@ -11,7 +11,95 @@ document.addEventListener('DOMContentLoaded', () => { const nameInput = document.getElementById('editTagName'); const kindSelect = document.getElementById('editTagKind'); + // Fandom UI elements + const fandomGroup = document.getElementById('fandomGroup'); + const fandomInput = document.getElementById('editTagFandom'); + const fandomIdInput = document.getElementById('editTagFandomId'); + const fandomAutocomplete = document.getElementById('fandomAutocomplete'); + let currentTagCard = null; + let fandomDebounce = null; + + // Show/hide fandom selector based on kind + function updateFandomVisibility() { + if (fandomGroup) { + fandomGroup.style.display = kindSelect.value === 'character' ? '' : 'none'; + } + } + + kindSelect?.addEventListener('change', updateFandomVisibility); + + // Fandom autocomplete search + async function searchFandoms(query) { + try { + const params = new URLSearchParams({ q: query, kind: 'fandom', limit: 10 }); + const res = await fetch(`/api/tags/search?${params}`); + const data = await res.json(); + return data.tags || []; + } catch (err) { + console.error('Failed to search fandoms:', err); + return []; + } + } + + function renderFandomAutocomplete(tags) { + if (!fandomAutocomplete) return; + + if (tags.length === 0) { + fandomAutocomplete.innerHTML = '
No matching fandoms
'; + fandomAutocomplete.style.display = 'block'; + return; + } + + fandomAutocomplete.innerHTML = tags.map((t, i) => { + const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name; + return `
+ 🎭 ${displayName} +
`; + }).join(''); + + fandomAutocomplete.style.display = 'block'; + } + + // Fandom input events + fandomInput?.addEventListener('input', () => { + clearTimeout(fandomDebounce); + fandomDebounce = setTimeout(async () => { + const query = fandomInput.value.trim(); + if (query.length < 1) { + fandomAutocomplete.style.display = 'none'; + fandomIdInput.value = ''; + return; + } + const tags = await searchFandoms(query); + renderFandomAutocomplete(tags); + // Clear the hidden ID since user is typing a new value + fandomIdInput.value = ''; + }, 200); + }); + + fandomInput?.addEventListener('focus', async () => { + const query = fandomInput.value.trim(); + const tags = await searchFandoms(query || ''); + renderFandomAutocomplete(tags); + }); + + fandomInput?.addEventListener('blur', () => { + // Delay to allow click on autocomplete item + setTimeout(() => { + if (fandomAutocomplete) fandomAutocomplete.style.display = 'none'; + }, 200); + }); + + // Click on fandom autocomplete item + fandomAutocomplete?.addEventListener('mousedown', (e) => { + e.preventDefault(); + const item = e.target.closest('.tag-autocomplete-item'); + if (!item) return; + fandomInput.value = item.dataset.name; + fandomIdInput.value = item.dataset.id; + fandomAutocomplete.style.display = 'none'; + }); // Open modal when edit button clicked (using event delegation for infinite scroll) document.addEventListener('click', async (e) => { @@ -33,6 +121,17 @@ document.addEventListener('DOMContentLoaded', () => { tagIdInput.value = data.tag.id; nameInput.value = data.tag.display_name; kindSelect.value = data.tag.kind || 'user'; + + // Set fandom fields + if (data.tag.fandom_id && data.tag.fandom_name) { + fandomInput.value = data.tag.fandom_name; + fandomIdInput.value = data.tag.fandom_id; + } else { + fandomInput.value = ''; + fandomIdInput.value = ''; + } + + updateFandomVisibility(); modal.classList.add('active'); } } catch (err) { @@ -44,6 +143,7 @@ document.addEventListener('DOMContentLoaded', () => { function closeModal() { modal.classList.remove('active'); currentTagCard = null; + if (fandomAutocomplete) fandomAutocomplete.style.display = 'none'; } closeBtn?.addEventListener('click', closeModal); @@ -76,8 +176,28 @@ document.addEventListener('DOMContentLoaded', () => { const data = await res.json(); if (data.ok) { + // After successful update, handle fandom association for character tags + if (kindSelect.value === 'character') { + const fandomName = fandomInput.value.trim(); + const fandomId = fandomIdInput.value; + const updateTagId = data.tag.id; + + // Set fandom via the dedicated endpoint + const fandomFormData = new FormData(); + if (fandomId) { + fandomFormData.append('fandom_id', fandomId); + } else if (fandomName) { + fandomFormData.append('fandom_name', fandomName); + } + // If both empty, this clears the fandom + + await fetch(`/api/tag/${updateTagId}/set-fandom`, { + method: 'POST', + body: fandomFormData + }); + } + if (data.merged) { - // Tag was merged alert(`Tags merged successfully! ${data.merged_count} images were updated.`); } // Reload page to show updated tag diff --git a/app/templates/tags_list.html b/app/templates/tags_list.html index 9dc2ac5..5a58e5e 100644 --- a/app/templates/tags_list.html +++ b/app/templates/tags_list.html @@ -135,6 +135,20 @@ + +
diff --git a/migrations/versions/f26021101_add_fandom_id_to_tag.py b/migrations/versions/f26021101_add_fandom_id_to_tag.py new file mode 100644 index 0000000..3411ed6 --- /dev/null +++ b/migrations/versions/f26021101_add_fandom_id_to_tag.py @@ -0,0 +1,30 @@ +"""Add fandom_id to tag for character-fandom association + +Revision ID: f26021101 +Revises: e26012202 +Create Date: 2026-02-11 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f26021101' +down_revision = 'e26012202' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('tag', sa.Column('fandom_id', sa.Integer(), nullable=True)) + op.create_foreign_key( + 'fk_tag_fandom_id', 'tag', 'tag', + ['fandom_id'], ['id'], + ondelete='SET NULL' + ) + + +def downgrade(): + op.drop_constraint('fk_tag_fandom_id', 'tag', type_='foreignkey') + op.drop_column('tag', 'fandom_id') diff --git a/summary.md b/summary.md index 59dc73c..e4cc366 100644 --- a/summary.md +++ b/summary.md @@ -4,7 +4,7 @@ A Flask-based image repository with Celery task processing for importing, organi > **IMPORTANT**: This summary must be kept up to date with any code changes. Update the timestamp below when making modifications. > -> **Last Updated**: 2026-02-05 (Consolidated JS utilities, shared modal template, Tags dropdown gap fix) +> **Last Updated**: 2026-02-11 (Character-fandom tag association system) --- @@ -59,11 +59,12 @@ Fields: id, filename, filepath, thumb_path, hash (SHA256), perceptual_hash (256- Relationships: tags (many-to-many via image_tags) ``` -### Tag (lines 42-51) +### Tag (lines 42-53) Tagging system for organization. ``` -Fields: id, name (unique), kind (artist|archive|user|source|post|character|series|fandom|rating) -Relationships: images (many-to-many) +Fields: id, name (unique), kind (artist|archive|user|source|post|character|series|fandom|rating), + fandom_id (nullable FK to tag.id, self-referential for character→fandom link) +Relationships: images (many-to-many), fandom (self-referential, character tags point to fandom tags) ``` ### ArchiveRecord (lines 53-66) @@ -259,7 +260,8 @@ Import tasks support two modes based on `context.deep_scan`: | `GET /api/tag/` | 635-647 | Get tag details | | `GET /api/post-metadata/` | 649-680 | Get post metadata for a post tag | | `GET /api/post-metadata/by-tag-name/` | 682-714 | Get post metadata by tag name | -| `POST /api/tag//update` | 716-793 | Update/merge tag | +| `POST /api/tag//update` | 716-793 | Update/merge tag (cascades fandom renames to linked characters) | +| `POST /api/tag//set-fandom` | — | Set/clear character tag's fandom association (updates name suffix) | | `POST /api/tag//delete` | 795-809 | Delete tag | | `GET /api/tag//image-count` | 870-880 | Count images with tag | @@ -376,7 +378,7 @@ layout.html (base) | `gallery-infinite.js` | Infinite scroll, timeline navigation (uses TagUtils) | | `view-modal.js` | Modal image navigation, tag editing, series management (uses TagUtils) | | `bulk-select.js` | Multi-select with ordered selection, bulk tag editing, tag refresh (uses TagUtils) | -| `tag-editor.js` | Tag autocomplete and editing | +| `tag-editor.js` | Tag autocomplete and editing (includes fandom selector for character tags) | | `showcase.js` | Showcase masonry layout and shuffle functionality (uses TagUtils) | **Script Loading Order**: `tag-utils.js` must be loaded before other JS files that depend on `window.TagUtils`. @@ -409,9 +411,9 @@ Files are stored with hash suffix: `{filename}__{hash[:10]}.{ext}` - `source` - Platform source (prefix: `source:platform`) - `post` - Post reference (prefix: `post:platform:artist:id`) - `user` - User-created tags (no prefix) -- `character` - Character tags (prefix: `character:name`) +- `character` - Character tags (prefix: `character:name` or `character:name (fandom)`) — linked to fandom via `fandom_id` FK; adding a character auto-applies its fandom tag - `series` - Series for ordered reading (prefix: `series:name`) -- `fandom` - Fandom/franchise tags (prefix: `fandom:name`) +- `fandom` - Fandom/franchise tags (prefix: `fandom:name`) — renaming cascades to all linked character tags - `rating` - Content rating tags (prefix: `rating:level`) ### Import Pipeline