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 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 19:53:02 -05:00
parent 3a30b876a2
commit bf20eeab5c
6 changed files with 362 additions and 18 deletions
+185 -9
View File
@@ -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/<int:image_id>/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/<int:image_id>/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/<int:image_id>/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/<int:tag_id>/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
})
+2
View File
@@ -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,
+121 -1
View File
@@ -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 = '<div class="tag-autocomplete-empty">No matching fandoms</div>';
fandomAutocomplete.style.display = 'block';
return;
}
fandomAutocomplete.innerHTML = tags.map((t, i) => {
const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name;
return `<div class="tag-autocomplete-item" data-index="${i}" data-id="${t.id}" data-name="${displayName}">
<span class="tag-autocomplete-name">🎭 ${displayName}</span>
</div>`;
}).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
+14
View File
@@ -135,6 +135,20 @@
</select>
</div>
<div class="form-group" id="fandomGroup" style="display: none;">
<label class="form-label" for="editTagFandom">Fandom</label>
<div style="position: relative;">
<input type="text"
id="editTagFandom"
class="form-input"
placeholder="Type to search fandoms..."
autocomplete="off">
<input type="hidden" id="editTagFandomId">
<div id="fandomAutocomplete" class="tag-autocomplete" style="display: none;"></div>
</div>
<small class="form-hint">Select an existing fandom or type a new one. Leave empty to remove fandom.</small>
</div>
<div class="form-actions">
<button type="submit" class="btn primary-btn">Save Changes</button>
<button type="button" id="tagDeleteBtn" class="btn danger-btn">Delete Tag</button>
@@ -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')
+10 -8
View File
@@ -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/<id>` | 635-647 | Get tag details |
| `GET /api/post-metadata/<id>` | 649-680 | Get post metadata for a post tag |
| `GET /api/post-metadata/by-tag-name/<name>` | 682-714 | Get post metadata by tag name |
| `POST /api/tag/<id>/update` | 716-793 | Update/merge tag |
| `POST /api/tag/<id>/update` | 716-793 | Update/merge tag (cascades fandom renames to linked characters) |
| `POST /api/tag/<id>/set-fandom` | — | Set/clear character tag's fandom association (updates name suffix) |
| `POST /api/tag/<id>/delete` | 795-809 | Delete tag |
| `GET /api/tag/<id>/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