diff --git a/db/init.sql b/db/init.sql index eb596c8..03bbda5 100644 --- a/db/init.sql +++ b/db/init.sql @@ -187,6 +187,7 @@ CREATE TABLE IF NOT EXISTS photo_jobs ( before_path VARCHAR(255), after_path VARCHAR(255), status VARCHAR(20) NOT NULL DEFAULT 'pending', + applied BOOLEAN NOT NULL DEFAULT false, attempts INT NOT NULL DEFAULT 0, error TEXT, created_at TIMESTAMPTZ DEFAULT now(), diff --git a/db/migration.sql b/db/migration.sql index 3c53fa3..6806479 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -208,6 +208,7 @@ CREATE TABLE IF NOT EXISTS photo_jobs ( before_path VARCHAR(255), after_path VARCHAR(255), status VARCHAR(20) NOT NULL DEFAULT 'pending', + applied BOOLEAN NOT NULL DEFAULT false, attempts INT NOT NULL DEFAULT 0, error TEXT, created_at TIMESTAMPTZ DEFAULT now(), @@ -221,3 +222,4 @@ INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') ON CONFLICT (key) DO NOTHING; ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255); +ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false; diff --git a/public/js/journal.js b/public/js/journal.js index 8b3a5d8..07f1e96 100644 --- a/public/js/journal.js +++ b/public/js/journal.js @@ -427,6 +427,7 @@ let enhanceEngineLoaded = false; let photoAiEnabled = false; let photoAiPollTimer = null; let enhanceAiResultPath = null; +let enhanceAiJobId = null; let enhanceClipPos = 50; function setEnhanceClip(p) { @@ -554,6 +555,7 @@ async function runPhotoAi() { throw new Error(err.error || 'Ошибка запуска ИИ'); } const { jobId } = await res.json(); + enhanceAiJobId = jobId; showEnhanceResult(false); setEnhanceLoadingText('ИИ обрабатывает фото... (может занять до минуты)'); btn.textContent = '⏳ ИИ...'; @@ -627,6 +629,7 @@ function openEnhance(id) { enhanceEntryId = id; enhanceSourceImg = null; enhanceAiResultPath = null; + enhanceAiJobId = null; ['Brightness', 'Contrast', 'Saturate'].forEach(k => { const el = document.getElementById('enhance' + k); el.value = 100; @@ -658,16 +661,40 @@ function openEnhance(id) { document.getElementById('enhanceModal').classList.add('open'); } -function cleanupAiPreview() { - if (!enhanceAiResultPath || !enhanceEntryId) return; - const toClean = enhanceAiResultPath; - const entryId = enhanceEntryId; - fetch(`${API}/api/entries/${entryId}/photo/enhance-ai/preview`, { - method: 'DELETE', - headers: hdrJson(), - body: JSON.stringify({ path: toClean }), - }).catch(() => {}); +function isDefaultEnhanceParams() { + const p = enhanceParams(); + return p.brightness === 100 && p.contrast === 100 && p.saturate === 100 && p.denoise === 0 && p.sharp === 0; +} + +async function applyAiResult() { + if (!enhanceAiJobId || !enhanceEntryId) return false; + const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${enhanceAiJobId}/apply`, { + method: 'POST', + headers: hdr(), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Ошибка применения ИИ-результата'); enhanceAiResultPath = null; + enhanceAiJobId = null; + return true; +} + +function cleanupAiPreview() { + if (!enhanceEntryId) return; + if (enhanceAiJobId) { + fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${enhanceAiJobId}/reject`, { + method: 'POST', + headers: hdr(), + }).catch(() => {}); + } else if (enhanceAiResultPath) { + fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai/preview`, { + method: 'DELETE', + headers: hdrJson(), + body: JSON.stringify({ path: enhanceAiResultPath }), + }).catch(() => {}); + } + enhanceAiResultPath = null; + enhanceAiJobId = null; } function closeEnhance() { @@ -686,7 +713,7 @@ function photoJobThumb(p) { } const PHOTO_JOB_ACTION_LABELS = { ai: 'ИИ-обработка', enhance: 'Улучшение', restore: 'Возврат оригинала', rollback: 'Откат' }; -const PHOTO_JOB_STATUS_LABELS = { pending: 'В очереди', processing: 'Обрабатывается', done: 'Готово', error: 'Ошибка' }; +const PHOTO_JOB_STATUS_LABELS = { pending: 'В очереди', processing: 'Обрабатывается', done: 'Готово', error: 'Ошибка', rejected: 'Отклонено' }; async function loadPhotoHistory() { if (!enhanceEntryId) return; @@ -703,7 +730,8 @@ async function loadPhotoHistory() { list.innerHTML = rows.map(r => { const before = r.before_path ? `было` : '—'; const after = r.after_path ? `стало` : '—'; - const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback'; + const canApply = r.status === 'done' && r.applied === false && r.action === 'ai' && !!r.after_path; + const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback' && !canApply; const err = r.error ? `
${esc(r.error)}
` : ''; const time = r.finished_at || r.created_at; return `
@@ -723,6 +751,7 @@ async function loadPhotoHistory() { ${err}
${canRollback ? `` : ''} + ${canApply ? `` : ''} `; }).join(''); } catch (e) { @@ -944,7 +973,11 @@ async function saveEnhance() { try { let done = false; let engineLabel = 'клиент'; - if (enhanceEngine !== 'client' && !enhanceAiResultPath) { + if (enhanceAiResultPath && isDefaultEnhanceParams()) { + await applyAiResult(); + engineLabel = 'ИИ'; + done = true; + } else if (enhanceEngine !== 'client' && !enhanceAiResultPath) { try { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 8000); @@ -1351,9 +1384,34 @@ document.getElementById('photoHistoryModal').addEventListener('click', e => { }); document.getElementById('photoHistoryList').addEventListener('click', e => { const btn = e.target.closest('[data-job-rollback]'); - if (!btn) return; - rollbackPhotoJob(parseInt(btn.dataset.jobRollback, 10)); + if (btn) { + rollbackPhotoJob(parseInt(btn.dataset.jobRollback, 10)); + return; + } + const applyBtn = e.target.closest('[data-job-apply]'); + if (applyBtn) applyPhotoJob(parseInt(applyBtn.dataset.jobApply, 10)); }); + +async function applyPhotoJob(jobId) { + if (!enhanceEntryId || enhanceSaving) return; + if (!confirm('Применить обработанную фотографию к записи? Текущая версия сохранится в истории.')) return; + enhanceSaving = true; + try { + const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${jobId}/apply`, { method: 'POST', headers: hdr() }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Ошибка применения'); + showToast('Фотография применена'); + const mp = data.photo_path; + const origImg = document.getElementById('enhanceOrig'); + if (origImg && mp) origImg.src = `${API}${mp}`; + loadPhotoHistory(); + refreshEntryCard(enhanceEntryId); + } catch (e) { + alert(e.message); + } finally { + enhanceSaving = false; + } +} document.getElementById('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); }); document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview); document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview); diff --git a/public/js/worker.js b/public/js/worker.js index 9717875..7b84283 100644 --- a/public/js/worker.js +++ b/public/js/worker.js @@ -35,6 +35,7 @@ const PHOTO_STATUS_LABELS = { processing: 'Обрабатывается', done: 'Готово', error: 'Ошибка', + rejected: 'Отклонено', }; const PHOTO_STATUS_BADGE = { done: 'ai-badge-done', diff --git a/server.js b/server.js index 1c6733c..02b03d8 100644 --- a/server.js +++ b/server.js @@ -615,14 +615,15 @@ async function removeEntryFiles(entryId) { async function sweepOrphanedUploads() { const dir = path.join(__dirname, 'uploads'); if (!fs.existsSync(dir)) return; - const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }] = await Promise.all([ + const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }] = await Promise.all([ pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'), pool.query('SELECT path AS p FROM project_files'), pool.query('SELECT photo_path AS p FROM group_photos'), pool.query('SELECT photo_path AS p FROM entry_photos'), + pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`), ]); const refs = new Set(); - [...photos, ...files, ...gphotos, ...ephotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, ''))); + [...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, ''))); for (const f of fs.readdirSync(dir)) { const fp = path.join(dir, f); if (!fs.statSync(fp).isFile()) continue; @@ -719,11 +720,13 @@ async function ensurePhotoJobsTable() { before_path VARCHAR(255), after_path VARCHAR(255), status VARCHAR(20) NOT NULL DEFAULT 'pending', + applied BOOLEAN NOT NULL DEFAULT false, attempts INT NOT NULL DEFAULT 0, error TEXT, created_at TIMESTAMPTZ DEFAULT now(), finished_at TIMESTAMPTZ )`); + await pool.query(`ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending'`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id)`); await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') ON CONFLICT (key) DO NOTHING`); @@ -3826,17 +3829,115 @@ app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res const jobId = parseInt(req.params.jobId, 10); if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' }); const { rows } = await pool.query( - 'SELECT status, error, after_path FROM photo_jobs WHERE id = $1 AND entry_id = $2', + 'SELECT id, status, error, after_path, applied FROM photo_jobs WHERE id = $1 AND entry_id = $2', [jobId, req.params.id] ); if (!rows.length) return res.status(404).json({ error: 'Задание не найдено' }); const job = rows[0]; - const out = { status: job.status }; + const out = { status: job.status, applied: job.applied, job_id: job.id }; if (job.status === 'error') out.error = job.error; if (job.status === 'done') out.photo_path = job.after_path; res.json(out); }); +app.post('/api/entries/:id/photo/jobs/:jobId/apply', requireAuth, async (req, res) => { + if (req.user.role !== 'admin') { + const acc = await entryAccessible(req.user, req.params.id); + if (!acc.found) return res.status(404).json({ error: 'Not found' }); + if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); + } + const client = await pool.connect(); + try { + const jobId = parseInt(req.params.jobId, 10); + if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' }); + await client.query('BEGIN'); + const { rows: jobRows } = await client.query( + `SELECT id, entry_id, action, status, applied, after_path FROM photo_jobs + WHERE id = $1 AND entry_id = $2 FOR UPDATE`, + [jobId, req.params.id] + ); + const job = jobRows[0]; + if (!job) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Задание не найдено' }); } + if (job.status !== 'done') { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Результат ещё не готов' }); } + if (job.applied) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Результат уже применён' }); } + if (!job.after_path || !isSafeUploadPath(job.after_path)) { + await client.query('ROLLBACK'); + return res.status(400).json({ error: 'Некорректный путь результата' }); + } + const afterAbs = path.join(UPLOADS_DIR, String(job.after_path).replace(/^\/+/, '').replace(/^uploads\//, '')); + if (!fs.existsSync(afterAbs)) { + await client.query('ROLLBACK'); + return res.status(400).json({ error: 'Файл результата не найден' }); + } + const { rows: entryRows } = await client.query('SELECT photo_path, photo_original_path FROM entries WHERE id = $1', [req.params.id]); + if (!entryRows.length) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Запись не найдена' }); } + const oldPath = entryRows[0].photo_path; + if (oldPath === job.after_path) { + await client.query(`UPDATE photo_jobs SET applied = true WHERE id = $1`, [jobId]); + await client.query('COMMIT'); + return res.json({ ok: true, photo_path: job.after_path }); + } + let originalPath = entryRows[0].photo_original_path; + let beforePath = originalPath; + const oldAbs = path.join(UPLOADS_DIR, String(oldPath || '').replace(/^\/+/, '').replace(/^uploads\//, '')); + if (oldPath && fs.existsSync(oldAbs)) { + try { + const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg'); + fs.renameSync(oldAbs, path.join(ORIGINALS_DIR, backupName)); + beforePath = `/uploads/.originals/${backupName}`; + if (!originalPath) originalPath = beforePath; + } catch (e) { + console.error('photo apply backup failed:', e); + } + } + await client.query('UPDATE entries SET photo_path = $1, photo_original_path = $2 WHERE id = $3', [job.after_path, originalPath, req.params.id]); + await client.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [job.after_path, req.params.id, oldPath]); + const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath || '').replace(/\.[^.]+$/, '') + '.webp'); + safeUnlink(oldThumb); + await client.query(`UPDATE photo_jobs SET applied = true, before_path = COALESCE(before_path, $1) WHERE id = $2`, [beforePath, jobId]); + await client.query('COMMIT'); + await logAudit(req, 'entry.photo.apply', { entry_id: req.params.id, job_id: jobId, before_path: beforePath, after_path: job.after_path }); + invalidateEntries(); + res.json({ ok: true, photo_path: job.after_path }); + } catch (e) { + await client.query('ROLLBACK').catch(() => {}); + console.error('POST /api/entries/:id/photo/jobs/:jobId/apply:', e); + res.status(500).json({ error: 'Ошибка применения фотографии' }); + } finally { + client.release(); + } +}); + +app.post('/api/entries/:id/photo/jobs/:jobId/reject', requireAuth, async (req, res) => { + if (req.user.role !== 'admin') { + const acc = await entryAccessible(req.user, req.params.id); + if (!acc.found) return res.status(404).json({ error: 'Not found' }); + if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); + } + try { + const jobId = parseInt(req.params.jobId, 10); + if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' }); + const { rows } = await pool.query( + `SELECT id, status, applied, after_path FROM photo_jobs WHERE id = $1 AND entry_id = $2`, + [jobId, req.params.id] + ); + const job = rows[0]; + if (!job) return res.status(404).json({ error: 'Задание не найдено' }); + if (job.applied) return res.status(400).json({ error: 'Результат уже применён' }); + if (job.status === 'done' && job.after_path && isSafeUploadPath(job.after_path)) safeUnlink(job.after_path); + await pool.query( + `UPDATE photo_jobs SET status = 'rejected', error = 'Отклонено пользователем', finished_at = now() + WHERE id = $1 AND applied = false`, + [jobId] + ); + await logAudit(req, 'entry.photo.reject', { entry_id: req.params.id, job_id: jobId }); + res.json({ ok: true }); + } catch (e) { + console.error('POST /api/entries/:id/photo/jobs/:jobId/reject:', e); + res.status(500).json({ error: 'Ошибка отклонения результата' }); + } +}); + app.get('/api/entries/:id/photo/jobs', requireAuth, async (req, res) => { if (req.user.role !== 'admin') { const acc = await entryAccessible(req.user, req.params.id); @@ -3844,7 +3945,7 @@ app.get('/api/entries/:id/photo/jobs', requireAuth, async (req, res) => { if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); } const { rows } = await pool.query( - `SELECT id, action, status, params, before_path, after_path, error, created_at, finished_at + `SELECT id, action, status, applied, params, before_path, after_path, error, created_at, finished_at FROM photo_jobs WHERE entry_id = $1 ORDER BY id DESC LIMIT 50`, [req.params.id] ); @@ -3912,6 +4013,11 @@ app.delete('/api/entries/:id/photo/enhance-ai/preview', requireAuth, async (req, const { path: p } = req.body || {}; if (!isSafeUploadPath(p)) return res.status(400).json({ error: 'Некорректный путь' }); safeUnlink(p); + await pool.query( + `UPDATE photo_jobs SET status = 'rejected', error = 'Отклонено пользователем', finished_at = now() + WHERE entry_id = $1 AND after_path = $2 AND status = 'done' AND applied = false`, + [req.params.id, p] + ); res.json({ ok: true }); }); diff --git a/worker.js b/worker.js index 291e254..760f087 100644 --- a/worker.js +++ b/worker.js @@ -138,18 +138,12 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie fs.writeFileSync(path.join(uploadsDir, newName), out); return `/uploads/${newName}`; } - async function applyResult(job, photoPath, newPath) { - const beforePath = backupOldFile(photoPath); - await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [newPath, job.entry_id]); - await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, job.entry_id, photoPath]); - const oldThumb = path.join(uploadsDir, '.thumbs', path.basename(photoPath).replace(/\.[^.]+$/, '') + '.webp'); - safeUnlinkPath(oldThumb); + async function applyResult(job, newPath) { await pool.query( - `UPDATE photo_jobs SET status = 'done', before_path = $1, after_path = $2, error = NULL, finished_at = now() WHERE id = $3`, - [beforePath, newPath, job.id] + `UPDATE photo_jobs SET status = 'done', after_path = $1, error = NULL, finished_at = now() WHERE id = $2`, + [newPath, job.id] ); - if (logAudit) await logAudit(null, 'photo.job.done', { entry_id: job.entry_id, job_id: job.id, action: job.action, before_path: beforePath, after_path: newPath }); - if (invalidateEntries) invalidateEntries(); + if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath }); } async function processOne(job) { stats.jobs++; @@ -168,7 +162,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie const srcAbs = path.join(uploadsDir, String(photoPath).replace(/^\/+/, '').replace(/^uploads\//, '')); try { const newPath = job.action === 'ai' ? await runAiEnhance(srcAbs) : await enhanceWithSharp(srcAbs, job.params); - await applyResult(job, photoPath, newPath); + await applyResult(job, newPath); stats.done++; stats.last_at = new Date().toISOString(); stats.last_error = null;