From d3e73b953340e49cf3ff89ab028341fa4a2d390a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 23 Jan 2026 08:38:07 -0500 Subject: [PATCH] changes to series order system. add artist retagging tool --- app/main.py | 126 ++++++++++++++++++++++++++++++ app/static/js/modal-pagination.js | 116 ++++++++++++++++++++++++++- app/static/style.css | 31 ++++++++ app/templates/_gallery_modal.html | 15 ++++ app/templates/settings.html | 29 +++++++ 5 files changed, 316 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index be0880d..81992b5 100644 --- a/app/main.py +++ b/app/main.py @@ -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//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 + }) diff --git a/app/static/js/modal-pagination.js b/app/static/js/modal-pagination.js index 1b8d3db..85484ef 100644 --- a/app/static/js/modal-pagination.js +++ b/app/static/js/modal-pagination.js @@ -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 = ''; + + 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); } diff --git a/app/static/style.css b/app/static/style.css index 5c9d00f..7aa30a3 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; diff --git a/app/templates/_gallery_modal.html b/app/templates/_gallery_modal.html index 25c53ff..b724c00 100644 --- a/app/templates/_gallery_modal.html +++ b/app/templates/_gallery_modal.html @@ -12,6 +12,7 @@
+ + +
diff --git a/app/templates/settings.html b/app/templates/settings.html index 2519509..b864df7 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -55,6 +55,7 @@ +
@@ -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())