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:
+185
-9
@@ -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
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user