diff --git a/common/package.json b/common/package.json index 70796bd..9e6bd49 100644 --- a/common/package.json +++ b/common/package.json @@ -8,6 +8,9 @@ "type": "git", "url": "https://github.com/SimplePhotoGallery/core" }, + "engines": { + "node": ">=20.0.0" + }, "type": "module", "main": "./dist/gallery.js", "types": "./dist/gallery.d.ts", diff --git a/common/src/gallery/schemas.ts b/common/src/gallery/schemas.ts index f865de3..e4b13a3 100644 --- a/common/src/gallery/schemas.ts +++ b/common/src/gallery/schemas.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; /** Zod schema for thumbnail metadata including path and dimensions */ -export const ThumbnailSchema = z.object({ +export const ThumbnailSchema = z.looseObject({ baseUrl: z.string().optional(), path: z.string(), pathRetina: z.string(), @@ -11,7 +11,7 @@ export const ThumbnailSchema = z.object({ }); /** Zod schema for media file metadata including type, dimensions, and thumbnail info */ -export const MediaFileSchema = z.object({ +export const MediaFileSchema = z.looseObject({ type: z.enum(['image', 'video']), filename: z.string(), url: z.string().optional(), @@ -26,7 +26,7 @@ export const MediaFileSchema = z.object({ * Zod schema for media file with path. * @deprecated Use MediaFileSchema instead which uses 'filename' instead of 'path'. */ -export const MediaFileDeprecatedSchema = z.object({ +export const MediaFileDeprecatedSchema = z.looseObject({ type: z.enum(['image', 'video']), path: z.string(), alt: z.string().optional(), @@ -37,7 +37,7 @@ export const MediaFileDeprecatedSchema = z.object({ }); /** Zod schema for a gallery section containing title, description, and media files */ -export const GallerySectionSchema = z.object({ +export const GallerySectionSchema = z.looseObject({ title: z.string().optional(), description: z.string().optional(), images: z.array(MediaFileSchema), @@ -47,14 +47,14 @@ export const GallerySectionSchema = z.object({ * Zod schema for a gallery section containing title, description, and media files. * @deprecated Use GallerySectionSchema instead which uses MediaFileSchema. */ -export const GallerySectionDeprecatedSchema = z.object({ +export const GallerySectionDeprecatedSchema = z.looseObject({ title: z.string().optional(), description: z.string().optional(), images: z.array(MediaFileDeprecatedSchema), }); /** Zod schema for sub-gallery metadata including title, header image, and path */ -export const SubGallerySchema = z.object({ +export const SubGallerySchema = z.looseObject({ title: z.string(), headerImage: z.string(), path: z.string(), @@ -79,15 +79,15 @@ const LandscapeSizesSchema = z.object({ }); /** Zod schema for header image variants allowing explicit specification of responsive hero images */ -export const HeaderImageVariantsSchema = z.object({ +export const HeaderImageVariantsSchema = z.looseObject({ portrait: z - .object({ + .looseObject({ avif: PortraitSizesSchema.optional(), jpg: PortraitSizesSchema.optional(), }) .optional(), landscape: z - .object({ + .looseObject({ avif: LandscapeSizesSchema.optional(), jpg: LandscapeSizesSchema.optional(), }) @@ -95,7 +95,7 @@ export const HeaderImageVariantsSchema = z.object({ }); /** Zod schema for complete gallery data including metadata, sections, and sub-galleries */ -export const GalleryMetadataSchema = z.object({ +export const GalleryMetadataSchema = z.looseObject({ image: z.string().optional(), imageWidth: z.number().optional(), imageHeight: z.number().optional(), @@ -112,7 +112,7 @@ export const GalleryMetadataSchema = z.object({ }); /** Zod schema for complete gallery data including metadata, sections, and sub-galleries */ -export const GalleryDataSchema = z.object({ +export const GalleryDataSchema = z.looseObject({ title: z.string(), description: z.string(), mediaBasePath: z.string().optional(), @@ -122,7 +122,7 @@ export const GalleryDataSchema = z.object({ headerImageVariants: HeaderImageVariantsSchema.optional(), theme: z.string().optional(), thumbnails: z - .object({ + .looseObject({ size: z.number().optional(), edge: z.enum(['auto', 'width', 'height']).optional(), format: z.enum(['avif', 'webp', 'jpeg']).optional(), @@ -137,14 +137,14 @@ export const GalleryDataSchema = z.object({ ctaBanner: z.boolean().optional(), customStyles: z.record(z.string(), z.string()).optional(), sections: z.array(GallerySectionSchema), - subGalleries: z.object({ title: z.string(), galleries: z.array(SubGallerySchema) }), + subGalleries: z.looseObject({ title: z.string(), galleries: z.array(SubGallerySchema) }), }); /** * Zod schema for complete gallery data without mediaBasePath. * @deprecated Use GalleryDataSchema instead which includes mediaBasePath and headerImageVariants. */ -export const GalleryDataDeprecatedSchema = z.object({ +export const GalleryDataDeprecatedSchema = z.looseObject({ title: z.string(), description: z.string(), url: z.string().optional(), @@ -154,5 +154,5 @@ export const GalleryDataDeprecatedSchema = z.object({ mediaBaseUrl: z.string().optional(), analyticsScript: z.string().optional(), sections: z.array(GallerySectionDeprecatedSchema), - subGalleries: z.object({ title: z.string(), galleries: z.array(SubGallerySchema) }), + subGalleries: z.looseObject({ title: z.string(), galleries: z.array(SubGallerySchema) }), }); diff --git a/common/src/theme/resolver.ts b/common/src/theme/resolver.ts index 1f0cf1b..080bca2 100644 --- a/common/src/theme/resolver.ts +++ b/common/src/theme/resolver.ts @@ -80,6 +80,10 @@ function resolveSubGallery( }; } +function getHeroVariantSources(variants: unknown): Record | undefined { + return variants && typeof variants === 'object' ? (variants as Record) : undefined; +} + /** * Resolve hero data with all paths computed and srcsets built. */ @@ -97,7 +101,7 @@ async function resolveHero(gallery: GalleryData): Promise { const srcsets = { portraitAvif: buildHeroSrcset( - headerImageVariants?.portrait?.avif, + getHeroVariantSources(headerImageVariants?.portrait?.avif), PORTRAIT_SIZES, thumbnailBasePath, imgBasename, @@ -106,7 +110,7 @@ async function resolveHero(gallery: GalleryData): Promise { useDefaultPaths, ), portraitJpg: buildHeroSrcset( - headerImageVariants?.portrait?.jpg, + getHeroVariantSources(headerImageVariants?.portrait?.jpg), PORTRAIT_SIZES, thumbnailBasePath, imgBasename, @@ -115,7 +119,7 @@ async function resolveHero(gallery: GalleryData): Promise { useDefaultPaths, ), landscapeAvif: buildHeroSrcset( - headerImageVariants?.landscape?.avif, + getHeroVariantSources(headerImageVariants?.landscape?.avif), LANDSCAPE_SIZES, thumbnailBasePath, imgBasename, @@ -124,7 +128,7 @@ async function resolveHero(gallery: GalleryData): Promise { useDefaultPaths, ), landscapeJpg: buildHeroSrcset( - headerImageVariants?.landscape?.jpg, + getHeroVariantSources(headerImageVariants?.landscape?.jpg), LANDSCAPE_SIZES, thumbnailBasePath, imgBasename, diff --git a/docs/commands/build.md b/docs/commands/build.md index 7c0bf82..df6d44b 100644 --- a/docs/commands/build.md +++ b/docs/commands/build.md @@ -14,20 +14,22 @@ If you have created the gallery in a different folder from the photos folder, th ## Options -| Option | Description | Default | -| ----------------------------- | -------------------------------------------------- | --------------------------------------- | -| `-g, --gallery ` | Path to gallery directory | Current directory | -| `-r, --recursive` | Build all galleries | `false` | -| `-b, --base-url ` | Base URL for external hosting | None | -| `-t, --thumbs-base-url ` | Base URL for external hosting of thumbnails | None | -| `--theme ` | Theme package name or local path | gallery.json theme or `theme-modern` | -| `--thumbnail-size ` | Override thumbnail size in pixels | From config hierarchy | -| `--thumbnail-edge ` | Override size mode: auto, width, or height | From config hierarchy | -| `--no-scan` | Do not scan for new photos | `true` | -| `--no-thumbnails` | Skip creating thumbnails | `true` | -| `-v, --verbose` | Show detailed output | | -| `-q, --quiet` | Only show warnings/errors | | -| `-h, --help` | Show command help | | +| Option | Description | Default | +| ----------------------------- | ---------------------------------------------- | ------------------------------------ | +| `-g, --gallery ` | Path to gallery directory | Current directory | +| `-r, --recursive` | Build all galleries | `false` | +| `-b, --base-url ` | Base URL for external hosting | None | +| `-t, --thumbs-base-url ` | Base URL for external hosting of thumbnails | None | +| `--theme ` | Theme package name or local path | gallery.json theme or `theme-modern` | +| `--thumbnail-size ` | Override thumbnail size in pixels | From config hierarchy | +| `--thumbnail-edge ` | Override size mode: auto, width, or height | From config hierarchy | +| `--no-scan` | Do not scan for new photos | `true` | +| `--prune` | Remove missing source files from gallery.json | `false` | +| `--no-thumbnails` | Skip creating thumbnails | `true` | +| `-y, --yes` | Confirm build prompts for non-interactive runs | `false` | +| `-v, --verbose` | Show detailed output | | +| `-q, --quiet` | Only show warnings/errors | | +| `-h, --help` | Show command help | | ## Examples @@ -50,6 +52,12 @@ spg build -b https://photos.example.com/ -t https://photos.example.com/thumbnail # Build without scanning for new photos spg build --no-scan +# Remove deleted source files while scanning +spg build --prune + +# Build non-interactively when photos must be copied into the gallery output +spg build --yes + # Build without creating thumbnails spg build --no-thumbnails diff --git a/gallery/package.json b/gallery/package.json index c52eca1..bec4080 100644 --- a/gallery/package.json +++ b/gallery/package.json @@ -9,6 +9,9 @@ "url": "https://github.com/SimplePhotoGallery/core" }, "homepage": "https://simple.photo", + "engines": { + "node": ">=20.0.0" + }, "files": [ "dist" ], diff --git a/gallery/src/index.ts b/gallery/src/index.ts index d6c920c..f6334cb 100644 --- a/gallery/src/index.ts +++ b/gallery/src/index.ts @@ -27,7 +27,7 @@ const program = new Command(); const telemetryService = new TelemetryService(packageJson.name, packageJson.version, createConsolaUI(program.opts())); program - .name('gallery') + .name('spg') .description('Simple Photo Gallery CLI') .version(packageJson.version) .option('-v, --verbose', 'Verbose output (debug level)', false) @@ -188,6 +188,8 @@ program .option('-t, --thumbs-base-url ', 'Base URL where the thumbnails are hosted') .option('--no-thumbnails', 'Skip creating thumbnails when building the gallery', true) .option('--no-scan', 'Do not scan for new photos when building the gallery', true) + .option('--prune', 'Remove gallery entries whose source files are missing when scanning', false) + .option('-y, --yes', 'Answer yes to build confirmations, including copying photos from mediaBasePath', false) .option( '--theme ', 'Theme package name (e.g., @simple-photo-gallery/theme-modern) or local path (e.g., ./themes/my-theme)', diff --git a/gallery/src/modules/build/index.ts b/gallery/src/modules/build/index.ts index 088348c..b0ea1cd 100644 --- a/gallery/src/modules/build/index.ts +++ b/gallery/src/modules/build/index.ts @@ -1,8 +1,8 @@ -import { execSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; import process from 'node:process'; -import { fileURLToPath } from 'node:url'; import { joinUrl, toUrlPath } from '@simple-photo-gallery/common/theme'; import { LogLevels, type ConsolaInstance } from 'consola'; @@ -15,14 +15,20 @@ import { } from './utils'; import { buildCliThumbnailConfig, findGalleries } from '../../utils'; -import { parseGalleryJson } from '../../utils/gallery'; +import { parseGalleryJson, writeGalleryJsonAtomic } from '../../utils/gallery'; import { scanDirectory } from '../init'; import { processGalleryThumbnails } from '../thumbnails'; import type { BuildOptions } from './types'; import type { CommandResultSummary } from '../telemetry/types'; -import type { GalleryData } from '@simple-photo-gallery/common'; +import type { GalleryData, MediaFile } from '@simple-photo-gallery/common'; import type { ThumbnailConfig } from '@simple-photo-gallery/common/theme'; +import type { Buffer } from 'node:buffer'; + +interface ScanAndAppendResult { + galleryData: GalleryData; + dirty: boolean; +} /** * Copies photos from gallery subdirectory to main directory when needed @@ -44,6 +50,28 @@ function copyPhotos(galleryData: GalleryData, galleryDir: string, ui: ConsolaIns } } +function removeThumbnailFiles(mediaFile: MediaFile, galleryDir: string, ui: ConsolaInstance): void { + const imagesDir = path.join(galleryDir, 'gallery', 'images'); + const thumbnailPaths = [mediaFile.thumbnail?.path, mediaFile.thumbnail?.pathRetina].filter( + (thumbnailPath): thumbnailPath is string => typeof thumbnailPath === 'string', + ); + + for (const thumbnailPath of thumbnailPaths) { + const filePath = path.resolve(imagesDir, thumbnailPath); + const relativePath = path.relative(path.resolve(imagesDir), filePath); + + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + ui.debug(`Skipping thumbnail outside images directory: ${thumbnailPath}`); + continue; + } + + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }); + ui.debug(`Deleted orphaned thumbnail ${filePath}`); + } + } +} + /** * Scans a directory for new media files and appends them to the gallery.json * @param galleryDir - Directory containing the gallery @@ -54,10 +82,12 @@ function copyPhotos(galleryData: GalleryData, galleryDir: string, ui: ConsolaIns */ async function scanAndAppendNewFiles( galleryDir: string, - galleryJsonPath: string, galleryData: GalleryData, + prune: boolean, ui: ConsolaInstance, -): Promise { +): Promise { + let dirty = false; + // Determine the directory to scan based on mediaBasePath const scanPath = galleryData.mediaBasePath || galleryDir; @@ -69,7 +99,7 @@ async function scanAndAppendNewFiles( scanResult = await scanDirectory(scanPath, ui); } catch { ui.debug(`Could not scan directory ${scanPath}`); - return galleryData; + return { galleryData, dirty }; } // Get all existing filenames from all sections @@ -79,6 +109,10 @@ async function scanAndAppendNewFiles( // Filter out files that already exist in the gallery const newMediaFiles = scanResult.mediaFiles.filter((file) => !existingFilenames.has(file.filename)); + const scannedFilenames = new Set(scanResult.mediaFiles.map((file) => file.filename)); + const missingMediaFiles = galleryData.sections.flatMap((section) => + section.images.filter((image) => !image.url && !scannedFilenames.has(image.filename)), + ); // If there are new files, append them to the last section if (newMediaFiles.length > 0) { @@ -95,16 +129,96 @@ async function scanAndAppendNewFiles( // Append new files to the last section lastSection.images.push(...newMediaFiles); - // Save the updated gallery.json - ui.debug('Updating gallery.json with new files'); - fs.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2)); + dirty = true; ui.success(`Added ${newMediaFiles.length} new ${newMediaFiles.length === 1 ? 'file' : 'files'} to gallery.json`); } else { ui.debug('No new media files found'); } - return galleryData; + if (missingMediaFiles.length > 0) { + if (prune) { + const missingFilenames = new Set(missingMediaFiles.map((file) => file.filename)); + + for (const mediaFile of missingMediaFiles) { + removeThumbnailFiles(mediaFile, galleryDir, ui); + } + + for (const section of galleryData.sections) { + section.images = section.images.filter((image) => !missingFilenames.has(image.filename)); + } + + dirty = true; + ui.success( + `Removed ${missingMediaFiles.length} missing ${missingMediaFiles.length === 1 ? 'file' : 'files'} from gallery.json`, + ); + } else { + ui.warn( + `Found ${missingMediaFiles.length} gallery ${ + missingMediaFiles.length === 1 ? 'entry' : 'entries' + } whose source files are missing. Run build --scan --prune to remove them.`, + ); + } + } + + return { galleryData, dirty }; +} + +function resolveAstroBinary(templateDir: string): string { + const requireFromTheme = createRequire(path.join(templateDir, 'package.json')); + const astroPackageJsonPath = requireFromTheme.resolve('astro/package.json'); + const astroPackageJson = JSON.parse(fs.readFileSync(astroPackageJsonPath, 'utf8')) as { + bin?: string | Record; + }; + const astroBinPath = typeof astroPackageJson.bin === 'string' ? astroPackageJson.bin : astroPackageJson.bin?.astro; + + if (!astroBinPath) { + throw new Error(`Could not resolve the astro binary from ${astroPackageJsonPath}`); + } + + return path.join(path.dirname(astroPackageJsonPath), astroBinPath); +} + +function getLastBuildOutputLines(...outputs: Array): string { + return outputs + .filter(Boolean) + .map((output) => output!.toString()) + .join('\n') + .split(/\r?\n/) + .filter(Boolean) + .slice(-30) + .join('\n'); +} + +function runAstroBuild(templateDir: string, galleryJsonPath: string, outputDir: string, ui: ConsolaInstance): void { + const astroBin = resolveAstroBinary(templateDir); + const verbose = ui.level === LogLevels.debug; + const result = spawnSync(process.execPath, [astroBin, 'build'], { + cwd: templateDir, + stdio: verbose ? 'inherit' : 'pipe', + env: { + ...process.env, + GALLERY_JSON_PATH: galleryJsonPath, + GALLERY_OUTPUT_DIR: outputDir, + }, + encoding: 'utf8', + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + if (!verbose) { + const buildOutput = getLastBuildOutputLines(result.stdout, result.stderr); + if (buildOutput) { + ui.error(`Astro build output:\n${buildOutput}`); + } + } + + const status = result.signal ? `signal ${result.signal}` : `exit code ${result.status ?? 'unknown'}`; + throw new Error(`Astro build failed with ${status}`); + } } /** @@ -123,8 +237,10 @@ async function buildGallery( galleryDir: string, templateDir: string, scan: boolean, + prune: boolean, shouldCreateThumbnails: boolean, ui: ConsolaInstance, + yes: boolean, baseUrl?: string, thumbsBaseUrl?: string, cliThumbnailConfig?: ThumbnailConfig, @@ -135,10 +251,22 @@ async function buildGallery( // Read the gallery.json file const galleryJsonPath = path.join(galleryDir, 'gallery', 'gallery.json'); let galleryData = parseGalleryJson(galleryJsonPath, ui); + let dirty = false; + + const markDirty = (): void => { + dirty = true; + }; + + const saveGalleryData = (): void => { + writeGalleryJsonAtomic(galleryJsonPath, galleryData); + dirty = false; + }; // Scan for new media files and append them to the gallery.json if (scan) { - galleryData = await scanAndAppendNewFiles(galleryDir, galleryJsonPath, galleryData, ui); + const scanResult = await scanAndAppendNewFiles(galleryDir, galleryData, prune, ui); + galleryData = scanResult.galleryData; + dirty ||= scanResult.dirty; } const socialMediaCardImagePath = path.join(galleryDir, 'gallery', 'images', 'social-media-card.jpg'); @@ -184,7 +312,7 @@ async function buildGallery( if (galleryData.headerImageBlurHash !== blurHash) { ui.debug('Updating gallery.json with header image blurhash'); galleryData.headerImageBlurHash = blurHash; - fs.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2)); + markDirty(); } } else { ui.warn('No header image provided, skipping social media card image creation'); @@ -193,9 +321,18 @@ async function buildGallery( // Ask the user if the photos should be copied if there is not baseUrl and mediaBasePath is set if (!mediaBaseUrl && mediaBasePath) { - const shouldCopyPhotos = await ui.prompt('All photos need to be copied. Are you sure you want to continue?', { - type: 'confirm', - }); + let shouldCopyPhotos = yes; + + if (!shouldCopyPhotos) { + if (!process.stdout.isTTY) { + throw new Error('Photos must be copied before build. Use --yes to confirm copying in non-interactive environments.'); + } + + shouldCopyPhotos = await ui.prompt('All photos need to be copied. Are you sure you want to continue?', { + type: 'confirm', + default: false, + }); + } if (shouldCopyPhotos) { ui.debug('Copying photos'); @@ -207,21 +344,21 @@ async function buildGallery( if (mediaBaseUrl && galleryData.mediaBaseUrl !== mediaBaseUrl) { ui.debug('Updating gallery.json with baseUrl'); galleryData.mediaBaseUrl = mediaBaseUrl; - fs.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2)); + markDirty(); } // If the thumbsBaseUrl is provided, update the gallery.json file if needed if (thumbsBaseUrl && galleryData.thumbsBaseUrl !== thumbsBaseUrl) { ui.debug('Updating gallery.json with thumbsBaseUrl'); galleryData.thumbsBaseUrl = thumbsBaseUrl; - fs.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2)); + markDirty(); } // If the theme is provided via CLI, update the gallery.json file if needed if (cliTheme && galleryData.theme !== cliTheme) { ui.debug('Updating gallery.json with theme'); galleryData.theme = cliTheme; - fs.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2)); + markDirty(); } // If thumbnail settings are provided via CLI, update the gallery.json file if needed @@ -233,7 +370,7 @@ async function buildGallery( if (needsUpdate) { ui.debug('Updating gallery.json with thumbnail settings'); galleryData.thumbnails = { ...currentThumbnails, ...cliThumbnailConfig }; - fs.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2)); + markDirty(); } } @@ -241,10 +378,19 @@ async function buildGallery( if (!galleryData.metadata.image && galleryData.headerImage) { ui.debug('Updating gallery.json with social media card URL'); - galleryData.metadata.image = thumbsBaseUrl - ? `${thumbsBaseUrl}/${path.basename(socialMediaCardImagePath)}` - : `${galleryData.url || ''}/${path.relative(galleryDir, socialMediaCardImagePath)}`; - fs.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2)); + const relativeSocialCardPath = toUrlPath(path.relative(galleryDir, socialMediaCardImagePath)); + if (thumbsBaseUrl) { + galleryData.metadata.image = joinUrl(thumbsBaseUrl, path.basename(socialMediaCardImagePath)); + } else if (galleryData.url) { + galleryData.metadata.image = joinUrl(galleryData.url, relativeSocialCardPath); + } else { + galleryData.metadata.image = `/${relativeSocialCardPath}`; + } + markDirty(); + } + + if (dirty) { + saveGalleryData(); } // Generate the thumbnails if needed @@ -255,11 +401,7 @@ async function buildGallery( // Build the template ui.debug('Building gallery from template'); try { - // Set the environment variable for the gallery.json path that will be used by the template - process.env.GALLERY_JSON_PATH = galleryJsonPath; - process.env.GALLERY_OUTPUT_DIR = path.join(galleryDir, 'gallery'); - - execSync('npx astro build', { cwd: templateDir, stdio: ui.level === LogLevels.debug ? 'inherit' : 'ignore' }); + runAstroBuild(templateDir, galleryJsonPath, path.join(galleryDir, 'gallery'), ui); } catch (error) { ui.error(`Build failed for ${galleryDir}`); throw error; @@ -295,6 +437,20 @@ function isLocalThemePath(theme: string): boolean { return theme.startsWith('./') || theme.startsWith('../') || theme.startsWith('/'); } +function resolvePackageJsonPath(packageName: string): string { + const candidates = [path.join(process.cwd(), 'package.json'), process.argv[1]].filter(Boolean) as string[]; + + for (const candidate of candidates) { + try { + return createRequire(candidate).resolve(`${packageName}/package.json`); + } catch { + // Try the next resolution base. + } + } + + throw new Error(`Cannot find package '${packageName}'`); +} + /** * Resolves the theme directory from either a local path or npm package name * @param theme - Theme identifier (path or package name) @@ -315,8 +471,8 @@ export async function resolveThemeDir(theme: string, ui: ConsolaInstance): Promi return themeDir; } else { // Resolve npm package - const themePath = await import.meta.resolve(`${theme}/package.json`); - const themeDir = path.dirname(fileURLToPath(themePath)); + const themePath = resolvePackageJsonPath(theme); + const themeDir = path.dirname(themePath); ui.debug(`Using npm theme package: ${theme} (${themeDir})`); return themeDir; } @@ -355,8 +511,10 @@ export async function build(options: BuildOptions, ui: ConsolaInstance): Promise path.resolve(dir), themeDir, options.scan, + options.prune, options.thumbnails, ui, + options.yes, baseUrl, thumbsBaseUrl, cliThumbnailConfig, diff --git a/gallery/src/modules/build/types/index.ts b/gallery/src/modules/build/types/index.ts index 876b0e9..a5f577e 100644 --- a/gallery/src/modules/build/types/index.ts +++ b/gallery/src/modules/build/types/index.ts @@ -12,8 +12,12 @@ export interface BuildOptions { thumbsBaseUrl?: string; /** Scan for new photos */ scan: boolean; + /** Remove gallery entries for missing source files during scan */ + prune: boolean; /** Create thumbnails */ thumbnails: boolean; + /** Answer yes to build confirmations */ + yes: boolean; /** Theme package name to use for building (e.g., '@simple-photo-gallery/theme-modern' or '@your-org/your-private-theme') */ theme?: string; /** Override thumbnail size in pixels */ diff --git a/gallery/src/modules/create-theme/templates/base/package.json b/gallery/src/modules/create-theme/templates/base/package.json index 451b98e..e3d4551 100644 --- a/gallery/src/modules/create-theme/templates/base/package.json +++ b/gallery/src/modules/create-theme/templates/base/package.json @@ -4,6 +4,9 @@ "description": "Custom theme for Simple Photo Gallery", "license": "MIT", "type": "module", + "engines": { + "node": ">=20.0.0" + }, "files": [ "public", "src", diff --git a/gallery/src/modules/init/index.ts b/gallery/src/modules/init/index.ts index 769f12a..e8bfcc7 100644 --- a/gallery/src/modules/init/index.ts +++ b/gallery/src/modules/init/index.ts @@ -1,10 +1,13 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; +import process from 'node:process'; import { toUrlPath } from '@simple-photo-gallery/common/theme'; import { capitalizeTitle, getMediaFileType } from './utils'; +import { shouldSkipDirectory } from '../../utils'; + import type { GallerySettingsFromUser, ProcessDirectoryResult, ScanDirectoryResult, ScanOptions, SubGallery } from './types'; import type { CommandResultSummary } from '../telemetry/types'; import type { MediaFile } from '@simple-photo-gallery/common'; @@ -37,7 +40,7 @@ export async function scanDirectory(dirPath: string, ui: ConsolaInstance): Promi mediaFiles.push(mediaFile); } - } else if (entry.isDirectory() && entry.name !== 'gallery') { + } else if (entry.isDirectory() && !shouldSkipDirectory(entry.name)) { subGalleryDirectories.push(path.join(dirPath, entry.name)); } } @@ -236,8 +239,9 @@ async function processDirectory( ui.start(`Scanning ${scanPath}`); let totalFiles = 0; - let totalGalleries = 1; + let totalGalleries = 0; const subGalleries: SubGallery[] = []; + let createdCurrentGallery = false; // Scan current directory for media files const { mediaFiles, subGalleryDirectories } = await scanDirectory(scanPath, ui); @@ -276,6 +280,10 @@ async function processDirectory( const exists = await galleryExists(outputPath); if (exists && !force) { + if (!process.stdout.isTTY) { + throw new Error(`Gallery already exists at ${galleryJsonPath}. Use --force to overwrite it.`); + } + // Ask user if they want to override const shouldOverride = await ui.prompt(`Gallery already exists at ${galleryJsonPath}. Do you want to override it?`, { type: 'confirm', @@ -284,7 +292,7 @@ async function processDirectory( if (!shouldOverride) { ui.info('Skipping gallery creation'); - return { totalFiles: 0, totalGalleries: 0 }; + return { totalFiles: totalFiles - mediaFiles.length, totalGalleries }; } } @@ -304,8 +312,11 @@ async function processDirectory( ui, ); + createdCurrentGallery = true; + totalGalleries += 1; + ui.success( - `Create gallery with ${mediaFiles.length} files and ${subGalleries.length} subgalleries at: ${galleryJsonPath}`, + `Created gallery with ${mediaFiles.length} files and ${subGalleries.length} subgalleries at: ${galleryJsonPath}`, ); } catch (error) { ui.error(`Error creating gallery.json at ${galleryJsonPath}`); @@ -313,11 +324,11 @@ async function processDirectory( } } - // Return result with suGgallery info if this directory has media files + // Return result with subgallery info if this directory has media files const result: ProcessDirectoryResult = { totalFiles, totalGalleries }; - // If this directory has media files or subGalleries, create a subGallery in the result - if (mediaFiles.length > 0 || subGalleries.length > 0) { + // If this directory created a gallery, create a subGallery in the result + if (createdCurrentGallery) { const dirName = path.basename(scanPath); result.subGallery = { title: capitalizeTitle(dirName), diff --git a/gallery/src/modules/init/utils/index.ts b/gallery/src/modules/init/utils/index.ts index e85af65..c3a09d9 100644 --- a/gallery/src/modules/init/utils/index.ts +++ b/gallery/src/modules/init/utils/index.ts @@ -23,9 +23,9 @@ export function getMediaFileType(fileName: string): MediaFileType | null { */ export function capitalizeTitle(folderName: string): string { return folderName - .replace('-', ' ') - .replace('_', ' ') + .replaceAll(/[-_]+/g, ' ') .split(' ') + .filter(Boolean) .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } diff --git a/gallery/src/modules/telemetry/clients/api.ts b/gallery/src/modules/telemetry/clients/api.ts index 6d7e1b0..9abd192 100644 --- a/gallery/src/modules/telemetry/clients/api.ts +++ b/gallery/src/modules/telemetry/clients/api.ts @@ -1,7 +1,5 @@ import process from 'node:process'; -import axios from 'axios'; - import type { TelemetryClient, TelemetryEvent } from '../types'; /** @@ -12,11 +10,14 @@ export class ApiTelemetryClient implements TelemetryClient { async record(event: TelemetryEvent): Promise { try { - axios.post(this.endpoint, event, { + await fetch(this.endpoint, { + method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `simple-photo-gallery/${event.packageVersion} (${process.platform}; ${process.arch})`, }, + body: JSON.stringify(event), + signal: AbortSignal.timeout(3000), }); } catch { // Swallow network errors - telemetry must never interrupt the CLI flow. diff --git a/gallery/src/modules/thumbnails/index.ts b/gallery/src/modules/thumbnails/index.ts index d2ce2b8..22ec22e 100644 --- a/gallery/src/modules/thumbnails/index.ts +++ b/gallery/src/modules/thumbnails/index.ts @@ -17,14 +17,14 @@ import { buildCliThumbnailConfig, findGalleries, handleFileProcessingError } fro import { generateBlurHash } from '../../utils/blurhash'; import { mapWithConcurrency } from '../../utils/concurrency'; import { getImageDescription } from '../../utils/descriptions'; -import { parseGalleryJson } from '../../utils/gallery'; +import { parseGalleryJson, writeGalleryJsonAtomic } from '../../utils/gallery'; import { createImageThumbnails, loadImageWithMetadata, type ImageEncodeOptions, type ThumbnailSizeDimension, } from '../../utils/image'; -import { getVideoDimensions, createVideoThumbnails } from '../../utils/video'; +import { checkVideoToolchain, getVideoDimensions, createVideoThumbnails } from '../../utils/video'; import { resolveThemeDir } from '../build'; import type { ThumbnailOptions } from './types'; @@ -32,6 +32,17 @@ import type { CommandResultSummary } from '../telemetry/types'; import type { MediaFile } from '@simple-photo-gallery/common'; import type { ResolvedThumbnailConfig, ThumbnailConfig } from '@simple-photo-gallery/common/theme'; +type MediaProcessingStatus = 'processed' | 'skipped' | 'failed'; + +interface ProcessMediaFileResult { + mediaFile: MediaFile; + status: MediaProcessingStatus; +} + +interface FailedMediaFile { + filename: string; +} + /** * Processes an image file to create thumbnail and extract metadata * @param imagePath - Path to the image file @@ -131,6 +142,7 @@ async function processVideo( thumbnailSize: number, thumbnailSizeDimension: ThumbnailSizeDimension = 'auto', verbose: boolean, + ui: ConsolaInstance, encodeOptions: ImageEncodeOptions = {}, lastMediaTimestamp?: Date, ): Promise { @@ -154,6 +166,7 @@ async function processVideo( thumbnailSize, thumbnailSizeDimension, verbose, + verbose ? (message) => ui.debug(`ffmpeg: ${message.trimEnd()}`) : undefined, encodeOptions, ); @@ -192,7 +205,12 @@ async function processMediaFile( thumbnailsPath: string, thumbnailConfig: ResolvedThumbnailConfig, ui: ConsolaInstance, -): Promise { + canProcessVideos: boolean, +): Promise { + if (mediaFile.type === 'video' && !canProcessVideos) { + return { mediaFile, status: 'skipped' }; + } + try { // Resolve the path relative to the mediaBasePath const filePath = path.resolve(path.join(mediaBasePath, mediaFile.filename)); @@ -231,6 +249,7 @@ async function processMediaFile( thumbnailConfig.size, thumbnailConfig.edge, verbose, + ui, encodeOptions, lastMediaTimestamp, )); @@ -243,18 +262,21 @@ async function processMediaFile( try { const blurHash = await generateBlurHash(thumbnailPath); return { - ...mediaFile, - thumbnail: { - ...mediaFile.thumbnail, - blurHash, + mediaFile: { + ...mediaFile, + thumbnail: { + ...mediaFile.thumbnail, + blurHash, + }, }, + status: 'processed', }; } catch (error) { ui.debug(` Failed to generate BlurHash for ${fileName}:`, error); } } - return mediaFile; + return { mediaFile, status: 'skipped' }; } updatedMediaFile.filename = mediaFile.filename; @@ -271,11 +293,11 @@ async function processMediaFile( updatedMediaFile.url = mediaFile.url; } - return updatedMediaFile; + return { mediaFile: updatedMediaFile, status: 'processed' }; } catch (error) { handleFileProcessingError(error, mediaFile.filename, ui); - return { ...mediaFile, thumbnail: undefined }; + return { mediaFile: { ...mediaFile, thumbnail: undefined }, status: 'failed' }; } } @@ -329,13 +351,7 @@ export async function processGalleryThumbnails( // If the mediaBasePath is not set, use the gallery directory const mediaBasePath = galleryData.mediaBasePath ?? path.join(galleryDir); - // Persist gallery.json atomically (write to a temp file then rename) so an interruption can never - // leave a truncated gallery.json behind. - const writeGalleryData = (): void => { - const tempPath = `${galleryJsonPath}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(galleryData, null, 2)); - fs.renameSync(tempPath, galleryJsonPath); - }; + const writeGalleryData = (): void => writeGalleryJsonAtomic(galleryJsonPath, galleryData); // On interruption (e.g. Ctrl-C) flush the progress made so far before exiting. Each completed file // records its lastMediaTimestamp, so a subsequent run skips everything already finished instead of @@ -354,27 +370,54 @@ export async function processGalleryThumbnails( // thread), so a worker pool sized to the available cores processes a large gallery several times // faster than the previous one-at-a-time loop while keeping per-file error isolation. const concurrency = Math.max(2, os.cpus().length - 1); + const videoFiles = galleryData.sections + .flatMap((section) => section.images) + .filter((mediaFile) => mediaFile.type === 'video'); + const videoToolchain = videoFiles.length > 0 ? checkVideoToolchain() : { available: true, missing: [], installHint: '' }; + if (!videoToolchain.available) { + ui.warn( + `Skipping ${videoFiles.length} video ${videoFiles.length === 1 ? 'file' : 'files'} because ${videoToolchain.missing.join( + ' and ', + )} ${videoToolchain.missing.length === 1 ? 'is' : 'are'} not available. ${videoToolchain.installHint}`, + ); + } // Flush progress to disk every PROGRESS_WRITE_INTERVAL files so a long run that is interrupted can // resume instead of redoing all the thumbnails already on disk. const PROGRESS_WRITE_INTERVAL = 16; let processedCount = 0; + let skippedCount = 0; + const failedFiles: FailedMediaFile[] = []; let processedSinceWrite = 0; try { for (const section of galleryData.sections) { await mapWithConcurrency(section.images, concurrency, async (mediaFile, index) => { - const updated = await processMediaFile(mediaFile, mediaBasePath, thumbnailsPath, thumbnailConfig, ui); - section.images[index] = updated; + const result = await processMediaFile( + mediaFile, + mediaBasePath, + thumbnailsPath, + thumbnailConfig, + ui, + videoToolchain.available, + ); + section.images[index] = result.mediaFile; + + if (result.status === 'processed') { + processedCount += 1; + } else if (result.status === 'skipped') { + skippedCount += 1; + } else { + failedFiles.push({ filename: mediaFile.filename }); + } - processedCount += 1; processedSinceWrite += 1; if (processedSinceWrite >= PROGRESS_WRITE_INTERVAL) { processedSinceWrite = 0; writeGalleryData(); } - return updated; + return result; }); } } finally { @@ -385,7 +428,20 @@ export async function processGalleryThumbnails( // Write the final gallery.json with all processed files writeGalleryData(); - ui.success(`Created thumbnails for ${processedCount} media files`); + if (failedFiles.length > 0) { + ui.warn( + `${failedFiles.length} media ${failedFiles.length === 1 ? 'file' : 'files'} failed during thumbnail processing: ${failedFiles + .map((file) => file.filename) + .join(', ')}`, + ); + throw new Error(`Failed to process ${failedFiles.length} media ${failedFiles.length === 1 ? 'file' : 'files'}`); + } + + ui.success( + `Created thumbnails for ${processedCount} media ${processedCount === 1 ? 'file' : 'files'}${ + skippedCount > 0 ? ` (${skippedCount} skipped)` : '' + }`, + ); return processedCount; } catch (error) { diff --git a/gallery/src/utils/gallery.ts b/gallery/src/utils/gallery.ts index 0254b77..622946f 100644 --- a/gallery/src/utils/gallery.ts +++ b/gallery/src/utils/gallery.ts @@ -44,6 +44,17 @@ export function parseGalleryJson(galleryJsonPath: string, ui: ConsolaInstance): } } +/** + * Writes gallery.json atomically so interruptions never leave a truncated file behind. + * @param galleryJsonPath - Path to the gallery.json file + * @param galleryData - Gallery data to persist + */ +export function writeGalleryJsonAtomic(galleryJsonPath: string, galleryData: GalleryData): void { + const tempPath = `${galleryJsonPath}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(galleryData, null, 2)); + fs.renameSync(tempPath, galleryJsonPath); +} + /** * Migrates gallery data from the deprecated schema to the new schema * @@ -62,7 +73,7 @@ export function migrateGalleryJson( // Check if a mediaBasePath should be used let mediaBasePath: string | undefined; - const imagePath = deprecatedGalleryData.sections[0].images[0].path; + const imagePath = deprecatedGalleryData.sections[0]?.images[0]?.path; if (imagePath && imagePath !== path.join('..', path.basename(imagePath))) { mediaBasePath = path.resolve(path.join(path.dirname(galleryJsonPath)), path.dirname(imagePath)); } @@ -96,7 +107,7 @@ export function migrateGalleryJson( // Write the gallery data to the gallery.json file ui.debug('Writing gallery data to gallery.json file'); - fs.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2)); + writeGalleryJsonAtomic(galleryJsonPath, galleryData); ui.success('Gallery data migrated to the new data format successfully.'); diff --git a/gallery/src/utils/index.ts b/gallery/src/utils/index.ts index 0ae7816..e3e9b77 100644 --- a/gallery/src/utils/index.ts +++ b/gallery/src/utils/index.ts @@ -6,6 +6,8 @@ import { THUMBNAIL_FORMATS } from '@simple-photo-gallery/common/theme'; import type { ThumbnailConfig, ThumbnailFormat } from '@simple-photo-gallery/common/theme'; import type { ConsolaInstance } from 'consola'; +const SKIPPED_DIRECTORY_NAMES = new Set(['gallery', 'node_modules']); + /** * Finds all gallery directories that contain a gallery/gallery.json file. * @@ -27,7 +29,7 @@ export function findGalleries(basePath: string, recursive: boolean): string[] { try { const entries = fs.readdirSync(basePath, { withFileTypes: true }); for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'gallery') { + if (entry.isDirectory() && !shouldSkipDirectory(entry.name)) { const subPath = path.join(basePath, entry.name); const subResults = findGalleries(subPath, recursive); galleryDirs.push(...subResults); @@ -41,6 +43,15 @@ export function findGalleries(basePath: string, recursive: boolean): string[] { return galleryDirs; } +/** + * Returns whether a directory should be excluded from gallery scans and recursive discovery. + * @param directoryName - Directory basename + * @returns true when the directory should be skipped + */ +export function shouldSkipDirectory(directoryName: string): boolean { + return directoryName.startsWith('.') || SKIPPED_DIRECTORY_NAMES.has(directoryName); +} + /** * Handles file processing errors with appropriate user-friendly messages * @param error - The error that occurred during file processing diff --git a/gallery/src/utils/version.ts b/gallery/src/utils/version.ts index d3c9de6..b4ad198 100644 --- a/gallery/src/utils/version.ts +++ b/gallery/src/utils/version.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; import { compareSemVer, parseSemVer } from 'semver-parser'; import type { ConsolaInstance } from 'consola'; @@ -19,15 +18,21 @@ interface PackageInfo { async function fetchLatestStableVersion(packageName: string): Promise { try { // Use abbreviated metadata endpoint for faster response - const response = await axios.get(`${NPM_REGISTRY_URL}/${packageName}`, { - timeout: CHECK_TIMEOUT_MS, + const response = await fetch(`${NPM_REGISTRY_URL}/${packageName}`, { + signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), headers: { Accept: 'application/vnd.npm.install-v1+json', }, }); + if (!response.ok) { + return undefined; + } + + const packageInfo = (await response.json()) as PackageInfo; + // Get all version numbers - const versions = Object.keys(response.data.versions); + const versions = Object.keys(packageInfo.versions); // Filter to only stable versions (no pre-release identifiers) const stableVersions = versions.filter((version) => { diff --git a/gallery/src/utils/video.ts b/gallery/src/utils/video.ts index 5bf4c3b..d8a86b7 100644 --- a/gallery/src/utils/video.ts +++ b/gallery/src/utils/video.ts @@ -1,5 +1,6 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { promises as fs } from 'node:fs'; +import process from 'node:process'; import ffprobe from 'node-ffprobe'; import sharp from 'sharp'; @@ -9,6 +10,44 @@ import { resizeImage, type ImageEncodeOptions, type ThumbnailSizeDimension } fro import type { Dimensions } from '../types'; import type { Buffer } from 'node:buffer'; +export interface VideoToolchainStatus { + available: boolean; + missing: string[]; + installHint: string; +} + +/** + * Checks whether the external ffmpeg tools required for video thumbnails are available. + * @returns Availability, missing executable names, and a platform-specific install hint + */ +export function checkVideoToolchain(): VideoToolchainStatus { + const requiredTools = ['ffmpeg', 'ffprobe']; + const missing = requiredTools.filter((tool) => { + const result = spawnSync(tool, ['-version'], { stdio: 'ignore' }); + return Boolean(result.error) || result.status !== 0; + }); + + return { + available: missing.length === 0, + missing, + installHint: getFfmpegInstallHint(), + }; +} + +function getFfmpegInstallHint(): string { + switch (process.platform) { + case 'darwin': { + return 'Install ffmpeg with: brew install ffmpeg'; + } + case 'win32': { + return 'Install ffmpeg with: winget install Gyan.FFmpeg'; + } + default: { + return 'Install ffmpeg with your package manager, for example: sudo apt install ffmpeg'; + } + } +} + /** * Gets video dimensions using ffprobe * @param filePath - Path to the video file @@ -44,6 +83,7 @@ export async function getVideoDimensions(filePath: string): Promise * @param size - Target size for thumbnail * @param sizeDimension - How to apply size: 'auto' (longer edge), 'width', or 'height' * @param verbose - Whether to enable verbose ffmpeg output + * @param onStderr - Optional handler for ffmpeg stderr output when verbose * @param encodeOptions - Encoding options (format, quality, effort) * @returns Promise resolving to thumbnail dimensions */ @@ -55,6 +95,7 @@ export async function createVideoThumbnails( size: number, sizeDimension: ThumbnailSizeDimension = 'auto', verbose: boolean = false, + onStderr?: (message: string) => void, encodeOptions: ImageEncodeOptions = {}, ): Promise { // Calculate dimensions maintaining aspect ratio based on sizeDimension @@ -99,8 +140,9 @@ export async function createVideoThumbnails( ]); ffmpeg.stderr.on('data', (data: Buffer) => { - // FFmpeg writes normal output to stderr, so we don't treat this as an error - console.log(`ffmpeg: ${data.toString()}`); + if (verbose) { + onStderr?.(data.toString()); + } }); ffmpeg.on('close', async (code: number) => { diff --git a/gallery/tests/build.test.ts b/gallery/tests/build.test.ts new file mode 100644 index 0000000..a67539a --- /dev/null +++ b/gallery/tests/build.test.ts @@ -0,0 +1,196 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +jest.mock('node:child_process', () => ({ + spawnSync: jest.fn(), +})); + +import { LogLevels } from 'consola'; + +import { build } from '../src/modules/build'; + +import type { BuildOptions } from '../src/modules/build/types'; +import type { ConsolaInstance } from 'consola'; + +const spawnSyncMock = spawnSync as jest.MockedFunction; + +function createMockUI(): ConsolaInstance { + return { + info: jest.fn(), + start: jest.fn(), + success: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + box: jest.fn(), + prompt: jest.fn(), + level: LogLevels.info, + } as unknown as ConsolaInstance; +} + +function createThemeStub(rootDir: string): string { + const themeDir = path.join(rootDir, 'theme'); + const astroDir = path.join(themeDir, 'node_modules', 'astro'); + mkdirSync(themeDir, { recursive: true }); + mkdirSync(astroDir, { recursive: true }); + writeFileSync( + path.join(themeDir, 'package.json'), + JSON.stringify({ name: 'test-theme', version: '1.0.0', type: 'module' }, null, 2), + ); + writeFileSync(path.join(astroDir, 'package.json'), JSON.stringify({ name: 'astro', bin: { astro: 'astro.js' } }, null, 2)); + writeFileSync(path.join(astroDir, 'astro.js'), ''); + + return themeDir; +} + +function createGallery(rootDir: string, overrides: Record = {}): void { + mkdirSync(path.join(rootDir, 'gallery'), { recursive: true }); + writeFileSync(path.join(rootDir, 'photo.jpg'), 'photo'); + + const galleryData = { + title: 'Test Gallery', + description: 'A test gallery', + headerImage: 'photo.jpg', + metadata: { image: '/gallery/images/existing-card.jpg' }, + sections: [ + { + images: [ + { + type: 'image', + filename: 'photo.jpg', + width: 100, + height: 100, + }, + ], + }, + ], + subGalleries: { title: 'Sub Galleries', galleries: [] }, + ...overrides, + }; + + writeFileSync(path.join(rootDir, 'gallery', 'gallery.json'), JSON.stringify(galleryData, null, 2)); +} + +function buildOptions(rootDir: string, themeDir: string, overrides: Partial = {}): BuildOptions { + return { + gallery: rootDir, + recursive: false, + baseUrl: undefined, + thumbsBaseUrl: undefined, + scan: false, + prune: false, + thumbnails: false, + yes: false, + theme: themeDir, + ...overrides, + }; +} + +function mockSuccessfulAstroBuild(): void { + spawnSyncMock.mockImplementation((_command, _args, options) => { + const outputDir = (options as { env?: Record }).env?.GALLERY_OUTPUT_DIR; + if (!outputDir) { + throw new Error('Missing GALLERY_OUTPUT_DIR'); + } + + const buildDir = path.join(outputDir, '_build'); + mkdirSync(buildDir, { recursive: true }); + writeFileSync(path.join(buildDir, 'index.html'), 'built'); + + return { status: 0, signal: null, stdout: '', stderr: '' } as ReturnType; + }); +} + +describe('build module', () => { + let tempDir: string; + let originalIsTTY: boolean | undefined; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'spg-build-')); + originalIsTTY = process.stdout.isTTY; + spawnSyncMock.mockReset(); + mockSuccessfulAstroBuild(); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true }); + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('prints the tail of Astro output when the build fails', async () => { + const themeDir = createThemeStub(tempDir); + createGallery(tempDir); + const ui = createMockUI(); + + spawnSyncMock.mockReturnValue({ + status: 1, + signal: null, + stdout: Array.from({ length: 40 }, (_, index) => `stdout ${index}`).join('\n'), + stderr: 'template exploded', + } as ReturnType); + + await expect(build(buildOptions(tempDir, themeDir), ui)).rejects.toThrow('Astro build failed'); + + expect(ui.error).toHaveBeenCalledWith(expect.stringContaining('Astro build output')); + expect(ui.error).toHaveBeenCalledWith(expect.stringContaining('template exploded')); + expect(ui.error).toHaveBeenCalledWith(expect.stringContaining(`Build failed for ${tempDir}`)); + }); + + test('fails fast instead of prompting to copy photos in non-interactive mode', async () => { + const themeDir = createThemeStub(tempDir); + const photosDir = path.join(tempDir, 'photos'); + mkdirSync(photosDir); + writeFileSync(path.join(photosDir, 'photo.jpg'), 'photo'); + createGallery(tempDir, { mediaBasePath: photosDir }); + const ui = createMockUI(); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + + await expect(build(buildOptions(tempDir, themeDir), ui)).rejects.toThrow('Use --yes'); + + expect(ui.prompt).not.toHaveBeenCalled(); + expect(spawnSyncMock).not.toHaveBeenCalled(); + }); + + test('prunes missing scanned files and deletes their thumbnails', async () => { + const themeDir = createThemeStub(tempDir); + const imagesDir = path.join(tempDir, 'gallery', 'images'); + mkdirSync(imagesDir, { recursive: true }); + writeFileSync(path.join(imagesDir, 'missing.avif'), 'thumb'); + writeFileSync(path.join(imagesDir, 'missing@2x.avif'), 'thumb'); + createGallery(tempDir, { + sections: [ + { + images: [ + { type: 'image', filename: 'photo.jpg', width: 100, height: 100 }, + { + type: 'image', + filename: 'missing.jpg', + width: 100, + height: 100, + thumbnail: { + path: 'missing.avif', + pathRetina: 'missing@2x.avif', + width: 100, + height: 100, + }, + }, + ], + }, + ], + }); + + await build(buildOptions(tempDir, themeDir, { scan: true, prune: true }), createMockUI()); + + const galleryData = JSON.parse(readFileSync(path.join(tempDir, 'gallery', 'gallery.json'), 'utf8')); + expect(galleryData.sections[0].images.map((image: { filename: string }) => image.filename)).toEqual(['photo.jpg']); + expect(existsSync(path.join(imagesDir, 'missing.avif'))).toBe(false); + expect(existsSync(path.join(imagesDir, 'missing@2x.avif'))).toBe(false); + expect(existsSync(path.join(tempDir, 'index.html'))).toBe(true); + }); +}); diff --git a/gallery/tests/gallery.test.ts b/gallery/tests/gallery.test.ts index 22231ee..5d315ac 100644 --- a/gallery/tests/gallery.test.ts +++ b/gallery/tests/gallery.test.ts @@ -563,8 +563,8 @@ describe('Separate gallery directory', () => { runCliCommand(`${tsxPath} ${cliPath} thumbnails --gallery ${separateGalleryPath}`); } - // Run build command (automatically answer 'y' to photo copy confirmation) - runCliCommand(`echo "y" | ${tsxPath} ${cliPath} build --gallery ${separateGalleryPath}`); + // Run build command with explicit non-interactive confirmation for copying photos + runCliCommand(`${tsxPath} ${cliPath} build --gallery ${separateGalleryPath} --yes`); // Validate build output using separate gallery helper validateSeparateBuildOutput(separateGalleryPath, galleryPath); diff --git a/gallery/tests/migration.test.ts b/gallery/tests/migration.test.ts index d4681e5..4fc2b43 100644 --- a/gallery/tests/migration.test.ts +++ b/gallery/tests/migration.test.ts @@ -298,6 +298,75 @@ describe('Gallery JSON Migration', () => { expect(galleryData.sections[0].images[0]).toHaveProperty('filename'); }); + test('should preserve unknown fields when parsing gallery.json', () => { + const testPath = path.resolve(migrationTestPath, 'unknown-fields'); + const galleryPath = path.resolve(testPath, 'gallery'); + + mkdirSync(galleryPath, { recursive: true }); + const galleryJsonPath = path.resolve(galleryPath, 'gallery.json'); + writeFileSync( + galleryJsonPath, + JSON.stringify( + { + title: 'Test Gallery', + description: 'Test description', + headerImage: 'img_1.jpg', + metadata: { + customMeta: 'keep me', + }, + sections: [ + { + customSectionField: 'keep section', + images: [ + { + type: 'image', + filename: 'img_1.jpg', + width: 1920, + height: 1080, + customMediaField: 'keep media', + thumbnail: { + path: 'img_1.avif', + pathRetina: 'img_1@2x.avif', + width: 300, + height: 200, + customThumbnailField: 'keep thumbnail', + }, + }, + ], + }, + ], + subGalleries: { + title: 'Sub Galleries', + galleries: [], + }, + futureRootField: { + nested: true, + }, + }, + null, + 2, + ), + ); + + const galleryData = parseGalleryJson(galleryJsonPath, createMockUI()) as unknown as { + metadata: { customMeta: string }; + sections: Array<{ + customSectionField: string; + images: Array<{ + customMediaField: string; + thumbnail: { customThumbnailField: string }; + }>; + }>; + futureRootField: { nested: boolean }; + }; + + expect(galleryData.futureRootField).toEqual({ nested: true }); + expect(galleryData.metadata.customMeta).toBe('keep me'); + expect(galleryData.sections[0].customSectionField).toBe('keep section'); + expect(galleryData.sections[0].images[0].customMediaField).toBe('keep media'); + expect(galleryData.sections[0].images[0].thumbnail.customThumbnailField).toBe('keep thumbnail'); + }); + test('should throw error when gallery.json file not found', () => { const mockUI = createMockUI(); const nonExistentPath = path.resolve(migrationTestPath, 'non-existent', 'gallery.json'); @@ -561,6 +630,39 @@ describe('Gallery JSON Migration', () => { expect(migratedData.mediaBasePath).toBeUndefined(); }); + test('should migrate empty deprecated galleries without crashing', () => { + const testPath = path.resolve(migrationTestPath, 'empty-deprecated'); + const galleryPath = path.resolve(testPath, 'gallery'); + + mkdirSync(galleryPath, { recursive: true }); + + const deprecatedData: GalleryDataDeprecated = { + title: 'Empty Gallery', + description: 'No media yet', + headerImage: '../header.jpg', + metadata: {}, + sections: [ + { + images: [], + }, + ], + subGalleries: { + title: 'Sub Galleries', + galleries: [], + }, + }; + + const mockUI = createMockUI(); + const galleryJsonPath = path.resolve(galleryPath, 'gallery.json'); + writeFileSync(galleryJsonPath, JSON.stringify(deprecatedData, null, 2)); + + const migratedData = migrateGalleryJson(deprecatedData, galleryJsonPath, mockUI); + + expect(migratedData.mediaBasePath).toBeUndefined(); + expect(migratedData.sections[0].images).toEqual([]); + expect(existsSync(`${galleryJsonPath}.old`)).toBe(true); + }); + test('should preserve all other fields during migration', () => { const testPath = path.resolve(migrationTestPath, 'preserve-fields'); const galleryPath = path.resolve(testPath, 'gallery'); diff --git a/gallery/tests/scan.test.ts b/gallery/tests/scan.test.ts index d61de62..5ec1ba5 100644 --- a/gallery/tests/scan.test.ts +++ b/gallery/tests/scan.test.ts @@ -2,7 +2,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { scanDirectory } from '../src/modules/init'; +import { init, scanDirectory } from '../src/modules/init'; +import { capitalizeTitle } from '../src/modules/init/utils'; import type { ConsolaInstance } from 'consola'; @@ -72,4 +73,55 @@ describe('scanDirectory', () => { expect(mediaFiles.map((file) => file.filename)).toEqual(['photo.jpg']); }); + + test('should ignore generated, dependency, and dot directories', async () => { + for (const dirName of ['gallery', 'node_modules', '.cache', '.git', 'album']) { + fs.mkdirSync(path.join(tempDir, dirName)); + } + + const { subGalleryDirectories } = await scanDirectory(tempDir, createMockUI()); + + expect(subGalleryDirectories).toEqual([path.join(tempDir, 'album')]); + }); +}); + +describe('init', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spg-init-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should report zero galleries for an empty directory', async () => { + const ui = { + ...createMockUI(), + box: jest.fn(), + }; + + const result = await init( + { + photos: tempDir, + recursive: false, + default: true, + force: false, + }, + ui as unknown as ConsolaInstance, + ); + + expect(result).toEqual({ processedMediaCount: 0, processedGalleryCount: 0 }); + expect(ui.box).toHaveBeenCalledWith('Created 0 galleries with 0 media files'); + expect(fs.existsSync(path.join(tempDir, 'gallery', 'gallery.json'))).toBe(false); + }); +}); + +describe('capitalizeTitle', () => { + test('should replace all dashes and underscores', () => { + expect(capitalizeTitle('my-summer_trip-2026')).toBe('My Summer Trip 2026'); + }); }); diff --git a/gallery/tests/thumbnails-progress.test.ts b/gallery/tests/thumbnails-progress.test.ts index c83cef6..157996a 100644 --- a/gallery/tests/thumbnails-progress.test.ts +++ b/gallery/tests/thumbnails-progress.test.ts @@ -45,6 +45,21 @@ function setupGallery(rootDir: string, filenames: string[]): void { writeFileSync(path.join(rootDir, 'gallery', 'gallery.json'), JSON.stringify(galleryData, null, 2)); } +function setupGalleryJson(rootDir: string, filenames: string[]): void { + mkdirSync(path.join(rootDir, 'gallery'), { recursive: true }); + + const galleryData = { + title: 'Test', + description: 'Test gallery', + headerImage: filenames[0], + metadata: {}, + sections: [{ images: filenames.map((filename) => ({ type: 'image', filename, width: 0, height: 0 })) }], + subGalleries: { title: 'Sub', galleries: [] }, + }; + + writeFileSync(path.join(rootDir, 'gallery', 'gallery.json'), JSON.stringify(galleryData, null, 2)); +} + function readGalleryJson(rootDir: string): { sections: { images: Record[] }[] } { return JSON.parse(readFileSync(path.join(rootDir, 'gallery', 'gallery.json'), 'utf8')); } @@ -101,4 +116,20 @@ describe('processGalleryThumbnails progress persistence', () => { expect(secondRun).toEqual(firstRun); expect(secondTimestamps).toEqual(firstTimestamps); }); + + test('reports failed media files and exits non-zero', async () => { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = mkdtempSync(path.join(os.tmpdir(), 'spg-progress-')); + copyFileSync(path.join(fixtureImages, 'img_1.jpg'), path.join(tempDir, 'img_1.jpg')); + setupGalleryJson(tempDir, ['img_1.jpg', 'missing.jpg']); + const ui = createMockUI(); + + await expect(processGalleryThumbnails(tempDir, ui)).rejects.toThrow('Failed to process 1 media file'); + + expect(ui.warn).toHaveBeenCalledWith(expect.stringContaining('missing.jpg')); + + const data = readGalleryJson(tempDir); + expect(data.sections[0].images[0].thumbnail).toBeDefined(); + expect(data.sections[0].images[1].thumbnail).toBeUndefined(); + }); }); diff --git a/themes/modern/package.json b/themes/modern/package.json index 90c9218..7c1e0a3 100644 --- a/themes/modern/package.json +++ b/themes/modern/package.json @@ -9,6 +9,9 @@ "url": "https://github.com/SimplePhotoGallery/core" }, "homepage": "https://simple.photo", + "engines": { + "node": ">=20.0.0" + }, "files": [ "public", "src", diff --git a/yarn.lock b/yarn.lock index 20e39ff..54006f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2580,6 +2580,15 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:6": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: "npm:4" + checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261 + languageName: node + linkType: hard + "agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": version: 7.1.4 resolution: "agent-base@npm:7.1.4" @@ -2973,13 +2982,14 @@ __metadata: linkType: hard "axios@npm:^1.12.2": - version: 1.12.2 - resolution: "axios@npm:1.12.2" + version: 1.17.0 + resolution: "axios@npm:1.17.0" dependencies: - follow-redirects: "npm:^1.15.6" - form-data: "npm:^4.0.4" - proxy-from-env: "npm:^1.1.0" - checksum: 10c0/80b063e318cf05cd33a4d991cea0162f3573481946f9129efb7766f38fde4c061c34f41a93a9f9521f02b7c9565ccbc197c099b0186543ac84a24580017adfed + follow-redirects: "npm:^1.16.0" + form-data: "npm:^4.0.5" + https-proxy-agent: "npm:^5.0.1" + proxy-from-env: "npm:^2.1.0" + checksum: 10c0/c4fa19ff3a3a63bde48beec03ad816b133b9a6385cccffffe172577ab18c6a70e299280d57f12c80c867fe25df41f92cb91d3a8258708a6d2be3e9e085f92650 languageName: node linkType: hard @@ -4863,13 +4873,13 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.6": - version: 1.15.11 - resolution: "follow-redirects@npm:1.15.11" +"follow-redirects@npm:^1.16.0": + version: 1.16.0 + resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: debug: optional: true - checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343 + checksum: 10c0/a1e2900163e6f1b4d1ed5c221b607f41decbab65534c63fe7e287e40a5d552a6496e7d9d7d976fa4ba77b4c51c11e5e9f683f10b43011ea11e442ff128d0e181 languageName: node linkType: hard @@ -4919,16 +4929,16 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.4": - version: 4.0.4 - resolution: "form-data@npm:4.0.4" +"form-data@npm:^4.0.5": + version: 4.0.6 + resolution: "form-data@npm:4.0.6" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.12" - checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695 + hasown: "npm:^2.0.4" + mime-types: "npm:^2.1.35" + checksum: 10c0/43947a77bf0ff45c6ceed789778982d47a3f3e720a74b71721174ebf3310a5f1a8be1d6b38a3ee3688e8a18a2c4273073ec0844cd37efda3eaf46d41c9c318ff languageName: node linkType: hard @@ -5324,6 +5334,15 @@ __metadata: languageName: node linkType: hard +"hasown@npm:^2.0.4": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/2d8de939e270b70618f8cebb69746620db10617dbb495bc66ddad326955ea24d3ca4af133aff3eb7c1853e0218f867bc2b050ec26fe02e3aea58f880ffc5e506 + languageName: node + linkType: hard + "hast-util-from-html@npm:^2.0.0, hast-util-from-html@npm:^2.0.3": version: 2.0.3 resolution: "hast-util-from-html@npm:2.0.3" @@ -5499,6 +5518,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^5.0.1": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1 + languageName: node + linkType: hard + "https-proxy-agent@npm:^7.0.1": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" @@ -7425,7 +7454,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12": +"mime-types@npm:^2.1.35": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -8358,10 +8387,10 @@ __metadata: languageName: node linkType: hard -"proxy-from-env@npm:^1.1.0": - version: 1.1.0 - resolution: "proxy-from-env@npm:1.1.0" - checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b +"proxy-from-env@npm:^2.1.0": + version: 2.1.0 + resolution: "proxy-from-env@npm:2.1.0" + checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 languageName: node linkType: hard