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