changes to series order system. add artist retagging tool
This commit is contained in:
+126
@@ -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
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user