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
})