#!/usr/bin/env -S node --experimental-strip-types import { writeFile } from "node:fs/promises"; import { basename, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { consola } from "consola"; import sharp from "sharp"; import { getImagesMissingMetadata, getMetadataPathForImage, getPhotoAbsolutePath, getPhotoDirectories, PHOTOS_DIRECTORY, } from "../src/lib/photo-albums.ts"; const PHOTOS_DIR = PHOTOS_DIRECTORY; // ─── IPTC parser ──────────────────────────────────────────────────────────── interface IptcFields { title?: string; caption?: string; keywords?: string[]; dateCreated?: string; timeCreated?: string; } function parseIptc(buf: Buffer): IptcFields { const fields: IptcFields = {}; let i = 0; while (i < buf.length - 4) { if (buf[i] !== 0x1c) { i++; continue; } const record = buf[i + 1]; const dataset = buf[i + 2]; const len = buf.readUInt16BE(i + 3); const value = buf.subarray(i + 5, i + 5 + len).toString("utf8"); i += 5 + len; if (record !== 2) continue; switch (dataset) { case 5: fields.title = value; break; case 25: fields.keywords ??= []; fields.keywords.push(value); break; case 55: fields.dateCreated = value; break; case 60: fields.timeCreated = value; break; case 120: fields.caption = value; break; } } return fields; } // ─── XMP parser ───────────────────────────────────────────────────────────── interface XmpFields { title?: string; description?: string; keywords?: string[]; lens?: string | undefined; createDate?: string | undefined; } function extractRdfLiValues(xml: string, tagName: string): string[] { const re = new RegExp(`<${tagName}[^>]*>[\\s\\S]*?<\\/${tagName}>`, "i"); const match = xml.match(re); if (!match) return []; const liRe = /]*>([^<]*)<\/rdf:li>/gi; const values: string[] = []; for (let m = liRe.exec(match[0]); m !== null; m = liRe.exec(match[0])) { if (m[1]?.trim()) values.push(m[1].trim()); } return values; } function extractXmpAttr(xml: string, attr: string): string | undefined { const re = new RegExp(`${attr}="([^"]*)"`, "i"); return xml.match(re)?.[1] ?? undefined; } function parseXmp(buf: Buffer): XmpFields { const xml = buf.toString("utf8"); const fields: XmpFields = {}; const titles = extractRdfLiValues(xml, "dc:title"); if (titles[0]) fields.title = titles[0]; const descriptions = extractRdfLiValues(xml, "dc:description"); if (descriptions[0]) fields.description = descriptions[0]; const subjects = extractRdfLiValues(xml, "dc:subject"); if (subjects.length > 0) fields.keywords = subjects; fields.lens = extractXmpAttr(xml, "aux:Lens"); fields.createDate = extractXmpAttr(xml, "xmp:CreateDate"); return fields; } // ─── EXIF parser (minimal TIFF IFD0 + SubIFD) ────────────────────────────── interface ExifFields { model?: string; lensModel?: string; fNumber?: number; focalLength?: string; exposureTime?: string; iso?: number; dateTimeOriginal?: string; gpsLatitude?: string; gpsLongitude?: string; gpsLatitudeRef?: string; gpsLongitudeRef?: string; } function parseExifBuffer(buf: Buffer): ExifFields { // Skip "Exif\0\0" header if present let offset = 0; if ( buf[0] === 0x45 && buf[1] === 0x78 && buf[2] === 0x69 && buf[3] === 0x66 ) { offset = 6; } const isLE = buf[offset] === 0x49; // "II" = little endian const read16 = isLE ? (o: number) => buf.readUInt16LE(offset + o) : (o: number) => buf.readUInt16BE(offset + o); const read32 = isLE ? (o: number) => buf.readUInt32LE(offset + o) : (o: number) => buf.readUInt32BE(offset + o); const readRational = (o: number): number => { const num = read32(o); const den = read32(o + 4); return den === 0 ? 0 : num / den; }; const readString = (o: number, len: number): string => { return buf .subarray(offset + o, offset + o + len) .toString("ascii") .replace(/\0+$/, ""); }; const fields: ExifFields = {}; const parseIfd = (ifdOffset: number, parseGps = false) => { if (ifdOffset + 2 > buf.length - offset) return; const count = read16(ifdOffset); for (let i = 0; i < count; i++) { const entryOffset = ifdOffset + 2 + i * 12; if (entryOffset + 12 > buf.length - offset) break; const tag = read16(entryOffset); const type = read16(entryOffset + 2); const numValues = read32(entryOffset + 4); const valueOffset = read32(entryOffset + 8); // For values that fit in 4 bytes, data is inline at entryOffset+8 const dataOffset = type === 2 && numValues <= 4 ? entryOffset + 8 : type === 5 || numValues > 4 ? valueOffset : entryOffset + 8; if (parseGps) { switch (tag) { case 1: // GPSLatitudeRef fields.gpsLatitudeRef = readString(entryOffset + 8, 2); break; case 2: // GPSLatitude if (dataOffset + 24 <= buf.length - offset) { const d = readRational(dataOffset); const m = readRational(dataOffset + 8); const s = readRational(dataOffset + 16); fields.gpsLatitude = `${d} deg ${Math.floor(m)}' ${s.toFixed(2)}"`; } break; case 3: // GPSLongitudeRef fields.gpsLongitudeRef = readString(entryOffset + 8, 2); break; case 4: // GPSLongitude if (dataOffset + 24 <= buf.length - offset) { const d = readRational(dataOffset); const m = readRational(dataOffset + 8); const s = readRational(dataOffset + 16); fields.gpsLongitude = `${d} deg ${Math.floor(m)}' ${s.toFixed(2)}"`; } break; } continue; } switch (tag) { case 0x0110: // Model if (dataOffset + numValues <= buf.length - offset) { fields.model = readString(dataOffset, numValues); } break; case 0x8769: // ExifIFD pointer parseIfd(valueOffset); break; case 0x8825: // GPS IFD pointer parseIfd(valueOffset, true); break; case 0x829a: // ExposureTime if (dataOffset + 8 <= buf.length - offset) { const num = read32(dataOffset); const den = read32(dataOffset + 4); fields.exposureTime = den > num ? `1/${Math.round(den / num)}` : `${num / den}`; } break; case 0x829d: // FNumber if (dataOffset + 8 <= buf.length - offset) { fields.fNumber = readRational(dataOffset); } break; case 0x8827: // ISO fields.iso = type === 3 ? read16(entryOffset + 8) : valueOffset; break; case 0x9003: // DateTimeOriginal if (dataOffset + numValues <= buf.length - offset) { fields.dateTimeOriginal = readString(dataOffset, numValues); } break; case 0x920a: // FocalLength if (dataOffset + 8 <= buf.length - offset) { const fl = readRational(dataOffset); fields.focalLength = fl.toFixed(1).replace(/\.0$/, ""); } break; case 0xa434: // LensModel if (dataOffset + numValues <= buf.length - offset) { fields.lensModel = readString(dataOffset, numValues); } break; } } }; const ifdOffset = read32(4); parseIfd(ifdOffset); return fields; } // ─── Merged metadata ──────────────────────────────────────────────────────── interface ImageMetadata { id: string; title: string[]; image: string; alt: string; location: string; date: string; tags: string[]; exif: { camera: string; lens: string; aperture: string; iso: string; focal_length: string; shutter_speed: string; }; } function formatGpsLocation(exif: ExifFields): string { if (!exif.gpsLatitude || !exif.gpsLongitude) return ""; const latRef = exif.gpsLatitudeRef ?? "N"; const lonRef = exif.gpsLongitudeRef ?? "E"; return `${exif.gpsLatitude} ${latRef}, ${exif.gpsLongitude} ${lonRef}`; } function formatDate(raw: string | undefined): string { if (!raw) return ""; // Handle "YYYY:MM:DD HH:MM:SS" or "YYYYMMDD" or "YYYY-MM-DDTHH:MM:SS" if (/^\d{8}$/.test(raw)) { return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`; } const [datePart] = raw.split(/[T ]/); if (!datePart) return ""; return datePart.replaceAll(":", "-"); } async function extractMetadata(imagePath: string): Promise { const meta = await sharp(imagePath).metadata(); const fileName = basename(imagePath); const iptc = meta.iptc ? parseIptc(meta.iptc) : ({} as IptcFields); const xmp = meta.xmp ? parseXmp(meta.xmp) : ({} as XmpFields); const exif = meta.exif ? parseExifBuffer(meta.exif) : ({} as ExifFields); const title = iptc.title || xmp.title || ""; const caption = iptc.caption || xmp.description || ""; const keywords = iptc.keywords ?? xmp.keywords ?? []; const date = formatDate( exif.dateTimeOriginal ?? xmp.createDate ?? iptc.dateCreated, ); if (!title && !caption) { consola.warn(`No title or caption found in ${fileName}`); } return { id: fileName.replace(/\.jpg$/i, ""), title: title ? [title] : [], image: `./${fileName}`, alt: caption, location: formatGpsLocation(exif), date, tags: keywords, exif: { camera: exif.model ?? "", lens: exif.lensModel || xmp.lens || "", aperture: exif.fNumber?.toString() ?? "", iso: exif.iso?.toString() ?? "", focal_length: exif.focalLength?.replace(/ mm$/, "") ?? "", shutter_speed: exif.exposureTime ?? "", }, }; } // ─── CLI ──────────────────────────────────────────────────────────────────── interface CliOptions { refresh: boolean; photosDirectory: string; } function parseCliOptions(argv: string[]): CliOptions { const nonFlagArgs = argv.filter((arg) => !arg.startsWith("--")); return { refresh: argv.includes("--refresh"), photosDirectory: resolve(nonFlagArgs[0] ?? PHOTOS_DIR), }; } async function getImagesToProcess( photosDirectory: string, options: Pick, ): Promise { const relativeImagePaths = options.refresh ? (await getPhotoDirectories(photosDirectory)).flatMap((d) => d.imagePaths) : await getImagesMissingMetadata(photosDirectory); consola.info( options.refresh ? `Refreshing ${relativeImagePaths.length} image(s)` : `Found ${relativeImagePaths.length} image(s) without metadata`, ); return relativeImagePaths.map((p) => getPhotoAbsolutePath(p, photosDirectory), ); } async function main() { consola.start("Checking for images to process..."); const opts = parseCliOptions(process.argv.slice(2)); const images = await getImagesToProcess(opts.photosDirectory, opts); if (images.length === 0) { consola.success( opts.refresh ? "No images found to refresh." : "No images require metadata.", ); return; } for (let i = 0; i < images.length; i++) { const imagePath = images[i] as string; const rel = relative(process.cwd(), imagePath); consola.info(`Processing ${i + 1}/${images.length}: ${rel}`); const metadata = await extractMetadata(imagePath); const relativeImagePath = relative(opts.photosDirectory, imagePath); const jsonPath = getMetadataPathForImage( relativeImagePath, opts.photosDirectory, ); await writeFile(jsonPath, JSON.stringify(metadata, null, 2)); consola.info(`Wrote ${relative(process.cwd(), jsonPath)}`); } consola.success(`Processed ${images.length} image(s).`); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { try { await main(); } catch (error) { consola.error(error); process.exit(1); } }