## 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
166 lines
4.6 KiB
JavaScript
166 lines
4.6 KiB
JavaScript
/**
|
|
* XMP Sidecar File Generator
|
|
* Generates Adobe XMP metadata files for photos with guest feedback
|
|
*/
|
|
|
|
class XmpGenerator {
|
|
/**
|
|
* Generate XMP sidecar content for a photo
|
|
* @param {Object} photo - Photo object with feedback data
|
|
* @param {Object} options - Generation options
|
|
* @returns {string} XMP file content
|
|
*/
|
|
generateXmp(photo, options = {}) {
|
|
const {
|
|
include_rating = true,
|
|
include_label = true,
|
|
include_description = true,
|
|
include_keywords = true
|
|
} = options;
|
|
|
|
const rating = include_rating ? this.mapRating(photo.average_rating) : 0;
|
|
const label = include_label ? this.mapLabel(photo.average_rating) : null;
|
|
|
|
const descriptionXml = include_description ? this.generateDescription(photo) : '';
|
|
const keywordsXml = include_keywords ? this.generateKeywords(photo) : '';
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="PicPeak Export">
|
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
|
<rdf:Description rdf:about=""
|
|
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
|
xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"
|
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"
|
|
xmlns:Iptc4xmpCore="http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/"
|
|
xmp:Rating="${rating}"${label ? `
|
|
xmp:Label="${label}"` : ''}>
|
|
${descriptionXml}
|
|
${keywordsXml}
|
|
</rdf:Description>
|
|
</rdf:RDF>
|
|
</x:xmpmeta>`;
|
|
}
|
|
|
|
/**
|
|
* Map PicPeak average rating to XMP 1-5 rating
|
|
* @param {number} avgRating - Average rating (0-5, decimal)
|
|
* @returns {number} XMP rating (0-5, integer)
|
|
*/
|
|
mapRating(avgRating) {
|
|
if (!avgRating || avgRating === 0) return 0;
|
|
if (avgRating >= 4.5) return 5;
|
|
if (avgRating >= 3.5) return 4;
|
|
if (avgRating >= 2.5) return 3;
|
|
if (avgRating >= 1.5) return 2;
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Map PicPeak rating to XMP color label
|
|
* @param {number} avgRating - Average rating
|
|
* @returns {string|null} XMP label color
|
|
*/
|
|
mapLabel(avgRating) {
|
|
if (!avgRating || avgRating === 0) return null;
|
|
if (avgRating >= 4.5) return 'Red'; // Top picks
|
|
if (avgRating >= 3.5) return 'Yellow'; // Good
|
|
if (avgRating >= 2.5) return 'Green'; // Average
|
|
if (avgRating >= 1.5) return 'Blue'; // Below average
|
|
return 'Purple'; // Low
|
|
}
|
|
|
|
/**
|
|
* Generate XMP description element
|
|
* @param {Object} photo - Photo object
|
|
* @returns {string} Description XML
|
|
*/
|
|
generateDescription(photo) {
|
|
const rating = photo.average_rating ? photo.average_rating.toFixed(1) : '0';
|
|
const likes = photo.like_count || 0;
|
|
const favorites = photo.favorite_count || 0;
|
|
|
|
const desc = `PicPeak Guest Feedback: ${rating} stars, ${likes} likes, ${favorites} favorites`;
|
|
|
|
return `<dc:description>
|
|
<rdf:Alt>
|
|
<rdf:li xml:lang="x-default">${this.escapeXml(desc)}</rdf:li>
|
|
</rdf:Alt>
|
|
</dc:description>`;
|
|
}
|
|
|
|
/**
|
|
* Generate XMP keywords element
|
|
* @param {Object} photo - Photo object
|
|
* @returns {string} Keywords XML
|
|
*/
|
|
generateKeywords(photo) {
|
|
const keywords = ['picpeak-export'];
|
|
|
|
if (photo.average_rating >= 4) {
|
|
keywords.push('guest-pick');
|
|
}
|
|
|
|
if (photo.average_rating >= 4.5) {
|
|
keywords.push('top-rated');
|
|
}
|
|
|
|
if (photo.like_count >= 5) {
|
|
keywords.push('popular');
|
|
}
|
|
|
|
if (photo.favorite_count > 0) {
|
|
keywords.push('favorited');
|
|
}
|
|
|
|
if (photo.category_name) {
|
|
keywords.push(this.sanitizeKeyword(photo.category_name));
|
|
}
|
|
|
|
return `<dc:subject>
|
|
<rdf:Bag>
|
|
${keywords.map(k => `<rdf:li>${this.escapeXml(k)}</rdf:li>`).join('\n ')}
|
|
</rdf:Bag>
|
|
</dc:subject>`;
|
|
}
|
|
|
|
/**
|
|
* Escape special XML characters
|
|
* @param {string} str - Input string
|
|
* @returns {string} Escaped string
|
|
*/
|
|
escapeXml(str) {
|
|
if (!str) return '';
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/**
|
|
* Sanitize keyword for XMP
|
|
* @param {string} keyword - Raw keyword
|
|
* @returns {string} Sanitized keyword
|
|
*/
|
|
sanitizeKeyword(keyword) {
|
|
return keyword
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
|
|
/**
|
|
* Get XMP filename from photo filename
|
|
* @param {string} photoFilename - Photo filename
|
|
* @returns {string} XMP filename
|
|
*/
|
|
getXmpFilename(photoFilename) {
|
|
return photoFilename.replace(/\.[^.]+$/, '.xmp');
|
|
}
|
|
}
|
|
|
|
module.exports = { XmpGenerator };
|