## Features Implemented ### 1. Event Rename Functionality - Add EventRenameDialog component with live slug preview - Create eventRenameService for safe event renaming - Add slug_redirects table for old URL redirects - Support optional email notification on rename - Fix date formatting in slug (YYYY-MM-DD format) ### 2. Optional Event Contact Fields - Add settings to make customer name/email/admin email optional - Create migration for field requirement settings - Update CreateEventPage forms to show "(optional)" labels - Fix boolean parsing in publicSettings.js ### 3. Photo Filtering & Export - Add PhotoFilterPanel with rating/likes/favorites/comments filters - Create PhotoExportMenu with ZIP/metadata/XMP export options - Add photoExportService with Lightroom XMP sidecar generation - Create photoFilterBuilder utility for query construction - Wire up photo selection to export button via onSelectionChange ### 4. Custom CSS Gallery Templates - Add CssTemplateEditor component with 3 template slots - Create cssSanitizer utility blocking XSS vectors - Add gallery CSS endpoint for template delivery - Integrate Custom CSS tab into Settings page - Include default "Elegant Dark" template ## Bug Fixes - Fix event rename date formatting (was showing full Date string) - Fix common.optional translation key missing in locales - Fix photo export button staying disabled when photos selected - Fix authService import missing in SettingsPage ## Documentation - Add comprehensive REFACTORING_PLAN.md for codebase improvement - Add test specification documents for all features - Add feature documentation for CSS templates ## Database Migrations - 049_add_slug_redirects.js - 050_add_optional_event_fields_settings.js - 051_add_photo_filter_indexes.js - 052_add_css_templates.js
252 lines
7.0 KiB
JavaScript
252 lines
7.0 KiB
JavaScript
/**
|
|
* Photo Export Service
|
|
* Handles exporting filtered photos in various formats
|
|
*/
|
|
|
|
const archiver = require('archiver');
|
|
const { PassThrough } = require('stream');
|
|
const { XmpGenerator } = require('./xmpGenerator');
|
|
const { db } = require('../database/db');
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
|
|
class PhotoExportService {
|
|
constructor() {
|
|
this.xmpGenerator = new XmpGenerator();
|
|
}
|
|
|
|
/**
|
|
* Get photos with full feedback data
|
|
* @param {number} eventId - Event ID
|
|
* @param {number[]} photoIds - Photo IDs to export (optional, exports all if not provided)
|
|
* @returns {Promise<Object[]>} Photos with feedback
|
|
*/
|
|
async getPhotosWithFeedback(eventId, photoIds = null) {
|
|
let query = db('photos')
|
|
.leftJoin('categories', 'photos.category_id', 'categories.id')
|
|
.where('photos.event_id', eventId)
|
|
.select(
|
|
'photos.id',
|
|
'photos.filename',
|
|
'photos.original_filename',
|
|
'photos.file_path',
|
|
'photos.average_rating',
|
|
'photos.feedback_count',
|
|
'photos.like_count',
|
|
'photos.favorite_count',
|
|
'photos.comment_count',
|
|
'photos.width',
|
|
'photos.height',
|
|
'photos.file_size',
|
|
'photos.created_at',
|
|
'categories.name as category_name'
|
|
)
|
|
.orderBy('photos.filename', 'asc');
|
|
|
|
if (photoIds && photoIds.length > 0) {
|
|
query = query.whereIn('photos.id', photoIds);
|
|
}
|
|
|
|
return await query;
|
|
}
|
|
|
|
/**
|
|
* Export photos in the specified format
|
|
* @param {number} eventId - Event ID
|
|
* @param {number[]} photoIds - Photo IDs to export
|
|
* @param {string} format - Export format (txt, csv, xmp, photos, json)
|
|
* @param {Object} options - Export options
|
|
* @returns {Promise<Object>} Export result with stream/content
|
|
*/
|
|
async exportPhotos(eventId, photoIds, format, options = {}) {
|
|
const photos = await this.getPhotosWithFeedback(eventId, photoIds);
|
|
|
|
if (photos.length === 0) {
|
|
throw new Error('No photos to export');
|
|
}
|
|
|
|
switch (format) {
|
|
case 'txt':
|
|
return this.exportAsTxt(photos, options);
|
|
case 'csv':
|
|
return this.exportAsCsv(photos, options);
|
|
case 'xmp':
|
|
return this.exportAsXmpZip(photos, options);
|
|
case 'json':
|
|
return this.exportAsJson(photos, eventId, options);
|
|
default:
|
|
throw new Error(`Unknown export format: ${format}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export as plain text filename list
|
|
*/
|
|
exportAsTxt(photos, options = {}) {
|
|
const { filename_format = 'original', separator = 'newline' } = options;
|
|
|
|
const filenames = photos.map(photo =>
|
|
filename_format === 'original' ? photo.original_filename : photo.filename
|
|
);
|
|
|
|
let content;
|
|
switch (separator) {
|
|
case 'comma':
|
|
content = filenames.join(', ');
|
|
break;
|
|
case 'semicolon':
|
|
content = filenames.join('; ');
|
|
break;
|
|
default:
|
|
content = filenames.join('\n');
|
|
}
|
|
|
|
return {
|
|
type: 'text',
|
|
content,
|
|
filename: `photo_list_${Date.now()}.txt`,
|
|
contentType: 'text/plain'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Export as CSV with metadata
|
|
*/
|
|
exportAsCsv(photos, options = {}) {
|
|
const { filename_format = 'original' } = options;
|
|
|
|
const headers = [
|
|
'filename',
|
|
'original_filename',
|
|
'rating',
|
|
'rating_count',
|
|
'likes',
|
|
'favorites',
|
|
'comments',
|
|
'category',
|
|
'width',
|
|
'height',
|
|
'file_size',
|
|
'created_at'
|
|
];
|
|
|
|
const rows = photos.map(photo => [
|
|
filename_format === 'original' ? photo.original_filename : photo.filename,
|
|
photo.original_filename || '',
|
|
photo.average_rating ? photo.average_rating.toFixed(2) : '0.00',
|
|
photo.feedback_count || 0,
|
|
photo.like_count || 0,
|
|
photo.favorite_count || 0,
|
|
photo.comment_count || 0,
|
|
photo.category_name || '',
|
|
photo.width || '',
|
|
photo.height || '',
|
|
photo.file_size || '',
|
|
photo.created_at ? new Date(photo.created_at).toISOString() : ''
|
|
]);
|
|
|
|
const csvContent = [
|
|
headers.join(','),
|
|
...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
|
].join('\n');
|
|
|
|
return {
|
|
type: 'text',
|
|
content: csvContent,
|
|
filename: `photo_export_${Date.now()}.csv`,
|
|
contentType: 'text/csv'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Export as XMP sidecar files in a ZIP archive
|
|
*/
|
|
async exportAsXmpZip(photos, options = {}) {
|
|
const { filename_format = 'original' } = options;
|
|
|
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
const passthrough = new PassThrough();
|
|
archive.pipe(passthrough);
|
|
|
|
for (const photo of photos) {
|
|
const baseFilename = filename_format === 'original'
|
|
? photo.original_filename
|
|
: photo.filename;
|
|
const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename);
|
|
const xmpContent = this.xmpGenerator.generateXmp(photo, options);
|
|
|
|
archive.append(xmpContent, { name: xmpFilename });
|
|
}
|
|
|
|
archive.finalize();
|
|
|
|
return {
|
|
type: 'stream',
|
|
stream: passthrough,
|
|
filename: `xmp_export_${Date.now()}.zip`,
|
|
contentType: 'application/zip'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Export as JSON metadata
|
|
*/
|
|
async exportAsJson(photos, eventId, options = {}) {
|
|
// Get event info
|
|
const event = await db('events')
|
|
.where('id', eventId)
|
|
.select('event_name', 'event_date', 'slug')
|
|
.first();
|
|
|
|
const exportData = {
|
|
export_info: {
|
|
event_name: event?.event_name || 'Unknown Event',
|
|
event_date: event?.event_date,
|
|
event_slug: event?.slug,
|
|
exported_at: new Date().toISOString(),
|
|
total_photos: photos.length
|
|
},
|
|
photos: photos.map(photo => ({
|
|
id: photo.id,
|
|
filename: photo.filename,
|
|
original_filename: photo.original_filename,
|
|
category: photo.category_name || null,
|
|
rating: {
|
|
average: photo.average_rating ? parseFloat(photo.average_rating.toFixed(2)) : 0,
|
|
count: photo.feedback_count || 0
|
|
},
|
|
likes: photo.like_count || 0,
|
|
favorites: photo.favorite_count || 0,
|
|
comments: photo.comment_count || 0,
|
|
dimensions: {
|
|
width: photo.width,
|
|
height: photo.height
|
|
},
|
|
file_size: photo.file_size,
|
|
created_at: photo.created_at
|
|
}))
|
|
};
|
|
|
|
return {
|
|
type: 'text',
|
|
content: JSON.stringify(exportData, null, 2),
|
|
filename: `photo_metadata_${Date.now()}.json`,
|
|
contentType: 'application/json'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get export format display names
|
|
*/
|
|
static getFormatOptions() {
|
|
return [
|
|
{ value: 'txt', label: 'Filename List (TXT)', description: 'Simple text list of filenames' },
|
|
{ value: 'csv', label: 'Filename List (CSV)', description: 'Spreadsheet with metadata' },
|
|
{ value: 'xmp', label: 'XMP Sidecar Files (ZIP)', description: 'For Lightroom/Bridge/Capture One' },
|
|
{ value: 'json', label: 'Metadata (JSON)', description: 'Structured data for automation' }
|
|
];
|
|
}
|
|
}
|
|
|
|
module.exports = { PhotoExportService };
|