UNPKG

astro-image-exif-loader

Version:

Astro content collection loader for extracting EXIF data from images

256 lines (253 loc) 7.81 kB
import { z } from "astro/zod"; import { exiftool } from "exiftool-vendored"; import { stat } from "fs/promises"; import { basename, relative, resolve } from "path"; import { glob } from "tinyglobby"; import picomatch from "picomatch"; //#region src/utils.ts const FILESYSTEM_LEAKY_TAGS = [ "Directory", "FileName", "FileModifyDate", "FileAccessDate", "FileInodeChangeDate", "FilePermissions", "FileType", "FileTypeExtension", "MIMEType", "ExifToolVersion", "SourceFile" ]; const EXIF_PRESET_MAPPINGS = { basic: [ "FileSize", "ImageWidth", "ImageHeight" ], camera: [ "Make", "Model", "LensModel", "Lens", "LensID", "LensInfo", "LensSerialNumber", "SerialNumber", "BodySerialNumber", "CameraSerialNumber", "LensMake", "MaxAperture", "MinFocalLength", "MaxFocalLength" ], exposure: [ "ISO", "FNumber", "ExposureTime", "ShutterSpeed", "FocalLength", "FocalLengthIn35mmFormat", "Flash", "WhiteBalance", "ExposureMode", "MeteringMode" ], datetime: [ "DateTimeOriginal", "CreateDate", "DateTime" ], location: [ "GPSLatitude", "GPSLongitude", "GPSAltitude", "Country", "State", "City", "Location", "Sub-location", "GPSAreaInformation", "Country-PrimaryLocationCode", "Province-State" ], technical: [ "ColorSpace", "Orientation", "Software", "SceneType", "SceneCaptureType" ], metadata: [ "Artist", "Copyright", "ImageDescription", "Keywords", "Title", "Subject" ] }; function toSerializable(value) { if (value === null || value === void 0) return null; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value; if (value instanceof Date || value && typeof value.toDate === "function") try { return value instanceof Date ? value.toISOString() : value.toDate().toISOString(); } catch { return String(value); } if (Array.isArray(value)) return value.map(toSerializable); if (value && typeof value.valueOf === "function") { const primitiveValue = value.valueOf(); if (typeof primitiveValue === "number" || typeof primitiveValue === "string") return primitiveValue; } if (value && typeof value.num === "number" && typeof value.den === "number") return value.num / value.den; return String(value); } function buildImageData(tags, fileName, mtime, fileSize, tagsToExtract, excludeTags, includeRawExif) { const data = { fileName, mtime }; if (tagsToExtract === null) { for (const [tagName, tagValue] of Object.entries(tags)) if (tagValue !== null && tagValue !== void 0 && !excludeTags.has(tagName)) data[tagName] = toSerializable(tagValue); data.FileSize = fileSize; } else if (tagsToExtract.size > 0) { for (const tagName of tagsToExtract) if (!excludeTags.has(tagName)) { const tagValue = tags[tagName]; if (tagValue !== null && tagValue !== void 0) data[tagName] = toSerializable(tagValue); } if (tagsToExtract.has("FileSize") && !excludeTags.has("FileSize")) data.FileSize = fileSize; } if (includeRawExif) { data.rawExif = {}; for (const [tagName, tagValue] of Object.entries(tags)) if (tagValue !== null && tagValue !== void 0) data.rawExif[tagName] = toSerializable(tagValue); } return data; } //#endregion //#region src/loader.ts function determineTagsToExtract(presets = [], tags) { const tagsSet = /* @__PURE__ */ new Set(); for (const preset of presets) if (EXIF_PRESET_MAPPINGS[preset]) for (const tag of EXIF_PRESET_MAPPINGS[preset]) tagsSet.add(tag); for (const tag of tags) tagsSet.add(tag); return tagsSet; } function createExifLoader(options = {}) { const { imagesDir = { pattern: "**/*", base: "src/content/images" }, presets = [], tags = [], excludeTags = [], extractAll = false, includeRawExif = false } = options; const tagsToExtract = extractAll ? null : determineTagsToExtract(presets, tags); const excludeTagsSet = new Set([...FILESYSTEM_LEAKY_TAGS, ...excludeTags]); return { name: "exif-gallery-loader", load: async ({ store, logger, config, watcher, generateDigest }) => { const dirCfg = imagesDir; const basePath = resolve(config.root.pathname, dirCfg.base || "src/content/images"); const patterns = Array.isArray(dirCfg.pattern) ? dirCfg.pattern : [dirCfg.pattern]; logger.info(`Loading images with EXIF data from ${patterns.join(", ")} (base: ${basePath})`); try { const files = await glob(patterns, { cwd: basePath, expandDirectories: false, onlyFiles: true }); if (files.length === 0) { const patternList = patterns.join(", "); const msg = [ "No images found for astro-exif loader.", ` base: ${basePath}`, ` pattern(s): ${patternList}`, "Make sure imagesDir.base is relative to your project root (e.g. \"src/content/images\")", "and pattern matches your files (e.g. \"**/*\" or \"**/*.{jpg,jpeg,png}\")." ].join("\n"); logger.error(msg); } for (const rel of files) { const abs = resolve(basePath, rel); await processImage(abs, store, logger, generateDigest, config.root.pathname, tagsToExtract, excludeTagsSet, includeRawExif); } } catch (error) { logger.error(`Error globbing files: ${error.message}`); } if (watcher) { watcher.add(basePath); const isMatch = picomatch(patterns); const matches = (changed) => { const absChanged = resolve(changed); const absBase = resolve(basePath); const sep = "/"; if (!(absChanged === absBase || absChanged.startsWith(absBase + sep))) return false; const rel = relative(basePath, changed); return isMatch(rel); }; const onAddOrChange = async (filePath) => { if (!matches(filePath)) return; logger.info(`File updated: ${filePath}`); await processImage(filePath, store, logger, generateDigest, config.root.pathname, tagsToExtract, excludeTagsSet, includeRawExif); }; watcher.on("add", onAddOrChange); watcher.on("change", onAddOrChange); watcher.on("unlink", (filePath) => { if (!matches(filePath)) return; const id = basename(filePath); store.delete(id); logger.info(`File removed: ${filePath}`); }); } } }; } async function processImage(filePath, store, logger, generateDigest, rootPath = "", tagsToExtract, excludeTags, includeRawExif) { try { const fileNameOnly = basename(filePath); const id = fileNameOnly; const stats = await stat(filePath); const mtime = stats.mtime.toISOString(); const existingEntry = store.get(id); if (existingEntry && existingEntry.data.mtime === mtime) return; let tags; try { tags = await exiftool.read(filePath); } catch (error) { const msg = `EXIF read failed for ${fileNameOnly}: ${error.message}`; logger.warn(msg); tags = {}; } const imageData = buildImageData(tags, fileNameOnly, mtime, stats.size, tagsToExtract, excludeTags, includeRawExif); const digest = generateDigest(imageData); const success = store.set({ id, data: imageData, digest, filePath: relative(rootPath, filePath) }); if (success) logger.info(`Processed ${fileNameOnly}`); else logger.debug(`Skipped ${fileNameOnly} (no changes)`); } catch (error) { logger.error(`Failed to process image ${filePath}: ${error.message}`); } } function exifSchema(zod, _options = {}) { const scalar = zod.union([ zod.string(), zod.number(), zod.boolean() ]); const val = zod.union([scalar, scalar.array()]).nullable().optional(); const base = zod.object({ fileName: zod.string(), mtime: zod.string(), rawExif: zod.record(val).optional() }).catchall(val); return base; } function defineExifCollection(options = {}) { const loader = createExifLoader(options); const schema = exifSchema(z, options); return { loader, schema }; } //#endregion export { createExifLoader, defineExifCollection, exifSchema };