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.models import ImageRecord, Tag, ArchiveRecord, image_tags, ImportTask, ImportBatch, AppSettings
|
||||||
from app import db
|
from app import db
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
main = Blueprint('main', __name__)
|
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('/')
|
@main.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""
|
"""
|
||||||
@@ -627,13 +647,30 @@ def search_tags():
|
|||||||
.all()
|
.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")
|
@main.get("/image/<int:image_id>/tags")
|
||||||
def list_tags(image_id):
|
def list_tags(image_id):
|
||||||
img = ImageRecord.query.get_or_404(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")
|
@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.
|
Minimal JSON endpoint to add a tag to an image.
|
||||||
Accepts either 'artist:NAME' / 'archive:...' or a bare freeform tag ('mytag').
|
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()
|
name = (request.form.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
@@ -656,14 +696,37 @@ def add_tag(image_id):
|
|||||||
|
|
||||||
img = ImageRecord.query.get_or_404(image_id)
|
img = ImageRecord.query.get_or_404(image_id)
|
||||||
tag = Tag.query.filter_by(name=tag_name).first()
|
tag = Tag.query.filter_by(name=tag_name).first()
|
||||||
|
|
||||||
|
fandom_tag = None
|
||||||
|
|
||||||
if not tag:
|
if not tag:
|
||||||
tag = Tag(name=tag_name, kind=kind)
|
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.add(tag)
|
||||||
db.session.flush()
|
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:
|
if tag not in img.tags:
|
||||||
img.tags.append(tag)
|
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")
|
@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)
|
tag = Tag.query.get_or_404(tag_id)
|
||||||
# Get display name (without prefix)
|
# Get display name (without prefix)
|
||||||
display_name = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name
|
display_name = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name
|
||||||
return jsonify(ok=True, tag={
|
result = {
|
||||||
"id": tag.id,
|
"id": tag.id,
|
||||||
"name": tag.name,
|
"name": tag.name,
|
||||||
"display_name": display_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
|
"kind": existing.kind
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Handle fandom-related updates
|
||||||
|
old_kind = tag.kind
|
||||||
|
old_name = tag.name
|
||||||
|
|
||||||
# No conflict - just update the tag
|
# No conflict - just update the tag
|
||||||
tag.name = full_name
|
tag.name = full_name
|
||||||
tag.kind = new_kind
|
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()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify(ok=True, tag={
|
return jsonify(ok=True, tag={
|
||||||
"id": tag.id,
|
"id": tag.id,
|
||||||
"name": tag.name,
|
"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.
|
Add a tag to multiple images at once.
|
||||||
Expects JSON body with 'image_ids' array and 'tag_name' string.
|
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 {}
|
data = request.get_json() or {}
|
||||||
image_ids = data.get('image_ids', [])
|
image_ids = data.get('image_ids', [])
|
||||||
@@ -1954,12 +2112,23 @@ def bulk_add_tag():
|
|||||||
|
|
||||||
# Get or create the tag
|
# Get or create the tag
|
||||||
tag = Tag.query.filter_by(name=tag_name).first()
|
tag = Tag.query.filter_by(name=tag_name).first()
|
||||||
|
fandom_tag = None
|
||||||
|
|
||||||
if not tag:
|
if not tag:
|
||||||
tag = Tag(name=tag_name, kind=kind)
|
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.add(tag)
|
||||||
db.session.flush()
|
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(
|
images = ImageRecord.query.filter(
|
||||||
ImageRecord.id.in_(image_ids)
|
ImageRecord.id.in_(image_ids)
|
||||||
).all()
|
).all()
|
||||||
@@ -1969,13 +2138,20 @@ def bulk_add_tag():
|
|||||||
if tag not in img.tags:
|
if tag not in img.tags:
|
||||||
img.tags.append(tag)
|
img.tags.append(tag)
|
||||||
added_count += 1
|
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()
|
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({
|
return jsonify({
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'added_count': added_count,
|
'added_count': added_count,
|
||||||
'tag': {'id': tag.id, 'name': tag.name, 'kind': tag.kind}
|
'tag': result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class Tag(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(255), unique=True, nullable=False)
|
name = db.Column(db.String(255), unique=True, nullable=False)
|
||||||
kind = db.Column(db.String(64), nullable=True)
|
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(
|
images = db.relationship(
|
||||||
"ImageRecord",
|
"ImageRecord",
|
||||||
secondary=image_tags,
|
secondary=image_tags,
|
||||||
|
|||||||
+121
-1
@@ -11,7 +11,95 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const nameInput = document.getElementById('editTagName');
|
const nameInput = document.getElementById('editTagName');
|
||||||
const kindSelect = document.getElementById('editTagKind');
|
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 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)
|
// Open modal when edit button clicked (using event delegation for infinite scroll)
|
||||||
document.addEventListener('click', async (e) => {
|
document.addEventListener('click', async (e) => {
|
||||||
@@ -33,6 +121,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
tagIdInput.value = data.tag.id;
|
tagIdInput.value = data.tag.id;
|
||||||
nameInput.value = data.tag.display_name;
|
nameInput.value = data.tag.display_name;
|
||||||
kindSelect.value = data.tag.kind || 'user';
|
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');
|
modal.classList.add('active');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -44,6 +143,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
function closeModal() {
|
function closeModal() {
|
||||||
modal.classList.remove('active');
|
modal.classList.remove('active');
|
||||||
currentTagCard = null;
|
currentTagCard = null;
|
||||||
|
if (fandomAutocomplete) fandomAutocomplete.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
closeBtn?.addEventListener('click', closeModal);
|
closeBtn?.addEventListener('click', closeModal);
|
||||||
@@ -76,8 +176,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.ok) {
|
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) {
|
if (data.merged) {
|
||||||
// Tag was merged
|
|
||||||
alert(`Tags merged successfully! ${data.merged_count} images were updated.`);
|
alert(`Tags merged successfully! ${data.merged_count} images were updated.`);
|
||||||
}
|
}
|
||||||
// Reload page to show updated tag
|
// Reload page to show updated tag
|
||||||
|
|||||||
@@ -135,6 +135,20 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn primary-btn">Save Changes</button>
|
<button type="submit" class="btn primary-btn">Save Changes</button>
|
||||||
<button type="button" id="tagDeleteBtn" class="btn danger-btn">Delete Tag</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
@@ -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.
|
> **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)
|
Relationships: tags (many-to-many via image_tags)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tag (lines 42-51)
|
### Tag (lines 42-53)
|
||||||
Tagging system for organization.
|
Tagging system for organization.
|
||||||
```
|
```
|
||||||
Fields: id, name (unique), kind (artist|archive|user|source|post|character|series|fandom|rating)
|
Fields: id, name (unique), kind (artist|archive|user|source|post|character|series|fandom|rating),
|
||||||
Relationships: images (many-to-many)
|
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)
|
### 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/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/<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 |
|
| `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 |
|
| `POST /api/tag/<id>/delete` | 795-809 | Delete tag |
|
||||||
| `GET /api/tag/<id>/image-count` | 870-880 | Count images with 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) |
|
| `gallery-infinite.js` | Infinite scroll, timeline navigation (uses TagUtils) |
|
||||||
| `view-modal.js` | Modal image navigation, tag editing, series management (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) |
|
| `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) |
|
| `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`.
|
**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`)
|
- `source` - Platform source (prefix: `source:platform`)
|
||||||
- `post` - Post reference (prefix: `post:platform:artist:id`)
|
- `post` - Post reference (prefix: `post:platform:artist:id`)
|
||||||
- `user` - User-created tags (no prefix)
|
- `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`)
|
- `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`)
|
- `rating` - Content rating tags (prefix: `rating:level`)
|
||||||
|
|
||||||
### Import Pipeline
|
### Import Pipeline
|
||||||
|
|||||||
Reference in New Issue
Block a user