changes to series order system. add artist retagging tool

This commit is contained in:
Bryan Van Deusen
2026-01-23 08:38:07 -05:00
parent 82cf3b782b
commit d3e73b9533
5 changed files with 316 additions and 1 deletions
+126
View File
@@ -537,11 +537,13 @@ def search_tags():
- q: search query (matches tag name, case-insensitive)
- limit: max results (default 10)
- exclude_kind: comma-separated list of tag kinds to exclude (e.g., 'archive')
- kind: filter to only include tags of this kind (e.g., 'series')
"""
query = request.args.get('q', '').strip().lower()
limit = request.args.get('limit', 10, type=int)
exclude_kind = request.args.get('exclude_kind', '').strip()
exclude_kinds = [k.strip() for k in exclude_kind.split(',') if k.strip()]
include_kind = request.args.get('kind', '').strip()
if not query:
# Return most-used tags when no query
@@ -552,6 +554,8 @@ def search_tags():
)
if exclude_kinds:
base_query = base_query.filter(~Tag.kind.in_(exclude_kinds))
if include_kind:
base_query = base_query.filter(Tag.kind == include_kind)
tags = (
base_query
.order_by(func.count(image_tags.c.image_id).desc())
@@ -562,6 +566,8 @@ def search_tags():
base_query = Tag.query.filter(Tag.name.ilike(f'%{query}%'))
if exclude_kinds:
base_query = base_query.filter(~Tag.kind.in_(exclude_kinds))
if include_kind:
base_query = base_query.filter(Tag.kind == include_kind)
tags = (
base_query
.order_by(Tag.name)
@@ -1285,6 +1291,72 @@ def regenerate_missing_thumbnails_celery():
return jsonify({'ok': False, 'error': str(e)}), 500
@main.route('/api/reapply-artist-tags', methods=['POST'])
def reapply_artist_tags_from_paths():
"""
Re-apply artist tags to all images based on their file paths.
Artist is extracted from the first directory component after /images/
(e.g., /images/artist_name/file.jpg -> artist:artist_name)
"""
try:
images = ImageRecord.query.all()
updated = 0
skipped = 0
errors = []
for img in images:
if not img.filepath:
skipped += 1
continue
# Extract artist from path: /images/{artist}/...
# Handle both /images/artist/file.jpg and /images/artist/subdir/file.jpg
path_parts = img.filepath.split('/')
try:
# Find 'images' in path and get the next component
images_idx = path_parts.index('images')
if images_idx + 1 < len(path_parts):
artist_name = path_parts[images_idx + 1]
else:
skipped += 1
continue
except ValueError:
# 'images' not in path, try alternative format
# Maybe it's a Windows path or different structure
skipped += 1
continue
if not artist_name or artist_name == '':
skipped += 1
continue
# Create or get the artist tag
tag_name = f"artist:{artist_name}"
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag = Tag(name=tag_name, kind='artist')
db.session.add(tag)
db.session.flush()
# Add tag if not already present
if tag not in img.tags:
img.tags.append(tag)
updated += 1
db.session.commit()
return jsonify({
'ok': True,
'updated': updated,
'skipped': skipped,
'total': len(images),
'errors': errors if errors else None
})
except Exception as e:
db.session.rollback()
return jsonify({'ok': False, 'error': str(e)}), 500
@main.route('/api/celery/status')
def celery_cluster_status():
"""
@@ -1865,3 +1937,57 @@ def get_image_series_info(image_id):
'series_tag_id': sp.series_tag_id,
'series_name': sp.series_tag.name
})
@main.post('/api/image/<int:image_id>/add-to-series')
def add_image_to_series(image_id):
"""Add a single image to a series with a specific page number."""
from app.models import SeriesPage, Tag
data = request.get_json() or {}
series_tag_id = data.get('series_tag_id')
page_number = data.get('page_number')
if not series_tag_id:
return jsonify(ok=False, error="series_tag_id required"), 400
if not page_number:
return jsonify(ok=False, error="page_number required"), 400
# Verify the series tag exists and is a series
series_tag = Tag.query.get(series_tag_id)
if not series_tag:
return jsonify(ok=False, error="Series not found"), 404
if series_tag.kind != 'series':
return jsonify(ok=False, error="Tag is not a series"), 400
# Check if image is already in this series
existing = SeriesPage.query.filter_by(
series_tag_id=series_tag_id,
image_id=image_id
).first()
if existing:
return jsonify(ok=False, error="Image already in this series"), 409
# Check if page number is taken
page_taken = SeriesPage.query.filter_by(
series_tag_id=series_tag_id,
page_number=page_number
).first()
if page_taken:
return jsonify(ok=False, error=f"Page {page_number} already exists in this series"), 409
# Create the series page
sp = SeriesPage(
series_tag_id=series_tag_id,
image_id=image_id,
page_number=page_number
)
db.session.add(sp)
db.session.commit()
return jsonify(ok=True, series_page={
'id': sp.id,
'page_number': sp.page_number,
'series_tag_id': sp.series_tag_id,
'series_name': series_tag.name
})
+115 -1
View File
@@ -14,7 +14,7 @@ document.addEventListener('DOMContentLoaded', () => {
const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null;
const tagAutocomplete = document.getElementById('tagAutocomplete');
// Series info elements
// Series info elements (when image IS in a series)
const seriesInfoEl = document.getElementById('modalSeriesInfo');
const seriesNameEl = document.getElementById('modalSeriesName');
const pageNumberInput = document.getElementById('modalPageNumber');
@@ -22,6 +22,14 @@ document.addEventListener('DOMContentLoaded', () => {
const pageUpdateStatus = document.getElementById('pageUpdateStatus');
let currentSeriesPageId = null;
// Add to series elements (when image is NOT in a series)
const addToSeriesEl = document.getElementById('modalAddToSeries');
const seriesSelect = document.getElementById('seriesSelect');
const addSeriesPageNumber = document.getElementById('addSeriesPageNumber');
const addToSeriesBtn = document.getElementById('addToSeriesBtn');
const addToSeriesStatus = document.getElementById('addToSeriesStatus');
let seriesTagsLoaded = false;
// Autocomplete state
let autocompleteItems = [];
let autocompleteSelectedIndex = -1;
@@ -106,6 +114,82 @@ document.addEventListener('DOMContentLoaded', () => {
if (pageUpdateStatus) pageUpdateStatus.textContent = '';
}
function hideAddToSeries() {
if (addToSeriesEl) addToSeriesEl.style.display = 'none';
if (addToSeriesStatus) addToSeriesStatus.textContent = '';
}
async function loadSeriesTags() {
if (!seriesSelect || seriesTagsLoaded) return;
try {
// Fetch all series tags using the kind filter
const r = await fetch('/api/tags/search?kind=series&limit=100');
const j = await r.json();
const seriesTags = j.tags || [];
// Clear existing options except the first placeholder
seriesSelect.innerHTML = '<option value="">Select series...</option>';
seriesTags.forEach(tag => {
const opt = document.createElement('option');
opt.value = tag.id;
// Display name without prefix
opt.textContent = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name;
seriesSelect.appendChild(opt);
});
seriesTagsLoaded = true;
} catch {
// ignore
}
}
function showAddToSeries() {
if (!addToSeriesEl) return;
loadSeriesTags();
if (seriesSelect) seriesSelect.value = '';
if (addSeriesPageNumber) addSeriesPageNumber.value = '1';
if (addToSeriesBtn) addToSeriesBtn.disabled = true;
if (addToSeriesStatus) addToSeriesStatus.textContent = '';
addToSeriesEl.style.display = 'block';
}
async function handleAddToSeries() {
const imageId = getEditorImageId();
const seriesTagId = seriesSelect?.value;
const pageNumber = parseInt(addSeriesPageNumber?.value);
if (!imageId || !seriesTagId || isNaN(pageNumber) || pageNumber < 1) return;
if (addToSeriesBtn) addToSeriesBtn.disabled = true;
if (addToSeriesStatus) addToSeriesStatus.textContent = 'Adding...';
try {
const r = await fetch(`/api/image/${imageId}/add-to-series`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ series_tag_id: parseInt(seriesTagId), page_number: pageNumber })
});
const j = await r.json();
if (j.ok) {
if (addToSeriesStatus) {
addToSeriesStatus.textContent = '✓ Added';
setTimeout(() => { addToSeriesStatus.textContent = ''; }, 2000);
}
// Switch to showing the series info instead of add-to-series
hideAddToSeries();
showSeriesInfo(j.series_page);
} else {
if (addToSeriesStatus) addToSeriesStatus.textContent = j.error || 'Error';
if (addToSeriesBtn) addToSeriesBtn.disabled = false;
}
} catch (e) {
if (addToSeriesStatus) addToSeriesStatus.textContent = 'Error';
if (addToSeriesBtn) addToSeriesBtn.disabled = false;
}
}
function showSeriesInfo(seriesPage) {
if (!seriesInfoEl || !seriesPage) return;
currentSeriesPageId = seriesPage.id;
@@ -130,12 +214,16 @@ document.addEventListener('DOMContentLoaded', () => {
async function loadSeriesInfo(imageId) {
hideSeriesInfo();
hideAddToSeries();
if (!imageId) return;
try {
const r = await fetch(`/api/image/${imageId}/series-info`);
const j = await r.json();
if (j.ok && j.in_series) {
showSeriesInfo(j.series_page);
} else {
// Show add-to-series option when not in a series
showAddToSeries();
}
} catch {
// ignore
@@ -190,6 +278,31 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
}
// Enable/disable add button based on series selection
if (seriesSelect) {
seriesSelect.addEventListener('change', () => {
if (addToSeriesBtn) {
addToSeriesBtn.disabled = !seriesSelect.value;
}
});
}
// Add to series button click
if (addToSeriesBtn) {
addToSeriesBtn.addEventListener('click', handleAddToSeries);
}
// Allow Enter key in add-to-series page number input
if (addSeriesPageNumber) {
addSeriesPageNumber.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && seriesSelect?.value) {
e.preventDefault();
handleAddToSeries();
}
});
}
async function addTag(imageId, name) {
const fd = new FormData();
fd.append('name', name);
@@ -429,6 +542,7 @@ document.addEventListener('DOMContentLoaded', () => {
setEditorImageId('');
renderTags([]);
hideSeriesInfo();
hideAddToSeries();
history.replaceState({}, '', window.location.pathname + window.location.search);
}
+31
View File
@@ -642,6 +642,37 @@ header {
color: var(--text-muted);
}
/* Add to series section in modal */
.modal-add-to-series {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.add-series-header {
margin-bottom: 0.5rem;
}
.add-series-controls {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.series-select {
flex: 1;
min-width: 120px;
padding: 0.3rem 0.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: var(--text);
font-size: 0.85rem;
cursor: pointer;
}
.series-select option {
background: var(--panel);
color: var(--text);
}
.tag-editor .tags {
margin: 0 0 0.5rem 0;
min-height: 28px;
+15
View File
@@ -12,6 +12,7 @@
</div>
<div id="modalTagEditor" class="tag-editor" data-image-id="">
<!-- Shown when image IS in a series -->
<div id="modalSeriesInfo" class="modal-series-info" style="display: none;">
<div class="series-info-row">
<span class="series-info-label">📚 Page</span>
@@ -22,6 +23,20 @@
<span id="pageUpdateStatus" class="page-update-status"></span>
</div>
</div>
<!-- Shown when image is NOT in a series -->
<div id="modalAddToSeries" class="modal-add-to-series" style="display: none;">
<div class="add-series-header">
<span class="series-info-label">📚 Add to Series</span>
</div>
<div class="add-series-controls">
<select id="seriesSelect" class="series-select">
<option value="">Select series...</option>
</select>
<input type="number" id="addSeriesPageNumber" min="1" value="1" class="series-page-input" placeholder="#">
<button id="addToSeriesBtn" class="btn-small" disabled>Add</button>
</div>
<span id="addToSeriesStatus" class="page-update-status"></span>
</div>
<div id="modalTagList" class="tags"></div>
<form id="modalTagForm" class="tag-form" autocomplete="off">
<div class="tag-form-wrapper">
+29
View File
@@ -55,6 +55,7 @@
<button id="retryFailedBtn" class="btn warning-btn">Retry Failed Tasks</button>
<button id="clearCompletedBtn" class="btn secondary-btn">Clear Completed</button>
<button id="regenMissingThumbsBtn" class="btn secondary-btn">Regenerate Missing Thumbnails</button>
<button id="reapplyArtistTagsBtn" class="btn secondary-btn">Reapply Artist Tags from Paths</button>
</div>
<div id="celeryWorkerInfo" style="margin-top: 1rem;">
@@ -733,6 +734,34 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Reapply Artist Tags from Paths
document.getElementById('reapplyArtistTagsBtn').addEventListener('click', async () => {
const btn = document.getElementById('reapplyArtistTagsBtn');
if (!confirm('This will re-apply artist tags to all images based on their file paths. Continue?')) {
return;
}
btn.disabled = true;
btn.textContent = 'Processing...';
try {
const r = await fetch('/api/reapply-artist-tags', { method: 'POST' });
const data = await r.json();
if (data.ok) {
alert(`Artist tags reapplied!\n\nUpdated: ${data.updated}\nSkipped: ${data.skipped}\nTotal images: ${data.total}`);
} else {
alert('Failed: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Reapply Artist Tags from Paths';
}
});
// Load import settings
fetch('/api/import-settings')
.then(r => r.json())