All checks were successful
Deploy / deploy (push) Successful in 49s
- German (default) and English i18n support - Categories and tags - Blog posts with hero images - Dark/light theme switcher - View Transitions removed to fix reload ghost images - Webmentions integration - RSS feeds per locale Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
424 lines
12 KiB
JavaScript
424 lines
12 KiB
JavaScript
#!/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[^>]*>([^<]*)<\/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<ImageMetadata> {
|
|
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<CliOptions, "refresh">,
|
|
): Promise<string[]> {
|
|
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);
|
|
}
|
|
}
|