Initial commit: Astro 6 static blog site
All checks were successful
Deploy / deploy (push) Successful in 49s
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>
This commit is contained in:
commit
5bb63bacf5
95 changed files with 12199 additions and 0 deletions
424
scripts/metadata.ts
Normal file
424
scripts/metadata.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
#!/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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue