import * as path from 'node:path' import pMap from 'p-map' import { GlobOptions } from 'glob' import { sanitizeUri } from 'micromark-util-sanitize-uri' import ExifReader from 'exifreader' import { loadImage } from "imagetools/api" import { resolve } from '@polymech/commons' import { files } from '@polymech/commons' import { sync as exists } from '@polymech/fs/exists' import { sync as read } from '@polymech/fs/read' import { sync as mv } from '@polymech/fs/move' import { logger } from '@/base/index.js' import { removeArrayValues, removeArrays, removeBufferValues, removeEmptyObjects } from '@/base/objects.js' import { ITEM_ASSET_URL, PRODUCT_CONFIG, PRODUCT_ROOT, DEFAULT_IMAGE_URL, ASSETS_LOCAL, ASSETS_GLOB } from 'config/config.js' import { GalleryImage, MetaJSON } from './images.js' import { validateFilename, sanitizeFilename } from "@polymech/fs/utils" import { env } from './index.js' export const default_sort = (files: string[]): string[] => { const getSortableParts = (filename: string) => { const baseName = path.parse(filename).name; const match = baseName.match(/^(\d+)_?(.*)$/); // Match leading numbers const numPart = match ? parseInt(match[1], 10) : NaN; const textPart = match ? match[2] : baseName; // Extract text part return { numPart, textPart }; } return files.sort((a, b) => { const { numPart: aNum, textPart: aText } = getSortableParts(a) const { numPart: bNum, textPart: bText } = getSortableParts(b) if (!isNaN(aNum) && !isNaN(bNum)) { return aNum - bNum || aText.localeCompare(bText, undefined, { numeric: true, sensitivity: 'base' }) } return aText.localeCompare(bText, undefined, { numeric: true, sensitivity: 'base' }) }) } export const default_sanitize = (paths: string[]): string[] => { return paths.map((filePath) => { const dir = path.dirname(filePath); const originalFilename = path.basename(filePath); const sanitizedFilename = sanitizeFilename(originalFilename) if (originalFilename === sanitizedFilename) { return filePath; } const newPath = path.join(dir, sanitizedFilename); try { mv(filePath, newPath); return newPath; } catch (error) { return filePath; // Return the original path in case of failure } }); } export const default_filter = async (url: string) => { try { const response = await fetch(url, { method: 'HEAD' }) if (!response.ok) { logger.warn(`Image URL not found ${url}`) return false } } catch (error) { logger.warn(`Image URL not found ${url} : ${error.message}`) return false } return true } export const default_filter_locale = async (url: string) => { return url && exists(url) } export const image_url = async (src, fallback = DEFAULT_IMAGE_URL) => { if (exists(src)) return src let safeSrc = src try { const response = await fetch(src, { method: 'HEAD' }) if (!response.ok) { safeSrc = fallback logger.warn(`Image URL not found ${src}, default to ${fallback}`) } } catch (error) { safeSrc = fallback } return safeSrc } export const gallery = async ( assetPath, product): Promise => { product = '' + product const root = resolve(PRODUCT_ROOT()) const profile = env() const assetSlug = path.parse(assetPath).name const productConfig: any = read(PRODUCT_CONFIG(product), "json") if (!productConfig) { logger.warn(`item gallery : item ${product} config not found !`) return [] } const mediaPath = `${root}/${product}/${assetPath}/` if (!exists(mediaPath)) { logger.warn(`item gallery : item ${product} media path not found ${mediaPath}!`) return [] } const galleryGlob = (productConfig.gallery || {})[assetSlug]?.glob || ASSETS_GLOB let galleryFiles: any[] = files(mediaPath, galleryGlob, { cwd: mediaPath, absolute: false, nodir: true } as GlobOptions) if (!galleryFiles || galleryFiles.length == 0) { return [] } if (!galleryFiles) { logger.warn(`ProductGallery : Product ${product} media files not found ! ${mediaPath}`) return [] } const assetUrl = (filePath) => { return sanitizeUri(ITEM_ASSET_URL( { assetPath, filePath, ITEM_REL: product, ...profile.variables } )) } if (!ASSETS_LOCAL) { galleryFiles = await pMap(galleryFiles, async (f) => (await default_filter(assetUrl(f))) ? f : null, { concurrency: 5 }) galleryFiles = galleryFiles.filter((f) => f !== null) galleryFiles = default_sort(galleryFiles) } else { galleryFiles = galleryFiles.filter(default_filter_locale) galleryFiles = default_sort(galleryFiles) } return await pMap(galleryFiles, async (file: string) => { const parts = path.parse(file) const filePath = path.join(mediaPath, file) const meta_path_json = `${mediaPath}/${parts.name}.json` const meta_json = exists(meta_path_json) ? read(meta_path_json, "json") as MetaJSON : { alt: "", keywords: "", title: "", description: "" } const meta_path_md = `${mediaPath}/${parts.name}.md` const meta_markdown = exists(meta_path_md) ? read(meta_path_md, "string") as string : "" as string let imageMeta: any = await loadImage(filePath) let exifRaw: any = null try { exifRaw = await ExifReader.load(filePath) } catch (e) { logger.error(`ProductGallery : Error loading exif data for ${filePath}`) exifRaw = {} } const keywords = exifRaw?.['LastKeywordXMP']?.description || exifRaw?.iptc?.Keywords?.description || ''.split(',').map((k) => k.trim()) const exifDescription = exifRaw?.['ImageDescription']?.description || '' const width = exifRaw?.['Image Width']?.value const height = exifRaw?.['Image Height']?.value const lon = exifRaw?.['GPSLongitude']?.description const lat = exifRaw?.['GPSLatitude']?.description const title = exifRaw?.title?.description || meta_json.title || '' const alt = meta_json.description || meta_markdown || exifDescription || exifRaw?.iptc?.['Caption/Abstract'].description || '' imageMeta.exif = exifRaw imageMeta = removeBufferValues(imageMeta) imageMeta = removeArrayValues(imageMeta) imageMeta = removeArrays(imageMeta) imageMeta = removeEmptyObjects(imageMeta) delete imageMeta.xmp delete imageMeta.icc delete imageMeta.exif.icc delete imageMeta.exif.xmp delete imageMeta.exif.iptc const src = ASSETS_LOCAL ? filePath : await image_url(assetUrl(file)) const ret: GalleryImage = { name: path.parse(file).name, src: src, url: src, meta: { format: imageMeta.format, width: imageMeta.width, height: imageMeta.height, space: imageMeta.space, channels: imageMeta.channels, depth: imageMeta.depth, density: imageMeta.density, chromaSubsampling: imageMeta.chromaSubsampling, isProgressive: imageMeta.isProgressive, resolutionUnit: imageMeta.resolutionUnit, hasProfile: imageMeta.hasProfile, hasAlpha: imageMeta.hasAlpha, orientation: imageMeta.orientation, exif: imageMeta.exif, json: meta_json as MetaJSON, markdown: meta_markdown as string }, keywords, description: alt, alt, width, height, title, gps: { lon, lat } } return ret }) } /** * Converts gallery images to individual JSON-LD objects for SEO optimization * @param images Array of GalleryImage objects to convert * @param lang Language code for internationalization * @param contentUrl Base URL for the gallery content * @returns Array of JSON-LD representations, one for each image */ export const toJsonLd = async (images: GalleryImage[], lang: string, contentUrl: string) => { if (!images || images.length === 0) { return [] } // Map each image to its own complete JSON-LD object return images.map((image, index) => { // Create a standalone ImageObject for each image const jsonLd = { "@context": "https://schema.org", "@type": "ImageObject", "inLanguage": lang, "contentUrl": contentUrl || image.src, "url": contentUrl || image.src, "name": image.title || image.name, "description": image.description || image.alt || "", "height": image.height, "width": image.width, "identifier": image.name, "keywords": Array.isArray(image.keywords) ? image.keywords.join(", ") : image.keywords || "" } // Add GPS coordinates if available if (image.gps && image.gps.lat && image.gps.lon) { jsonLd["contentLocation"] = { "@type": "Place", "geo": { "@type": "GeoCoordinates", "latitude": image.gps.lat, "longitude": image.gps.lon } } } return jsonLd }) }