Major refactor: deduplicate routes, sync, JS, and fix bugs
- Extract 8 common route patterns into factory functions in routes/shared.py (favourite, upload, replace cover, save defaults, clone, save JSON, get missing, clear covers) — removes ~1,100 lines across 9 route files - Extract generic _sync_category() in sync.py — 7 sync functions become one-liner wrappers, removing ~350 lines - Extract shared detail page JS into static/js/detail-common.js — all 9 detail templates now call initDetailPage() with minimal config - Extract layout inline JS into static/js/layout-utils.js (~185 lines) - Extract library toolbar JS into static/js/library-toolbar.js - Fix finalize missing-image bug: raise RuntimeError instead of logging warning so job is marked failed - Fix missing scheduler default in _default_checkpoint_data() - Fix N+1 query in Character.get_available_outfits() with batch IN query - Convert all print() to logger across services and routes - Add missing tags display to styles, scenes, detailers, checkpoints detail - Update delete buttons to use trash.png icon with solid red background - Update CLAUDE.md to reflect new architecture Net reduction: ~1,600 lines Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
193
static/js/library-toolbar.js
Normal file
193
static/js/library-toolbar.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Shared library toolbar JS — handles batch generation, tag regeneration,
|
||||
* and bulk create operations for all category index pages.
|
||||
*
|
||||
* Reads configuration from data attributes on the toolbar wrapper element:
|
||||
* data-toolbar-category — e.g. "outfits"
|
||||
* data-get-missing-url — e.g. "/get_missing_outfits"
|
||||
* data-clear-covers-url — e.g. "/clear_all_outfit_covers"
|
||||
* data-generate-url — e.g. "/outfit/{slug}/generate" (with {slug} placeholder)
|
||||
* data-regen-tags-category — e.g. "outfits" (for /admin/bulk_regenerate_tags/<cat>)
|
||||
* data-bulk-create-url — e.g. "/outfits/bulk_create"
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const toolbar = document.querySelector('[data-toolbar-category]');
|
||||
if (!toolbar) return;
|
||||
|
||||
const category = toolbar.dataset.toolbarCategory;
|
||||
const getMissingUrl = toolbar.dataset.getMissingUrl;
|
||||
const clearCoversUrl = toolbar.dataset.clearCoversUrl;
|
||||
const generateUrlPattern = toolbar.dataset.generateUrl;
|
||||
const regenTagsCat = toolbar.dataset.regenTagsCategory;
|
||||
const bulkCreateUrl = toolbar.dataset.bulkCreateUrl;
|
||||
|
||||
const batchBtn = document.getElementById('batch-generate-btn');
|
||||
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||
const regenTagsBtn = document.getElementById('regen-tags-all-btn');
|
||||
const bulkCreateBtn = document.getElementById('bulk-create-btn');
|
||||
const bulkOverwriteBtn = document.getElementById('bulk-overwrite-btn');
|
||||
|
||||
// --- Utility: poll a job until done ---
|
||||
function waitForJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||
else if (data.status === 'failed' || data.status === 'removed') {
|
||||
clearInterval(poll); reject(new Error(data.error || 'Job failed'));
|
||||
}
|
||||
} catch (err) { /* ignore transient errors */ }
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Batch Generate Missing Covers ---
|
||||
async function runBatch() {
|
||||
if (!getMissingUrl || !generateUrlPattern) return;
|
||||
|
||||
const response = await fetch(getMissingUrl);
|
||||
const data = await response.json();
|
||||
const missing = data.missing;
|
||||
|
||||
if (missing.length === 0) {
|
||||
alert('No items missing cover images.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchBtn) batchBtn.disabled = true;
|
||||
if (regenAllBtn) regenAllBtn.disabled = true;
|
||||
|
||||
// Phase 1: Queue all jobs
|
||||
const jobs = [];
|
||||
for (const item of missing) {
|
||||
try {
|
||||
const url = generateUrlPattern.replace('{slug}', item.slug);
|
||||
const body = new URLSearchParams({ action: 'replace' });
|
||||
// Secondary categories need a random character for generation
|
||||
if (category !== 'characters') {
|
||||
body.set('character_slug', '__random__');
|
||||
}
|
||||
const genResp = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const genData = await genResp.json();
|
||||
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
|
||||
} catch (err) {
|
||||
console.error(`Failed to queue ${item.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Poll all concurrently, update card images as they finish
|
||||
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
||||
try {
|
||||
const jobResult = await waitForJob(jobId);
|
||||
if (jobResult.result && jobResult.result.image_url) {
|
||||
const img = document.getElementById(`img-${item.slug}`);
|
||||
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
||||
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
||||
if (noImgSpan) noImgSpan.classList.add('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed for ${item.name}:`, err);
|
||||
}
|
||||
}));
|
||||
|
||||
if (batchBtn) batchBtn.disabled = false;
|
||||
if (regenAllBtn) regenAllBtn.disabled = false;
|
||||
alert(`Batch generation complete! ${jobs.length} images queued.`);
|
||||
}
|
||||
|
||||
if (batchBtn) {
|
||||
batchBtn.addEventListener('click', async () => {
|
||||
if (!getMissingUrl) return;
|
||||
const response = await fetch(getMissingUrl);
|
||||
const data = await response.json();
|
||||
if (data.missing.length === 0) {
|
||||
alert('No items missing cover images.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Generate cover images for ${data.missing.length} items?`)) return;
|
||||
runBatch();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Regenerate All Covers ---
|
||||
if (regenAllBtn) {
|
||||
regenAllBtn.addEventListener('click', async () => {
|
||||
if (!clearCoversUrl) return;
|
||||
if (!confirm('This will unassign ALL current cover images and generate new ones. Proceed?')) return;
|
||||
|
||||
const clearResp = await fetch(clearCoversUrl, { method: 'POST' });
|
||||
if (clearResp.ok) {
|
||||
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
||||
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
||||
runBatch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Regenerate Tags (LLM) ---
|
||||
if (regenTagsBtn && regenTagsCat) {
|
||||
regenTagsBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Regenerate tags for ALL items using the LLM? This will consume API credits.')) return;
|
||||
regenTagsBtn.disabled = true;
|
||||
const origText = regenTagsBtn.textContent;
|
||||
regenTagsBtn.textContent = 'Queuing...';
|
||||
try {
|
||||
const resp = await fetch(`/admin/bulk_regenerate_tags/${regenTagsCat}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
alert(`Queued ${data.queued} tag regeneration tasks. Watch progress in the queue.`);
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Request failed: ' + err.message);
|
||||
}
|
||||
regenTagsBtn.disabled = false;
|
||||
regenTagsBtn.textContent = origText;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Bulk Create from LoRAs (LLM) ---
|
||||
async function doBulkCreate(overwrite) {
|
||||
if (!bulkCreateUrl) return;
|
||||
const body = overwrite ? new URLSearchParams({ overwrite: 'true' }) : undefined;
|
||||
try {
|
||||
const resp = await fetch(bulkCreateUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: body,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
alert(`Queued ${data.queued} LLM tasks (${data.skipped} skipped). Watch progress in the queue.`);
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Request failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (bulkCreateBtn) {
|
||||
bulkCreateBtn.addEventListener('click', () => {
|
||||
if (!confirm('Create entries from LoRA files using the LLM? This will consume API credits.')) return;
|
||||
doBulkCreate(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (bulkOverwriteBtn) {
|
||||
bulkOverwriteBtn.addEventListener('click', () => {
|
||||
if (!confirm('WARNING: This will overwrite ALL existing metadata using the LLM. This consumes API credits. Proceed?')) return;
|
||||
doBulkCreate(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user