diff --git a/public/groups.html b/public/groups.html index 7ae0d26..7316216 100644 --- a/public/groups.html +++ b/public/groups.html @@ -111,17 +111,21 @@
-
+
+
- Последние — только главное обработанное фото каждой записи. Все — главное фото плюс вся галерея записи. + Главное фото — текущая версия снимка записи. Галерея — все фото записи. Оригиналы — версии до обработки, они попадают в подпапку «Оригиналы», если обработка уже применялась.
diff --git a/public/js/groups.js b/public/js/groups.js index 9248272..e45d23e 100644 --- a/public/js/groups.js +++ b/public/js/groups.js @@ -306,8 +306,9 @@ function openGroupFilesModal(groupId, groupName) { document.getElementById('groupFilesTo').value = todayISOLocal(); document.getElementById('groupFilesIncludeWorks').checked = true; document.getElementById('groupFilesIncludePhotos').checked = false; - const latest = document.querySelector('input[name="groupFilesMode"][value="latest"]'); - if (latest) latest.checked = true; + document.getElementById('groupFilesMainPhoto').checked = true; + document.getElementById('groupFilesGallery').checked = false; + document.getElementById('groupFilesOriginals').checked = false; onGroupFilesPhotosToggle(); document.getElementById('groupFilesModal').classList.add('open'); } @@ -321,14 +322,27 @@ function todayISOLocal() { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } +const GROUP_FILES_PHOTO_BOXES = ['groupFilesMainPhoto', 'groupFilesGallery', 'groupFilesOriginals']; + function onGroupFilesPhotosToggle() { const on = document.getElementById('groupFilesIncludePhotos').checked; - document.querySelectorAll('input[name="groupFilesMode"]').forEach(el => { el.disabled = !on; }); + GROUP_FILES_PHOTO_BOXES.forEach(id => { + const el = document.getElementById(id); + if (el) el.disabled = !on; + }); } -function groupFilesMode() { - const checked = document.querySelector('input[name="groupFilesMode"]:checked'); - return checked && checked.value === 'all' ? 'all' : 'latest'; +function groupFilesPhotosOptions() { + const main = document.getElementById('groupFilesMainPhoto').checked; + const gallery = document.getElementById('groupFilesGallery').checked; + const originals = document.getElementById('groupFilesOriginals').checked; + return { + main, + gallery, + originals, + mode: gallery ? 'all' : 'latest', + any: main || gallery || originals, + }; } async function downloadGroupFiles() { @@ -337,8 +351,10 @@ async function downloadGroupFiles() { const dateTo = document.getElementById('groupFilesTo').value; const includeWorks = document.getElementById('groupFilesIncludeWorks').checked; const includePhotos = document.getElementById('groupFilesIncludePhotos').checked; + const photoOpts = groupFilesPhotosOptions(); if (!groupId) { alert('Выберите группу'); return; } if (!includeWorks && !includePhotos) { alert('Выберите хотя бы одну категорию: работы или фото записи'); return; } + if (includePhotos && !photoOpts.any) { alert('Выберите, какие фото выгружать: главное фото, галерея или оригиналы'); return; } if (dateFrom && dateTo && dateFrom > dateTo) { alert('Дата «С» позже даты «По»'); return; } const btn = document.getElementById('downloadGroupFilesBtn'); btn.disabled = true; @@ -349,7 +365,9 @@ async function downloadGroupFiles() { if (dateTo) q.set('date_to', dateTo); q.set('include_files', includeWorks ? '1' : '0'); q.set('include_photos', includePhotos ? '1' : '0'); - q.set('photos_mode', groupFilesMode()); + q.set('include_main', photoOpts.main ? '1' : '0'); + q.set('photos_mode', photoOpts.mode); + q.set('include_originals', photoOpts.originals ? '1' : '0'); const res = await fetch(`${API}/api/groups/${groupId}/export/files?${q}`, { headers: hdr() }); if (!res.ok) { let msg = 'Не удалось сформировать архив'; diff --git a/server.js b/server.js index f0779a3..e30217e 100644 --- a/server.js +++ b/server.js @@ -2061,6 +2061,12 @@ function optPhotoRefPath(v, max) { return reqPhotoRefPath(v, max); } +function photoRefKey(p) { + if (typeof p !== 'string') return null; + if (isSafeUploadPath(p) || ORIGINALS_PATH_RE.test(p)) return p.slice('/uploads/'.length); + return null; +} + const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']); function optAiText(v, max) { @@ -4142,6 +4148,8 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => { const includeFiles = req.query.include_files !== '0'; const includePhotos = req.query.include_photos !== '0'; const photosMode = req.query.photos_mode === 'all' ? 'all' : 'latest'; + const includeMain = includePhotos && req.query.include_main !== '0'; + const includeOriginals = includePhotos && req.query.include_originals === '1'; if (!includeFiles && !includePhotos) { return res.status(400).json({ error: 'Выберите хотя бы одну категорию: работы или фото записи' }); } @@ -4171,7 +4179,7 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => { conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`); } const condStr = conds.join(' AND '); - const [studentsRes, filesRes, photosRes, entryPhotosRes] = await Promise.all([ + const [studentsRes, filesRes, photosRes, entryPhotosRes, originalsRes] = await Promise.all([ pool.query('SELECT id, name FROM students WHERE group_id = $1 ORDER BY name', [gid]), includeFiles ? pool.query( @@ -4183,7 +4191,7 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => { params ) : null, - includePhotos + includePhotos && includeMain ? pool.query( `SELECT e.photo_path, e.student_name, e.created_at FROM entries e @@ -4202,10 +4210,20 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => { params ) : null, + includeOriginals + ? pool.query( + `SELECT e.photo_original_path, e.student_name, e.created_at + FROM entries e + WHERE ${condStr} AND e.photo_original_path IS NOT NULL + ORDER BY e.student_name, e.created_at DESC`, + params + ) + : null, ]); const fileRows = filesRes ? filesRes.rows : []; const photoRows = photosRes ? photosRes.rows : []; const entryPhotoRows = entryPhotosRes ? entryPhotosRes.rows : []; + const originalRows = originalsRes ? originalsRes.rows : []; const studentNames = new Set(studentsRes.rows.map(s => s.name)); const zip = createZipWriter(); const folderOf = name => String(name || '').replace(/[\\/:*?"<>|]/g, '_').replace(/^[\s.]+/, '').slice(0, 80) || 'Без имени'; @@ -4233,8 +4251,8 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => { const seenFiles = new Map(); const seenPaths = new Set(); async function addEntryFile(row, subDir) { - if (!isSafeUploadPath(row.path)) return false; - const stored = row.path.slice('/uploads/'.length); + const stored = photoRefKey(row.path); + if (!stored) return false; if (seenPaths.has(stored)) return false; const data = await storage.getBuffer(stored); if (!data) return false; @@ -4262,6 +4280,7 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => { } let worksAdded = 0; let photosAdded = 0; + let originalsAdded = 0; for (const f of fileRows) if (await addEntryFile(f, null)) worksAdded++; for (const p of photoRows) { if (await addEntryFile({ name: `photo_${new Date(p.created_at || Date.now()).toISOString().slice(0, 10)}.jpg`, path: p.photo_path, student_name: p.student_name, created_at: p.created_at }, 'Фото записи')) photosAdded++; @@ -4270,12 +4289,15 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => { const storedName = String(p.photo_path).split('/').pop(); if (await addEntryFile({ name: storedName || 'photo.jpg', path: p.photo_path, student_name: p.student_name, created_at: p.created_at }, 'Фото записи')) photosAdded++; } + for (const p of originalRows) { + if (await addEntryFile({ name: `original_${new Date(p.created_at || Date.now()).toISOString().slice(0, 10)}.jpg`, path: p.photo_original_path, student_name: p.student_name, created_at: p.created_at }, 'Фото записи/Оригиналы')) originalsAdded++; + } const meta = { exported_at: new Date().toISOString(), group: { id: gid, name: groupName }, period: { date_from: dateFrom, date_to: dateTo }, - options: { files: includeFiles, photos: includePhotos, photos_mode: photosMode }, - totals: { students: studentNames.size, files: filesBuilt.length, works: worksAdded, photos: photosAdded }, + options: { files: includeFiles, photos: includePhotos, main_photo: includeMain, photos_mode: photosMode, originals: includeOriginals }, + totals: { students: studentNames.size, files: filesBuilt.length, works: worksAdded, photos: photosAdded, originals: originalsAdded }, students: [...studentNames].sort(), files: filesBuilt, }; @@ -4288,6 +4310,7 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => { files: filesBuilt.length, works: worksAdded, photos: photosAdded, + originals: originalsAdded, photos_mode: photosMode, date_from: dateFrom, date_to: dateTo,