/**
* XMP Sidecar File Generator
* Generates Adobe XMP metadata files for photos with guest feedback
*/
const { COLOR_LABEL_TO_XMP, dominantColorLabel } = require('../constants/colorLabels');
const { roundRating } = require('./markMerge');
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) : null;
const descriptionXml = include_description ? this.generateDescription(photo) : '';
const keywordsXml = include_keywords ? this.generateKeywords(photo) : '';
return `
${descriptionXml}
${keywordsXml}
`;
}
/**
* 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) {
// Delegates to markMerge.roundRating (#745) so the sidecar and the v1 API
// can never disagree about how many stars a photo has. The thresholds
// used to live here; they moved rather than being copied.
return roundRating(avgRating);
}
/**
* Resolve the photo's XMP colour label.
*
* A real colour label the client set while proofing (#1044) always wins:
* that is the whole point of using Lightroom's colour set, and it is an
* explicit choice rather than something inferred. Only when a photo has no
* label does this fall back to the historical rating-derived mapping, so
* exports for events that never enabled colour labels are unchanged.
*
* @param {Object} photo - photo row, may carry dominant_color_label
* @returns {string|null} XMP label colour
*/
mapLabel(photo) {
// Tolerate being handed a bare rating: this used to take one, and
// callers outside the export service may still do so.
if (typeof photo === 'number' || photo === null || photo === undefined) {
return this.mapRatingToLabel(photo);
}
const colorLabel = photo.dominant_color_label
|| dominantColorLabel(photo.color_labels);
if (colorLabel && COLOR_LABEL_TO_XMP[colorLabel]) {
return COLOR_LABEL_TO_XMP[colorLabel];
}
return this.mapRatingToLabel(photo.average_rating);
}
/**
* The pre-#1044 mapping: infer a colour from the average star rating.
* @param {number} avgRating - Average rating
* @returns {string|null} XMP label color
*/
mapRatingToLabel(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 ? parseFloat(photo.average_rating).toFixed(1) : '0';
const likes = photo.like_count || 0;
const favorites = photo.favorite_count || 0;
const colorLabel = photo.dominant_color_label || dominantColorLabel(photo.color_labels);
const colorPart = colorLabel ? `, ${colorLabel} label` : '';
const desc = `PicPeak Guest Feedback: ${rating} stars, ${likes} likes, ${favorites} favorites${colorPart}`;
return `
${this.escapeXml(desc)}
`;
}
/**
* 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');
}
// A searchable keyword for the client's colour choice (#1044) — Lightroom
// can filter on xmp:Label directly, Bridge and Capture One users often
// find keywords easier.
const colorLabel = photo.dominant_color_label || dominantColorLabel(photo.color_labels);
if (colorLabel) {
keywords.push(`color-${colorLabel}`);
}
if (photo.category_name) {
keywords.push(this.sanitizeKeyword(photo.category_name));
}
return `
${keywords.map(k => `${this.escapeXml(k)}`).join('\n ')}
`;
}
/**
* 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, ''');
}
/**
* 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 };