* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
350 lines
19 KiB
JavaScript
350 lines
19 KiB
JavaScript
import { randomUUID } from 'crypto';
|
|
import { basename, dirname, extname, resolve } from 'path';
|
|
import { UPLOADS_DIR } from './runtime-paths.mjs';
|
|
import { db, createImageEditVersion, getImageEditRoot } from './db.mjs';
|
|
import { renderImageVersion, resolveEditableImage } from './image-editor.mjs';
|
|
|
|
const uploadRoot = resolve(UPLOADS_DIR);
|
|
const TERMINAL_ITEM = new Set(['completed', 'failed', 'cancelled']);
|
|
const TERMINAL_JOB = new Set(['completed', 'partial', 'failed', 'cancelled']);
|
|
let loopTimer = null;
|
|
let processing = false;
|
|
|
|
function jsonParse(value, fallback = {}) {
|
|
try { return JSON.parse(value || ''); } catch { return fallback; }
|
|
}
|
|
|
|
function normalizePath(value) {
|
|
return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
|
}
|
|
|
|
function clamp(value, min, max, fallback = min) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : fallback;
|
|
}
|
|
|
|
function slug(value, fallback = 'bild') {
|
|
const clean = String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
return clean || fallback;
|
|
}
|
|
|
|
function parseProfile(row) {
|
|
if (!row) return null;
|
|
return {
|
|
...row,
|
|
is_builtin: Number(row.is_builtin || 0) === 1,
|
|
operations: jsonParse(row.operations_json, {}),
|
|
};
|
|
}
|
|
|
|
function parseItem(row) {
|
|
if (!row) return null;
|
|
return { ...row, settings: jsonParse(row.settings_json, {}) };
|
|
}
|
|
|
|
function parseJob(row, { includeItems = false } = {}) {
|
|
if (!row) return null;
|
|
const job = {
|
|
...row,
|
|
pause_requested: Number(row.pause_requested || 0) === 1,
|
|
cancel_requested: Number(row.cancel_requested || 0) === 1,
|
|
settings: jsonParse(row.settings_json, {}),
|
|
};
|
|
if (includeItems) job.items = db.prepare('SELECT * FROM image_batch_job_items WHERE job_id = ? ORDER BY created_at, id').all(job.id).map(parseItem);
|
|
return job;
|
|
}
|
|
|
|
export function listImageBatchProfiles() {
|
|
return db.prepare('SELECT * FROM image_batch_profiles ORDER BY is_builtin DESC, name COLLATE NOCASE').all().map(parseProfile);
|
|
}
|
|
|
|
export function createImageBatchProfile(data = {}) {
|
|
const name = String(data.name || '').trim().slice(0, 80);
|
|
if (!name) throw new Error('Profilname fehlt.');
|
|
const description = String(data.description || '').trim().slice(0, 300);
|
|
const operations = data.operations && typeof data.operations === 'object' ? data.operations : {};
|
|
const filenamePattern = String(data.filename_pattern || '{name}-{profile}-{index}').trim().slice(0, 120) || '{name}-{profile}-{index}';
|
|
const key = `custom-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
|
const result = db.prepare(`INSERT INTO image_batch_profiles
|
|
(key, name, description, operations_json, filename_pattern, is_builtin) VALUES (?, ?, ?, ?, ?, 0)`)
|
|
.run(key, name, description, JSON.stringify(operations), filenamePattern);
|
|
return parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(result.lastInsertRowid));
|
|
}
|
|
|
|
export function updateImageBatchProfile(id, data = {}) {
|
|
const current = parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(Number(id)));
|
|
if (!current) throw new Error('Produktionsprofil wurde nicht gefunden.');
|
|
const name = String(data.name ?? current.name).trim().slice(0, 80);
|
|
if (!name) throw new Error('Profilname fehlt.');
|
|
const description = String(data.description ?? current.description ?? '').trim().slice(0, 300);
|
|
const operations = data.operations && typeof data.operations === 'object' ? data.operations : current.operations;
|
|
const filenamePattern = String(data.filename_pattern ?? current.filename_pattern ?? '{name}-{profile}-{index}').trim().slice(0, 120);
|
|
db.prepare(`UPDATE image_batch_profiles SET name = ?, description = ?, operations_json = ?, filename_pattern = ?, updated_at = datetime('now') WHERE id = ?`)
|
|
.run(name, description, JSON.stringify(operations), filenamePattern, Number(id));
|
|
return parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(Number(id)));
|
|
}
|
|
|
|
export function deleteImageBatchProfile(id) {
|
|
const profile = parseProfile(db.prepare('SELECT * FROM image_batch_profiles WHERE id = ?').get(Number(id)));
|
|
if (!profile) return { ok: true, deleted: 0 };
|
|
if (profile.is_builtin) throw new Error('Integrierte Produktionsprofile können nicht gelöscht werden.');
|
|
const inUse = Number(db.prepare("SELECT COUNT(*) AS count FROM image_batch_job_items WHERE profile_id = ? AND status IN ('queued','running')").get(Number(id))?.count || 0);
|
|
if (inUse) throw new Error('Das Profil wird noch von einem laufenden Auftrag verwendet.');
|
|
const result = db.prepare('DELETE FROM image_batch_profiles WHERE id = ?').run(Number(id));
|
|
return { ok: true, deleted: result.changes };
|
|
}
|
|
|
|
function mergeOperations(profileOperations = {}, overrides = {}) {
|
|
const operations = { ...profileOperations };
|
|
operations.resize = { ...(profileOperations.resize || {}) };
|
|
operations.watermark = { ...(profileOperations.watermark || {}) };
|
|
if (overrides.remove_background === true) {
|
|
operations.remove_background = true;
|
|
operations.background_sensitivity = Math.round(clamp(overrides.background_sensitivity, 0, 100, 45));
|
|
if (!overrides.format) operations.format = 'png';
|
|
}
|
|
if (overrides.watermark?.enabled) {
|
|
operations.watermark = {
|
|
enabled: true,
|
|
text: String(overrides.watermark.text || 'Vendoo').slice(0, 80),
|
|
position: String(overrides.watermark.position || 'bottom-right'),
|
|
opacity: Math.round(clamp(overrides.watermark.opacity, 10, 100, 40)),
|
|
};
|
|
}
|
|
if (overrides.format) operations.format = String(overrides.format);
|
|
if (Number.isFinite(Number(overrides.quality))) operations.quality = Math.round(clamp(overrides.quality, 40, 100, 90));
|
|
return operations;
|
|
}
|
|
|
|
function filenameFromPattern(pattern, sourcePath, profile, index) {
|
|
const sourceName = basename(sourcePath, extname(sourcePath));
|
|
return slug(String(pattern || '{name}-{profile}-{index}')
|
|
.replaceAll('{name}', slug(sourceName))
|
|
.replaceAll('{profile}', slug(profile.key || profile.name))
|
|
.replaceAll('{index}', String(index).padStart(2, '0')),
|
|
`vendoo-${index}`);
|
|
}
|
|
|
|
export function createImageBatchJob(data = {}) {
|
|
const sourcePaths = [...new Set((Array.isArray(data.source_paths) ? data.source_paths : []).map(normalizePath).filter(Boolean))].slice(0, 200);
|
|
const profileIds = [...new Set((Array.isArray(data.profile_ids) ? data.profile_ids : []).map(Number).filter(Number.isFinite))].slice(0, 20);
|
|
if (!sourcePaths.length) throw new Error('Mindestens ein Quellbild auswählen.');
|
|
if (!profileIds.length) throw new Error('Mindestens ein Produktionsprofil auswählen.');
|
|
for (const sourcePath of sourcePaths) resolveEditableImage(uploadRoot, sourcePath);
|
|
const placeholders = profileIds.map(() => '?').join(',');
|
|
const profiles = db.prepare(`SELECT * FROM image_batch_profiles WHERE id IN (${placeholders}) ORDER BY is_builtin DESC, name`).all(...profileIds).map(parseProfile);
|
|
if (profiles.length !== profileIds.length) throw new Error('Mindestens ein Produktionsprofil wurde nicht gefunden.');
|
|
|
|
const id = randomUUID();
|
|
const name = String(data.name || `Bildproduktion ${new Date().toLocaleDateString('de-DE')}`).trim().slice(0, 100);
|
|
const overrides = data.overrides && typeof data.overrides === 'object' ? data.overrides : {};
|
|
const settings = { source_paths: sourcePaths, profile_ids: profileIds, overrides };
|
|
const totalItems = sourcePaths.length * profiles.length;
|
|
const insertJob = db.prepare(`INSERT INTO image_batch_jobs
|
|
(id, name, source_count, profile_count, total_items, settings_json) VALUES (?, ?, ?, ?, ?, ?)`);
|
|
const insertItem = db.prepare(`INSERT INTO image_batch_job_items
|
|
(id, job_id, source_path, profile_id, profile_key, profile_name, settings_json) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
const transaction = db.transaction(() => {
|
|
insertJob.run(id, name, sourcePaths.length, profiles.length, totalItems, JSON.stringify(settings));
|
|
let itemIndex = 0;
|
|
for (const sourcePath of sourcePaths) {
|
|
for (const profile of profiles) {
|
|
itemIndex += 1;
|
|
const operations = mergeOperations(profile.operations, overrides);
|
|
const itemSettings = {
|
|
operations,
|
|
filename_pattern: profile.filename_pattern,
|
|
output_filename: filenameFromPattern(profile.filename_pattern, sourcePath, profile, itemIndex),
|
|
};
|
|
insertItem.run(randomUUID(), id, sourcePath, profile.id, profile.key, profile.name, JSON.stringify(itemSettings));
|
|
}
|
|
}
|
|
});
|
|
transaction();
|
|
scheduleLoop(20);
|
|
return getImageBatchJob(id);
|
|
}
|
|
|
|
export function getImageBatchJob(id) {
|
|
return parseJob(db.prepare('SELECT * FROM image_batch_jobs WHERE id = ?').get(String(id || '')), { includeItems: true });
|
|
}
|
|
|
|
export function paginateImageBatchJobs({ page = 1, pageSize = 10, status = '' } = {}) {
|
|
const safeSize = Math.max(5, Math.min(15, Number(pageSize) || 10));
|
|
const requestedPage = Math.max(1, Number(page) || 1);
|
|
const statuses = String(status || '').split(',').map(value => value.trim()).filter(Boolean);
|
|
const where = statuses.length ? `WHERE status IN (${statuses.map(() => '?').join(',')})` : '';
|
|
const total = Number(db.prepare(`SELECT COUNT(*) AS count FROM image_batch_jobs ${where}`).get(...statuses)?.count || 0);
|
|
const totalPages = Math.max(1, Math.ceil(total / safeSize));
|
|
const safePage = Math.min(requestedPage, totalPages);
|
|
const rows = db.prepare(`SELECT * FROM image_batch_jobs ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
|
.all(...statuses, safeSize, (safePage - 1) * safeSize).map(row => parseJob(row));
|
|
return { items: rows, pagination: { page: safePage, page_size: safeSize, total, total_pages: totalPages }, summary: getImageBatchSummary() };
|
|
}
|
|
|
|
export function getImageBatchSummary() {
|
|
const rows = db.prepare('SELECT status, COUNT(*) AS count FROM image_batch_jobs GROUP BY status').all();
|
|
const summary = { total: 0, queued: 0, running: 0, paused: 0, completed: 0, partial: 0, failed: 0, cancelled: 0 };
|
|
for (const row of rows) { summary[row.status] = Number(row.count || 0); summary.total += Number(row.count || 0); }
|
|
return summary;
|
|
}
|
|
|
|
function refreshJob(jobId) {
|
|
const counts = db.prepare(`SELECT
|
|
COUNT(*) AS total,
|
|
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
|
|
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
|
|
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled,
|
|
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running,
|
|
SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END) AS queued
|
|
FROM image_batch_job_items WHERE job_id = ?`).get(jobId);
|
|
const job = db.prepare('SELECT * FROM image_batch_jobs WHERE id = ?').get(jobId);
|
|
if (!job) return null;
|
|
let status = job.status;
|
|
let phase = job.phase;
|
|
let completedAt = job.completed_at;
|
|
if (Number(job.cancel_requested || 0) === 1 && Number(counts.running || 0) === 0) {
|
|
status = 'cancelled'; phase = 'cancelled'; completedAt = completedAt || new Date().toISOString();
|
|
} else if (Number(job.pause_requested || 0) === 1 && Number(counts.running || 0) === 0) {
|
|
status = 'paused'; phase = 'paused';
|
|
} else if (Number(counts.running || 0) > 0) {
|
|
status = 'running'; phase = 'processing';
|
|
} else if (Number(counts.queued || 0) > 0) {
|
|
status = 'queued'; phase = 'queued';
|
|
} else if (Number(counts.total || 0) > 0) {
|
|
const failed = Number(counts.failed || 0);
|
|
const completed = Number(counts.completed || 0);
|
|
const cancelled = Number(counts.cancelled || 0);
|
|
if (completed === Number(counts.total)) status = 'completed';
|
|
else if (completed > 0 && (failed > 0 || cancelled > 0)) status = 'partial';
|
|
else if (failed > 0) status = 'failed';
|
|
else status = 'cancelled';
|
|
phase = status; completedAt = completedAt || new Date().toISOString();
|
|
}
|
|
db.prepare(`UPDATE image_batch_jobs SET status = ?, phase = ?, completed_items = ?, failed_items = ?, cancelled_items = ?, completed_at = ?, updated_at = datetime('now') WHERE id = ?`)
|
|
.run(status, phase, Number(counts.completed || 0), Number(counts.failed || 0), Number(counts.cancelled || 0), completedAt, jobId);
|
|
return getImageBatchJob(jobId);
|
|
}
|
|
|
|
export function pauseImageBatchJob(id) {
|
|
const job = getImageBatchJob(id);
|
|
if (!job) throw new Error('Bildauftrag wurde nicht gefunden.');
|
|
if (TERMINAL_JOB.has(job.status)) return job;
|
|
db.prepare("UPDATE image_batch_jobs SET pause_requested = 1, status = CASE WHEN status = 'running' THEN status ELSE 'paused' END, phase = 'pausing', updated_at = datetime('now') WHERE id = ?").run(job.id);
|
|
return refreshJob(job.id);
|
|
}
|
|
|
|
export function resumeImageBatchJob(id) {
|
|
const job = getImageBatchJob(id);
|
|
if (!job) throw new Error('Bildauftrag wurde nicht gefunden.');
|
|
if (TERMINAL_JOB.has(job.status) && !['failed', 'partial'].includes(job.status)) return job;
|
|
db.prepare("UPDATE image_batch_jobs SET pause_requested = 0, cancel_requested = 0, status = 'queued', phase = 'queued', completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(job.id);
|
|
scheduleLoop(20);
|
|
return refreshJob(job.id);
|
|
}
|
|
|
|
export function cancelImageBatchJob(id) {
|
|
const job = getImageBatchJob(id);
|
|
if (!job) throw new Error('Bildauftrag wurde nicht gefunden.');
|
|
if (TERMINAL_JOB.has(job.status)) return job;
|
|
const tx = db.transaction(() => {
|
|
db.prepare("UPDATE image_batch_jobs SET cancel_requested = 1, pause_requested = 0, phase = 'cancelling', updated_at = datetime('now') WHERE id = ?").run(job.id);
|
|
db.prepare("UPDATE image_batch_job_items SET status = 'cancelled', phase = 'cancelled', completed_at = datetime('now'), updated_at = datetime('now') WHERE job_id = ? AND status = 'queued'").run(job.id);
|
|
});
|
|
tx();
|
|
return refreshJob(job.id);
|
|
}
|
|
|
|
export function retryImageBatchJob(id) {
|
|
const job = getImageBatchJob(id);
|
|
if (!job) throw new Error('Bildauftrag wurde nicht gefunden.');
|
|
const result = db.prepare("UPDATE image_batch_job_items SET status = 'queued', phase = 'queued', error_message = NULL, output_path = NULL, started_at = NULL, completed_at = NULL, updated_at = datetime('now') WHERE job_id = ? AND status IN ('failed','cancelled')").run(job.id);
|
|
if (!result.changes) throw new Error('Dieser Auftrag enthält keine wiederholbaren Positionen.');
|
|
db.prepare("UPDATE image_batch_jobs SET status = 'queued', phase = 'queued', pause_requested = 0, cancel_requested = 0, error_message = NULL, completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(job.id);
|
|
scheduleLoop(20);
|
|
return refreshJob(job.id);
|
|
}
|
|
|
|
export function retryImageBatchItem(jobId, itemId) {
|
|
const item = parseItem(db.prepare('SELECT * FROM image_batch_job_items WHERE id = ? AND job_id = ?').get(String(itemId), String(jobId)));
|
|
if (!item) throw new Error('Bildposition wurde nicht gefunden.');
|
|
if (!['failed', 'cancelled'].includes(item.status)) throw new Error('Nur fehlgeschlagene oder abgebrochene Positionen können wiederholt werden.');
|
|
db.prepare("UPDATE image_batch_job_items SET status = 'queued', phase = 'queued', error_message = NULL, output_path = NULL, started_at = NULL, completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(item.id);
|
|
db.prepare("UPDATE image_batch_jobs SET status = 'queued', phase = 'queued', pause_requested = 0, cancel_requested = 0, completed_at = NULL, updated_at = datetime('now') WHERE id = ?").run(jobId);
|
|
scheduleLoop(20);
|
|
return refreshJob(jobId);
|
|
}
|
|
|
|
export function deleteImageBatchJob(id) {
|
|
const job = getImageBatchJob(id);
|
|
if (!job) return { ok: true, deleted: 0 };
|
|
if (!TERMINAL_JOB.has(job.status)) throw new Error('Laufende Bildaufträge können nicht gelöscht werden.');
|
|
const result = db.prepare('DELETE FROM image_batch_jobs WHERE id = ?').run(job.id);
|
|
return { ok: true, deleted: result.changes };
|
|
}
|
|
|
|
export function getImageBatchOutputPaths(id) {
|
|
return db.prepare("SELECT output_path FROM image_batch_job_items WHERE job_id = ? AND status = 'completed' AND output_path IS NOT NULL ORDER BY created_at, id")
|
|
.all(String(id || '')).map(row => row.output_path).filter(Boolean);
|
|
}
|
|
|
|
async function processNextItem() {
|
|
if (processing) return;
|
|
const item = parseItem(db.prepare(`SELECT i.* FROM image_batch_job_items i
|
|
JOIN image_batch_jobs j ON j.id = i.job_id
|
|
WHERE i.status = 'queued' AND j.pause_requested = 0 AND j.cancel_requested = 0 AND j.status IN ('queued','running')
|
|
ORDER BY j.created_at ASC, i.created_at ASC LIMIT 1`).get());
|
|
if (!item) return;
|
|
processing = true;
|
|
try {
|
|
const job = db.prepare('SELECT * FROM image_batch_jobs WHERE id = ?').get(item.job_id);
|
|
db.prepare("UPDATE image_batch_jobs SET status = 'running', phase = 'processing', started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?").run(item.job_id);
|
|
db.prepare("UPDATE image_batch_job_items SET status = 'running', phase = 'rendering', started_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(item.id);
|
|
const outputDirectory = `batch/${String(item.job_id).slice(0, 8)}`;
|
|
const rootPath = getImageEditRoot(item.source_path) || item.source_path;
|
|
const result = await renderImageVersion({
|
|
uploadRoot,
|
|
sourcePath: item.source_path,
|
|
operations: item.settings.operations || {},
|
|
context: 'batch',
|
|
rootPath,
|
|
recordVersion: createImageEditVersion,
|
|
outputDirectory,
|
|
outputFilename: item.settings.output_filename,
|
|
});
|
|
db.prepare("UPDATE image_batch_job_items SET status = 'completed', phase = 'completed', output_path = ?, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?")
|
|
.run(result.filename, item.id);
|
|
} catch (error) {
|
|
db.prepare("UPDATE image_batch_job_items SET status = 'failed', phase = 'failed', error_message = ?, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?")
|
|
.run(String(error?.message || error || 'Bild konnte nicht verarbeitet werden.').slice(0, 1000), item.id);
|
|
} finally {
|
|
refreshJob(item.job_id);
|
|
processing = false;
|
|
scheduleLoop(20);
|
|
}
|
|
}
|
|
|
|
function scheduleLoop(delay = 500) {
|
|
if (loopTimer) clearTimeout(loopTimer);
|
|
loopTimer = setTimeout(async () => {
|
|
loopTimer = null;
|
|
await processNextItem();
|
|
scheduleLoop(500);
|
|
}, delay);
|
|
loopTimer.unref?.();
|
|
}
|
|
|
|
export function stopImageBatchJobLoop() {
|
|
if (loopTimer) clearTimeout(loopTimer);
|
|
loopTimer = null;
|
|
}
|
|
|
|
export function startImageBatchJobLoop() {
|
|
db.prepare("UPDATE image_batch_job_items SET status = 'queued', phase = 'queued', started_at = NULL, updated_at = datetime('now') WHERE status = 'running'").run();
|
|
db.prepare("UPDATE image_batch_job_items SET status = 'cancelled', phase = 'cancelled', completed_at = COALESCE(completed_at, datetime('now')), updated_at = datetime('now') WHERE status = 'queued' AND job_id IN (SELECT id FROM image_batch_jobs WHERE cancel_requested = 1)").run();
|
|
db.prepare("UPDATE image_batch_jobs SET status = CASE WHEN cancel_requested = 1 THEN 'cancelled' WHEN pause_requested = 1 THEN 'paused' ELSE 'queued' END, phase = CASE WHEN cancel_requested = 1 THEN 'cancelled' WHEN pause_requested = 1 THEN 'paused' ELSE 'queued' END, updated_at = datetime('now') WHERE status = 'running'").run();
|
|
for (const row of db.prepare("SELECT id FROM image_batch_jobs WHERE status IN ('queued','running','paused','cancelled')").all()) refreshJob(row.id);
|
|
scheduleLoop(200);
|
|
}
|