simple-photo-gallery 2.0.8 → 2.0.10-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,73 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import process2 from 'process';
2
+ import process3, { stdout } from 'process';
3
3
  import { Command } from 'commander';
4
4
  import { LogLevels, createConsola } from 'consola';
5
5
  import { execSync, spawn } from 'child_process';
6
- import fs6, { promises } from 'fs';
7
- import path4 from 'path';
8
- import { z } from 'zod';
6
+ import fs8, { promises } from 'fs';
7
+ import path7 from 'path';
9
8
  import { Buffer } from 'buffer';
10
9
  import sharp2 from 'sharp';
11
- import ExifReader from 'exifreader';
12
10
  import { encode } from 'blurhash';
11
+ import ExifReader from 'exifreader';
12
+ import { z } from 'zod';
13
13
  import ffprobe from 'node-ffprobe';
14
+ import os from 'os';
15
+ import Conf from 'conf';
14
16
  import axios from 'axios';
15
17
  import { compareSemVer, parseSemVer } from 'semver-parser';
16
18
 
17
- var ThumbnailSchema = z.object({
18
- path: z.string(),
19
- pathRetina: z.string(),
20
- width: z.number(),
21
- height: z.number(),
22
- blurHash: z.string().optional()
23
- });
24
- var MediaFileSchema = z.object({
25
- type: z.enum(["image", "video"]),
26
- path: z.string(),
27
- alt: z.string().optional(),
28
- width: z.number(),
29
- height: z.number(),
30
- thumbnail: ThumbnailSchema.optional(),
31
- lastMediaTimestamp: z.string().optional()
32
- });
33
- var GallerySectionSchema = z.object({
34
- title: z.string().optional(),
35
- description: z.string().optional(),
36
- images: z.array(MediaFileSchema)
37
- });
38
- var SubGallerySchema = z.object({
39
- title: z.string(),
40
- headerImage: z.string(),
41
- path: z.string()
42
- });
43
- var GalleryMetadataSchema = z.object({
44
- image: z.string().optional(),
45
- imageWidth: z.number().optional(),
46
- imageHeight: z.number().optional(),
47
- ogUrl: z.string().optional(),
48
- ogType: z.string().optional(),
49
- ogSiteName: z.string().optional(),
50
- twitterSite: z.string().optional(),
51
- twitterCreator: z.string().optional(),
52
- author: z.string().optional(),
53
- keywords: z.string().optional(),
54
- canonicalUrl: z.string().optional(),
55
- language: z.string().optional(),
56
- robots: z.string().optional()
57
- });
58
- var GalleryDataSchema = z.object({
59
- title: z.string(),
60
- description: z.string(),
61
- url: z.string().optional(),
62
- headerImage: z.string(),
63
- thumbnailSize: z.number().optional(),
64
- metadata: GalleryMetadataSchema,
65
- galleryOutputPath: z.string().optional(),
66
- mediaBaseUrl: z.string().optional(),
67
- sections: z.array(GallerySectionSchema),
68
- subGalleries: z.object({ title: z.string(), galleries: z.array(SubGallerySchema) })
69
- });
70
-
71
19
  // src/config/index.ts
72
20
  var DEFAULT_THUMBNAIL_SIZE = 300;
73
21
  var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".tiff", ".tif", ".svg", ".avif"]);
@@ -135,13 +83,22 @@ async function createImageThumbnails(image, metadata, outputPath, outputPathReti
135
83
  return { width, height };
136
84
  }
137
85
 
86
+ // src/utils/blurhash.ts
87
+ async function generateBlurHash(imagePath, componentX = 4, componentY = 3) {
88
+ const image = await loadImage(imagePath);
89
+ const { data, info } = await image.resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
90
+ const pixels = new Uint8ClampedArray(data.buffer);
91
+ return encode(pixels, info.width, info.height, componentX, componentY);
92
+ }
93
+
138
94
  // src/modules/build/utils/index.ts
139
- path4.dirname(new URL(import.meta.url).pathname);
95
+ path7.dirname(new URL(import.meta.url).pathname);
140
96
  async function createGallerySocialMediaCardImage(headerPhotoPath, title, ouputPath, ui) {
141
97
  ui.start(`Creating social media card image`);
142
- if (fs6.existsSync(ouputPath)) {
98
+ const headerBasename = path7.basename(headerPhotoPath, path7.extname(headerPhotoPath));
99
+ if (fs8.existsSync(ouputPath)) {
143
100
  ui.success(`Social media card image already exists`);
144
- return;
101
+ return headerBasename;
145
102
  }
146
103
  const image = await loadImage(headerPhotoPath);
147
104
  const resizedImageBuffer = await image.resize(1200, 631, { fit: "cover" }).jpeg({ quality: 90 }).toBuffer();
@@ -161,76 +118,115 @@ async function createGallerySocialMediaCardImage(headerPhotoPath, title, ouputPa
161
118
  const finalImageBuffer = await sharp2(resizedImageBuffer).composite([{ input: Buffer.from(svgText), top: 0, left: 0 }]).jpeg({ quality: 90 }).toBuffer();
162
119
  await sharp2(finalImageBuffer).toFile(outputPath);
163
120
  ui.success(`Created social media card image successfully`);
121
+ return headerBasename;
164
122
  }
165
123
  async function createOptimizedHeaderImage(headerPhotoPath, outputFolder, ui) {
166
124
  ui.start(`Creating optimized header images`);
167
125
  const image = await loadImage(headerPhotoPath);
126
+ const headerBasename = path7.basename(headerPhotoPath, path7.extname(headerPhotoPath));
127
+ const generatedFiles = [];
128
+ ui.debug("Generating blurhash for header image");
129
+ const blurHash = await generateBlurHash(headerPhotoPath);
168
130
  const landscapeYFactor = 3 / 4;
169
131
  for (const width of HEADER_IMAGE_LANDSCAPE_WIDTHS) {
170
132
  ui.debug(`Creating landscape header image ${width}`);
171
- if (fs6.existsSync(path4.join(outputFolder, `header_landscape_${width}.avif`))) {
133
+ const avifFilename = `${headerBasename}_landscape_${width}.avif`;
134
+ const jpgFilename = `${headerBasename}_landscape_${width}.jpg`;
135
+ if (fs8.existsSync(path7.join(outputFolder, avifFilename))) {
172
136
  ui.debug(`Landscape header image ${width} AVIF already exists`);
173
137
  } else {
174
138
  await cropAndResizeImage(
175
139
  image.clone(),
176
- path4.join(outputFolder, `header_landscape_${width}.avif`),
140
+ path7.join(outputFolder, avifFilename),
177
141
  width,
178
142
  width * landscapeYFactor,
179
143
  "avif"
180
144
  );
181
145
  }
182
- if (fs6.existsSync(path4.join(outputFolder, `header_landscape_${width}.jpg`))) {
146
+ generatedFiles.push(avifFilename);
147
+ if (fs8.existsSync(path7.join(outputFolder, jpgFilename))) {
183
148
  ui.debug(`Landscape header image ${width} JPG already exists`);
184
149
  } else {
185
- await cropAndResizeImage(
186
- image.clone(),
187
- path4.join(outputFolder, `header_landscape_${width}.jpg`),
188
- width,
189
- width * landscapeYFactor,
190
- "jpg"
191
- );
150
+ await cropAndResizeImage(image.clone(), path7.join(outputFolder, jpgFilename), width, width * landscapeYFactor, "jpg");
192
151
  }
152
+ generatedFiles.push(jpgFilename);
193
153
  }
194
154
  const portraitYFactor = 4 / 3;
195
155
  for (const width of HEADER_IMAGE_PORTRAIT_WIDTHS) {
196
156
  ui.debug(`Creating portrait header image ${width}`);
197
- if (fs6.existsSync(path4.join(outputFolder, `header_portrait_${width}.avif`))) {
157
+ const avifFilename = `${headerBasename}_portrait_${width}.avif`;
158
+ const jpgFilename = `${headerBasename}_portrait_${width}.jpg`;
159
+ if (fs8.existsSync(path7.join(outputFolder, avifFilename))) {
198
160
  ui.debug(`Portrait header image ${width} AVIF already exists`);
199
161
  } else {
200
- await cropAndResizeImage(
201
- image.clone(),
202
- path4.join(outputFolder, `header_portrait_${width}.avif`),
203
- width,
204
- width * portraitYFactor,
205
- "avif"
206
- );
162
+ await cropAndResizeImage(image.clone(), path7.join(outputFolder, avifFilename), width, width * portraitYFactor, "avif");
207
163
  }
208
- if (fs6.existsSync(path4.join(outputFolder, `header_portrait_${width}.jpg`))) {
164
+ generatedFiles.push(avifFilename);
165
+ if (fs8.existsSync(path7.join(outputFolder, jpgFilename))) {
209
166
  ui.debug(`Portrait header image ${width} JPG already exists`);
210
167
  } else {
211
- await cropAndResizeImage(
212
- image.clone(),
213
- path4.join(outputFolder, `header_portrait_${width}.jpg`),
214
- width,
215
- width * portraitYFactor,
216
- "jpg"
217
- );
168
+ await cropAndResizeImage(image.clone(), path7.join(outputFolder, jpgFilename), width, width * portraitYFactor, "jpg");
218
169
  }
170
+ generatedFiles.push(jpgFilename);
219
171
  }
220
172
  ui.success(`Created optimized header image successfully`);
173
+ return { headerBasename, generatedFiles, blurHash };
174
+ }
175
+ function hasOldHeaderImages(outputFolder, currentHeaderBasename) {
176
+ if (!fs8.existsSync(outputFolder)) {
177
+ return false;
178
+ }
179
+ const files = fs8.readdirSync(outputFolder);
180
+ for (const file of files) {
181
+ const landscapeMatch = file.match(/^(.+)_landscape_\d+\.(avif|jpg)$/);
182
+ const portraitMatch = file.match(/^(.+)_portrait_\d+\.(avif|jpg)$/);
183
+ if (landscapeMatch && landscapeMatch[1] !== currentHeaderBasename || portraitMatch && portraitMatch[1] !== currentHeaderBasename) {
184
+ return true;
185
+ }
186
+ }
187
+ return false;
188
+ }
189
+ function cleanupOldHeaderImages(outputFolder, currentHeaderBasename, ui) {
190
+ ui.start(`Cleaning up old header images`);
191
+ if (!fs8.existsSync(outputFolder)) {
192
+ ui.debug(`Output folder ${outputFolder} does not exist, skipping cleanup`);
193
+ return;
194
+ }
195
+ const files = fs8.readdirSync(outputFolder);
196
+ let deletedCount = 0;
197
+ for (const file of files) {
198
+ const landscapeMatch = file.match(/^(.+)_landscape_\d+\.(avif|jpg)$/);
199
+ const portraitMatch = file.match(/^(.+)_portrait_\d+\.(avif|jpg)$/);
200
+ if (landscapeMatch && landscapeMatch[1] !== currentHeaderBasename) {
201
+ const filePath = path7.join(outputFolder, file);
202
+ ui.debug(`Deleting old landscape header image: ${file}`);
203
+ fs8.unlinkSync(filePath);
204
+ deletedCount++;
205
+ } else if (portraitMatch && portraitMatch[1] !== currentHeaderBasename) {
206
+ const filePath = path7.join(outputFolder, file);
207
+ ui.debug(`Deleting old portrait header image: ${file}`);
208
+ fs8.unlinkSync(filePath);
209
+ deletedCount++;
210
+ }
211
+ }
212
+ if (deletedCount > 0) {
213
+ ui.success(`Deleted ${deletedCount} old header image(s)`);
214
+ } else {
215
+ ui.debug(`No old header images to clean up`);
216
+ }
221
217
  }
222
218
  function findGalleries(basePath, recursive) {
223
219
  const galleryDirs = [];
224
- const galleryJsonPath = path4.join(basePath, "gallery", "gallery.json");
225
- if (fs6.existsSync(galleryJsonPath)) {
220
+ const galleryJsonPath = path7.join(basePath, "gallery", "gallery.json");
221
+ if (fs8.existsSync(galleryJsonPath)) {
226
222
  galleryDirs.push(basePath);
227
223
  }
228
224
  if (recursive) {
229
225
  try {
230
- const entries = fs6.readdirSync(basePath, { withFileTypes: true });
226
+ const entries = fs8.readdirSync(basePath, { withFileTypes: true });
231
227
  for (const entry of entries) {
232
228
  if (entry.isDirectory() && entry.name !== "gallery") {
233
- const subPath = path4.join(basePath, entry.name);
229
+ const subPath = path7.join(basePath, entry.name);
234
230
  const subResults = findGalleries(subPath, recursive);
235
231
  galleryDirs.push(...subResults);
236
232
  }
@@ -252,16 +248,341 @@ function handleFileProcessingError(error, filename, ui) {
252
248
  }
253
249
  ui.debug(error);
254
250
  }
251
+ function parseTelemetryOption(value) {
252
+ if (value !== "0" && value !== "1") {
253
+ throw new Error("Telemetry option must be either 0 or 1.");
254
+ }
255
+ return value;
256
+ }
257
+ var ThumbnailSchema = z.object({
258
+ path: z.string(),
259
+ pathRetina: z.string(),
260
+ width: z.number(),
261
+ height: z.number(),
262
+ blurHash: z.string().optional()
263
+ });
264
+ var MediaFileSchema = z.object({
265
+ type: z.enum(["image", "video"]),
266
+ filename: z.string(),
267
+ alt: z.string().optional(),
268
+ width: z.number(),
269
+ height: z.number(),
270
+ thumbnail: ThumbnailSchema.optional(),
271
+ lastMediaTimestamp: z.string().optional()
272
+ });
273
+ var MediaFileDeprecatedSchema = z.object({
274
+ type: z.enum(["image", "video"]),
275
+ path: z.string(),
276
+ alt: z.string().optional(),
277
+ width: z.number(),
278
+ height: z.number(),
279
+ thumbnail: ThumbnailSchema.optional(),
280
+ lastMediaTimestamp: z.string().optional()
281
+ });
282
+ var GallerySectionSchema = z.object({
283
+ title: z.string().optional(),
284
+ description: z.string().optional(),
285
+ images: z.array(MediaFileSchema)
286
+ });
287
+ var GallerySectionDeprecatedSchema = z.object({
288
+ title: z.string().optional(),
289
+ description: z.string().optional(),
290
+ images: z.array(MediaFileDeprecatedSchema)
291
+ });
292
+ var SubGallerySchema = z.object({
293
+ title: z.string(),
294
+ headerImage: z.string(),
295
+ path: z.string()
296
+ });
297
+ var GalleryMetadataSchema = z.object({
298
+ image: z.string().optional(),
299
+ imageWidth: z.number().optional(),
300
+ imageHeight: z.number().optional(),
301
+ ogUrl: z.string().optional(),
302
+ ogType: z.string().optional(),
303
+ ogSiteName: z.string().optional(),
304
+ twitterSite: z.string().optional(),
305
+ twitterCreator: z.string().optional(),
306
+ author: z.string().optional(),
307
+ keywords: z.string().optional(),
308
+ canonicalUrl: z.string().optional(),
309
+ language: z.string().optional(),
310
+ robots: z.string().optional()
311
+ });
312
+ var GalleryDataSchema = z.object({
313
+ title: z.string(),
314
+ description: z.string(),
315
+ mediaBasePath: z.string().optional(),
316
+ url: z.string().optional(),
317
+ headerImage: z.string(),
318
+ headerImageBlurHash: z.string().optional(),
319
+ thumbnailSize: z.number().optional(),
320
+ metadata: GalleryMetadataSchema,
321
+ mediaBaseUrl: z.string().optional(),
322
+ analyticsScript: z.string().optional(),
323
+ sections: z.array(GallerySectionSchema),
324
+ subGalleries: z.object({ title: z.string(), galleries: z.array(SubGallerySchema) })
325
+ });
326
+ var GalleryDataDeprecatedSchema = z.object({
327
+ title: z.string(),
328
+ description: z.string(),
329
+ url: z.string().optional(),
330
+ headerImage: z.string(),
331
+ thumbnailSize: z.number().optional(),
332
+ metadata: GalleryMetadataSchema,
333
+ mediaBaseUrl: z.string().optional(),
334
+ analyticsScript: z.string().optional(),
335
+ sections: z.array(GallerySectionDeprecatedSchema),
336
+ subGalleries: z.object({ title: z.string(), galleries: z.array(SubGallerySchema) })
337
+ });
338
+
339
+ // src/utils/gallery.ts
340
+ function parseGalleryJson(galleryJsonPath, ui) {
341
+ let galleryContent;
342
+ try {
343
+ galleryContent = fs8.readFileSync(galleryJsonPath, "utf8");
344
+ } catch (error) {
345
+ ui.error("Error parsing gallery.json: file not found");
346
+ throw error;
347
+ }
348
+ let galleryJSON;
349
+ try {
350
+ galleryJSON = JSON.parse(galleryContent);
351
+ } catch (error) {
352
+ ui.error("Error parsing gallery.json: invalid JSON");
353
+ throw error;
354
+ }
355
+ try {
356
+ return GalleryDataSchema.parse(galleryJSON);
357
+ } catch (error) {
358
+ try {
359
+ const deprecatedGalleryData = GalleryDataDeprecatedSchema.parse(galleryJSON);
360
+ return migrateGalleryJson(deprecatedGalleryData, galleryJsonPath, ui);
361
+ } catch {
362
+ ui.error("Error parsing gallery.json: invalid gallery data");
363
+ throw error;
364
+ }
365
+ }
366
+ }
367
+ function migrateGalleryJson(deprecatedGalleryData, galleryJsonPath, ui) {
368
+ ui.start("Old gallery.json format detected. Migrating gallery.json to the new data format.");
369
+ let mediaBasePath;
370
+ const imagePath = deprecatedGalleryData.sections[0].images[0].path;
371
+ if (imagePath && imagePath !== path7.join("..", path7.basename(imagePath))) {
372
+ mediaBasePath = path7.resolve(path7.join(path7.dirname(galleryJsonPath)), path7.dirname(imagePath));
373
+ }
374
+ const sections = deprecatedGalleryData.sections.map((section) => ({
375
+ ...section,
376
+ images: section.images.map((image) => ({
377
+ ...image,
378
+ path: void 0,
379
+ filename: path7.basename(image.path)
380
+ }))
381
+ }));
382
+ const galleryData = {
383
+ ...deprecatedGalleryData,
384
+ headerImage: path7.basename(deprecatedGalleryData.headerImage),
385
+ sections,
386
+ mediaBasePath
387
+ };
388
+ ui.debug("Backing up old gallery.json file");
389
+ fs8.copyFileSync(galleryJsonPath, `${galleryJsonPath}.old`);
390
+ ui.debug("Writing gallery data to gallery.json file");
391
+ fs8.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
392
+ ui.success("Gallery data migrated to the new data format successfully.");
393
+ return galleryData;
394
+ }
395
+ function getMediaFileType(fileName) {
396
+ const ext = path7.extname(fileName).toLowerCase();
397
+ if (IMAGE_EXTENSIONS.has(ext)) return "image";
398
+ if (VIDEO_EXTENSIONS.has(ext)) return "video";
399
+ return null;
400
+ }
401
+ function capitalizeTitle(folderName) {
402
+ return folderName.replace("-", " ").replace("_", " ").split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
403
+ }
404
+
405
+ // src/modules/init/index.ts
406
+ async function scanDirectory(dirPath, ui) {
407
+ const mediaFiles = [];
408
+ const subGalleryDirectories = [];
409
+ try {
410
+ const entries = await promises.readdir(dirPath, { withFileTypes: true });
411
+ for (const entry of entries) {
412
+ if (entry.isFile()) {
413
+ const mediaType = getMediaFileType(entry.name);
414
+ if (mediaType) {
415
+ const mediaFile = {
416
+ type: mediaType,
417
+ filename: entry.name,
418
+ width: 0,
419
+ height: 0
420
+ };
421
+ mediaFiles.push(mediaFile);
422
+ }
423
+ } else if (entry.isDirectory() && entry.name !== "gallery") {
424
+ subGalleryDirectories.push(path7.join(dirPath, entry.name));
425
+ }
426
+ }
427
+ } catch (error) {
428
+ if (error instanceof Error && error.message.includes("ENOENT")) {
429
+ ui.error(`Directory does not exist: ${dirPath}`);
430
+ } else if (error instanceof Error && error.message.includes("ENOTDIR")) {
431
+ ui.error(`Path is not a directory: ${dirPath}`);
432
+ } else {
433
+ ui.error(`Error scanning directory ${dirPath}:`, error);
434
+ }
435
+ throw error;
436
+ }
437
+ return { mediaFiles, subGalleryDirectories };
438
+ }
439
+ async function getGallerySettingsFromUser(galleryName, defaultImage, ui) {
440
+ ui.info(`Enter gallery settings for the gallery in folder "${galleryName}"`);
441
+ const title = await ui.prompt("Enter gallery title", { type: "text", default: "My Gallery", placeholder: "My Gallery" });
442
+ const description = await ui.prompt("Enter gallery description", {
443
+ type: "text",
444
+ default: "My gallery with fantastic photos.",
445
+ placeholder: "My gallery with fantastic photos."
446
+ });
447
+ const url = await ui.prompt("Enter the URL where the gallery will be hosted (important for social media image)", {
448
+ type: "text",
449
+ default: "",
450
+ placeholder: ""
451
+ });
452
+ const headerImage = await ui.prompt("Enter the name of the header image", {
453
+ type: "text",
454
+ default: defaultImage,
455
+ placeholder: defaultImage
456
+ });
457
+ return { title, description, url, headerImage };
458
+ }
459
+ async function createGalleryJson(mediaFiles, galleryJsonPath, scanPath, subGalleries = [], useDefaultSettings, ui) {
460
+ const galleryDir = path7.dirname(galleryJsonPath);
461
+ const isSameLocation = path7.relative(scanPath, path7.join(galleryDir, "..")) === "";
462
+ const mediaBasePath = isSameLocation ? void 0 : scanPath;
463
+ const relativeSubGalleries = subGalleries.map((subGallery) => ({
464
+ ...subGallery,
465
+ headerImage: subGallery.headerImage ? path7.relative(galleryDir, subGallery.headerImage) : ""
466
+ }));
467
+ let galleryData = {
468
+ title: "My Gallery",
469
+ description: "My gallery with fantastic photos.",
470
+ headerImage: mediaFiles[0]?.filename || "",
471
+ mediaBasePath,
472
+ metadata: {},
473
+ sections: [
474
+ {
475
+ images: mediaFiles
476
+ }
477
+ ],
478
+ subGalleries: {
479
+ title: "Sub Galleries",
480
+ galleries: relativeSubGalleries
481
+ }
482
+ };
483
+ if (!useDefaultSettings) {
484
+ galleryData = {
485
+ ...galleryData,
486
+ ...await getGallerySettingsFromUser(
487
+ path7.basename(path7.join(galleryDir, "..")),
488
+ path7.basename(mediaFiles[0]?.filename || ""),
489
+ ui
490
+ )
491
+ };
492
+ }
493
+ await promises.writeFile(galleryJsonPath, JSON.stringify(galleryData, null, 2));
494
+ }
495
+ async function galleryExists(outputPath) {
496
+ const galleryPath = path7.join(outputPath, "gallery");
497
+ const galleryJsonPath = path7.join(galleryPath, "gallery.json");
498
+ try {
499
+ await promises.access(galleryJsonPath);
500
+ return true;
501
+ } catch {
502
+ return false;
503
+ }
504
+ }
505
+ async function processDirectory(scanPath, outputPath, recursive, useDefaultSettings, force, ui) {
506
+ ui.start(`Scanning ${scanPath}`);
507
+ let totalFiles = 0;
508
+ let totalGalleries = 1;
509
+ const subGalleries = [];
510
+ const { mediaFiles, subGalleryDirectories } = await scanDirectory(scanPath, ui);
511
+ totalFiles += mediaFiles.length;
512
+ if (recursive) {
513
+ for (const subGalleryDir of subGalleryDirectories) {
514
+ const result2 = await processDirectory(
515
+ subGalleryDir,
516
+ path7.join(outputPath, path7.basename(subGalleryDir)),
517
+ recursive,
518
+ useDefaultSettings,
519
+ force,
520
+ ui
521
+ );
522
+ totalFiles += result2.totalFiles;
523
+ totalGalleries += result2.totalGalleries;
524
+ if (result2.subGallery) {
525
+ subGalleries.push(result2.subGallery);
526
+ }
527
+ }
528
+ }
529
+ if (mediaFiles.length > 0 || subGalleries.length > 0) {
530
+ const galleryPath = path7.join(outputPath, "gallery");
531
+ const galleryJsonPath = path7.join(galleryPath, "gallery.json");
532
+ const exists = await galleryExists(outputPath);
533
+ if (exists && !force) {
534
+ const shouldOverride = await ui.prompt(`Gallery already exists at ${galleryJsonPath}. Do you want to override it?`, {
535
+ type: "confirm",
536
+ default: false
537
+ });
538
+ if (!shouldOverride) {
539
+ ui.info("Skipping gallery creation");
540
+ return { totalFiles: 0, totalGalleries: 0 };
541
+ }
542
+ }
543
+ try {
544
+ await promises.mkdir(galleryPath, { recursive: true });
545
+ await createGalleryJson(mediaFiles, galleryJsonPath, scanPath, subGalleries, useDefaultSettings, ui);
546
+ ui.success(
547
+ `Create gallery with ${mediaFiles.length} files and ${subGalleries.length} subgalleries at: ${galleryJsonPath}`
548
+ );
549
+ } catch (error) {
550
+ ui.error(`Error creating gallery.json at ${galleryJsonPath}`);
551
+ throw error;
552
+ }
553
+ }
554
+ const result = { totalFiles, totalGalleries };
555
+ if (mediaFiles.length > 0 || subGalleries.length > 0) {
556
+ const dirName = path7.basename(scanPath);
557
+ result.subGallery = {
558
+ title: capitalizeTitle(dirName),
559
+ headerImage: mediaFiles[0]?.filename || "",
560
+ path: path7.join("..", dirName)
561
+ };
562
+ }
563
+ return result;
564
+ }
565
+ async function init(options, ui) {
566
+ try {
567
+ const scanPath = path7.resolve(options.photos);
568
+ const outputPath = options.gallery ? path7.resolve(options.gallery) : scanPath;
569
+ const result = await processDirectory(scanPath, outputPath, options.recursive, options.default, options.force, ui);
570
+ ui.box(
571
+ `Created ${result.totalGalleries} ${result.totalGalleries === 1 ? "gallery" : "galleries"} with ${result.totalFiles} media ${result.totalFiles === 1 ? "file" : "files"}`
572
+ );
573
+ return {
574
+ processedMediaCount: result.totalFiles,
575
+ processedGalleryCount: result.totalGalleries
576
+ };
577
+ } catch (error) {
578
+ ui.error("Error initializing gallery");
579
+ throw error;
580
+ }
581
+ }
255
582
  async function getFileMtime(filePath) {
256
583
  const stats = await promises.stat(filePath);
257
584
  return stats.mtime;
258
585
  }
259
- async function generateBlurHash(imagePath, componentX = 4, componentY = 3) {
260
- const image = await loadImage(imagePath);
261
- const { data, info } = await image.resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
262
- const pixels = new Uint8ClampedArray(data.buffer);
263
- return encode(pixels, info.width, info.height, componentX, componentY);
264
- }
265
586
  async function getVideoDimensions(filePath) {
266
587
  const data = await ffprobe(filePath);
267
588
  const videoStream = data.streams.find((stream) => stream.codec_type === "video");
@@ -322,7 +643,7 @@ async function createVideoThumbnails(inputPath, videoDimensions, outputPath, out
322
643
  // src/modules/thumbnails/index.ts
323
644
  async function processImage(imagePath, thumbnailPath, thumbnailPathRetina, thumbnailSize, lastMediaTimestamp) {
324
645
  const fileMtime = await getFileMtime(imagePath);
325
- if (lastMediaTimestamp && fileMtime <= lastMediaTimestamp && fs6.existsSync(thumbnailPath)) {
646
+ if (lastMediaTimestamp && fileMtime <= lastMediaTimestamp && fs8.existsSync(thumbnailPath)) {
326
647
  return void 0;
327
648
  }
328
649
  const { image, metadata } = await loadImageWithMetadata(imagePath);
@@ -344,7 +665,7 @@ async function processImage(imagePath, thumbnailPath, thumbnailPathRetina, thumb
344
665
  const blurHash = await generateBlurHash(thumbnailPath);
345
666
  return {
346
667
  type: "image",
347
- path: imagePath,
668
+ filename: path7.basename(imagePath),
348
669
  alt: description,
349
670
  width: imageDimensions.width,
350
671
  height: imageDimensions.height,
@@ -360,7 +681,7 @@ async function processImage(imagePath, thumbnailPath, thumbnailPathRetina, thumb
360
681
  }
361
682
  async function processVideo(videoPath, thumbnailPath, thumbnailPathRetina, thumbnailSize, verbose, lastMediaTimestamp) {
362
683
  const fileMtime = await getFileMtime(videoPath);
363
- if (lastMediaTimestamp && fileMtime <= lastMediaTimestamp && fs6.existsSync(thumbnailPath)) {
684
+ if (lastMediaTimestamp && fileMtime <= lastMediaTimestamp && fs8.existsSync(thumbnailPath)) {
364
685
  return void 0;
365
686
  }
366
687
  const videoDimensions = await getVideoDimensions(videoPath);
@@ -375,7 +696,7 @@ async function processVideo(videoPath, thumbnailPath, thumbnailPathRetina, thumb
375
696
  const blurHash = await generateBlurHash(thumbnailPath);
376
697
  return {
377
698
  type: "video",
378
- path: videoPath,
699
+ filename: path7.basename(videoPath),
379
700
  alt: void 0,
380
701
  width: videoDimensions.width,
381
702
  height: videoDimensions.height,
@@ -389,24 +710,24 @@ async function processVideo(videoPath, thumbnailPath, thumbnailPathRetina, thumb
389
710
  lastMediaTimestamp: fileMtime.toISOString()
390
711
  };
391
712
  }
392
- async function processMediaFile(mediaFile, galleryDir, thumbnailsPath, thumbnailSize, ui) {
713
+ async function processMediaFile(mediaFile, mediaBasePath, galleryDir, thumbnailsPath, thumbnailSize, ui) {
393
714
  try {
394
- const galleryJsonDir = path4.join(galleryDir, "gallery");
395
- const filePath = path4.resolve(path4.join(galleryJsonDir, mediaFile.path));
396
- const fileName = path4.basename(filePath);
397
- const fileNameWithoutExt = path4.parse(fileName).name;
715
+ const filePath = path7.resolve(path7.join(mediaBasePath, mediaFile.filename));
716
+ const fileName = mediaFile.filename;
717
+ const fileNameWithoutExt = path7.parse(fileName).name;
718
+ const galleryJsonDir = path7.join(galleryDir, "gallery");
398
719
  const thumbnailFileName = `${fileNameWithoutExt}.avif`;
399
- const thumbnailPath = path4.join(thumbnailsPath, thumbnailFileName);
720
+ const thumbnailPath = path7.join(thumbnailsPath, thumbnailFileName);
400
721
  const thumbnailPathRetina = thumbnailPath.replace(".avif", "@2x.avif");
401
- const relativeThumbnailPath = path4.relative(galleryJsonDir, thumbnailPath);
402
- const relativeThumbnailRetinaPath = path4.relative(galleryJsonDir, thumbnailPathRetina);
722
+ const relativeThumbnailPath = path7.relative(galleryJsonDir, thumbnailPath);
723
+ const relativeThumbnailRetinaPath = path7.relative(galleryJsonDir, thumbnailPathRetina);
403
724
  const lastMediaTimestamp = mediaFile.lastMediaTimestamp ? new Date(mediaFile.lastMediaTimestamp) : void 0;
404
725
  const verbose = ui.level === LogLevels.debug;
405
726
  ui.debug(` Processing ${mediaFile.type}: ${fileName}`);
406
727
  const updatedMediaFile = await (mediaFile.type === "image" ? processImage(filePath, thumbnailPath, thumbnailPathRetina, thumbnailSize, lastMediaTimestamp) : processVideo(filePath, thumbnailPath, thumbnailPathRetina, thumbnailSize, verbose, lastMediaTimestamp));
407
728
  if (!updatedMediaFile) {
408
729
  ui.debug(` Skipping ${fileName} because it has already been processed`);
409
- if (mediaFile.thumbnail && !mediaFile.thumbnail.blurHash && fs6.existsSync(thumbnailPath)) {
730
+ if (mediaFile.thumbnail && !mediaFile.thumbnail.blurHash && fs8.existsSync(thumbnailPath)) {
410
731
  try {
411
732
  const blurHash = await generateBlurHash(thumbnailPath);
412
733
  return {
@@ -422,34 +743,41 @@ async function processMediaFile(mediaFile, galleryDir, thumbnailsPath, thumbnail
422
743
  }
423
744
  return mediaFile;
424
745
  }
425
- updatedMediaFile.path = mediaFile.path;
746
+ updatedMediaFile.filename = mediaFile.filename;
426
747
  if (updatedMediaFile.thumbnail) {
427
748
  updatedMediaFile.thumbnail.path = relativeThumbnailPath;
428
749
  updatedMediaFile.thumbnail.pathRetina = relativeThumbnailRetinaPath;
429
750
  }
430
751
  return updatedMediaFile;
431
752
  } catch (error) {
432
- handleFileProcessingError(error, path4.basename(mediaFile.path), ui);
433
- return mediaFile;
753
+ handleFileProcessingError(error, mediaFile.filename, ui);
754
+ return { ...mediaFile, thumbnail: void 0 };
434
755
  }
435
756
  }
436
757
  async function processGalleryThumbnails(galleryDir, ui) {
437
- const galleryJsonPath = path4.join(galleryDir, "gallery", "gallery.json");
438
- const thumbnailsPath = path4.join(galleryDir, "gallery", "images");
758
+ const galleryJsonPath = path7.join(galleryDir, "gallery", "gallery.json");
759
+ const thumbnailsPath = path7.join(galleryDir, "gallery", "images");
439
760
  ui.start(`Creating thumbnails: ${galleryDir}`);
440
761
  try {
441
- fs6.mkdirSync(thumbnailsPath, { recursive: true });
442
- const galleryContent = fs6.readFileSync(galleryJsonPath, "utf8");
443
- const galleryData = GalleryDataSchema.parse(JSON.parse(galleryContent));
762
+ fs8.mkdirSync(thumbnailsPath, { recursive: true });
763
+ const galleryData = parseGalleryJson(galleryJsonPath, ui);
444
764
  const thumbnailSize = galleryData.thumbnailSize || DEFAULT_THUMBNAIL_SIZE;
765
+ const mediaBasePath = galleryData.mediaBasePath ?? path7.join(galleryDir);
445
766
  let processedCount = 0;
446
767
  for (const section of galleryData.sections) {
447
768
  for (const [index, mediaFile] of section.images.entries()) {
448
- section.images[index] = await processMediaFile(mediaFile, galleryDir, thumbnailsPath, thumbnailSize, ui);
769
+ section.images[index] = await processMediaFile(
770
+ mediaFile,
771
+ mediaBasePath,
772
+ galleryDir,
773
+ thumbnailsPath,
774
+ thumbnailSize,
775
+ ui
776
+ );
449
777
  }
450
778
  processedCount += section.images.length;
451
779
  }
452
- fs6.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
780
+ fs8.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
453
781
  ui.success(`Created thumbnails for ${processedCount} media files`);
454
782
  return processedCount;
455
783
  } catch (error) {
@@ -462,7 +790,7 @@ async function thumbnails(options, ui) {
462
790
  const galleryDirs = findGalleries(options.gallery, options.recursive);
463
791
  if (galleryDirs.length === 0) {
464
792
  ui.error("No galleries found.");
465
- return;
793
+ return { processedGalleryCount: 0, processedMediaCount: 0 };
466
794
  }
467
795
  let totalGalleries = 0;
468
796
  let totalProcessed = 0;
@@ -476,6 +804,7 @@ async function thumbnails(options, ui) {
476
804
  ui.box(
477
805
  `Created thumbnails for ${totalGalleries} ${totalGalleries === 1 ? "gallery" : "galleries"} with ${totalProcessed} media ${totalProcessed === 1 ? "file" : "files"}`
478
806
  );
807
+ return { processedGalleryCount: totalGalleries, processedMediaCount: totalProcessed };
479
808
  } catch (error) {
480
809
  ui.error("Error creating thumbnails");
481
810
  throw error;
@@ -483,68 +812,113 @@ async function thumbnails(options, ui) {
483
812
  }
484
813
 
485
814
  // src/modules/build/index.ts
486
- function checkFileIsOneFolderUp(filePath) {
487
- const normalizedPath = path4.normalize(filePath);
488
- const pathParts = normalizedPath.split(path4.sep);
489
- return pathParts.length === 2 && pathParts[0] === "..";
490
- }
491
815
  function copyPhotos(galleryData, galleryDir, ui) {
492
816
  for (const section of galleryData.sections) {
493
817
  for (const image of section.images) {
494
- if (!checkFileIsOneFolderUp(image.path)) {
495
- const sourcePath = path4.join(galleryDir, "gallery", image.path);
496
- const fileName = path4.basename(image.path);
497
- const destPath = path4.join(galleryDir, fileName);
818
+ if (galleryData.mediaBasePath) {
819
+ const sourcePath = path7.join(galleryData.mediaBasePath, image.filename);
820
+ const destPath = path7.join(galleryDir, image.filename);
498
821
  ui.debug(`Copying photo to ${destPath}`);
499
- fs6.copyFileSync(sourcePath, destPath);
822
+ fs8.copyFileSync(sourcePath, destPath);
500
823
  }
501
824
  }
502
825
  }
503
826
  }
827
+ async function scanAndAppendNewFiles(galleryDir, galleryJsonPath, galleryData, ui) {
828
+ const scanPath = galleryData.mediaBasePath || galleryDir;
829
+ ui.debug(`Scanning ${scanPath} for new media files`);
830
+ let scanResult;
831
+ try {
832
+ scanResult = await scanDirectory(scanPath, ui);
833
+ } catch {
834
+ ui.debug(`Could not scan directory ${scanPath}`);
835
+ return galleryData;
836
+ }
837
+ const existingFilenames = new Set(
838
+ galleryData.sections.flatMap((section) => section.images.map((image) => image.filename))
839
+ );
840
+ const newMediaFiles = scanResult.mediaFiles.filter((file) => !existingFilenames.has(file.filename));
841
+ if (newMediaFiles.length > 0) {
842
+ ui.info(`Found ${newMediaFiles.length} new media ${newMediaFiles.length === 1 ? "file" : "files"}`);
843
+ if (galleryData.sections.length === 0) {
844
+ galleryData.sections.push({ images: [] });
845
+ }
846
+ const lastSectionIndex = galleryData.sections.length - 1;
847
+ const lastSection = galleryData.sections[lastSectionIndex];
848
+ lastSection.images.push(...newMediaFiles);
849
+ ui.debug("Updating gallery.json with new files");
850
+ fs8.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
851
+ ui.success(`Added ${newMediaFiles.length} new ${newMediaFiles.length === 1 ? "file" : "files"} to gallery.json`);
852
+ } else {
853
+ ui.debug("No new media files found");
854
+ }
855
+ return galleryData;
856
+ }
504
857
  async function buildGallery(galleryDir, templateDir, ui, baseUrl) {
505
858
  ui.start(`Building gallery ${galleryDir}`);
506
- await processGalleryThumbnails(galleryDir, ui);
507
- const galleryJsonPath = path4.join(galleryDir, "gallery", "gallery.json");
508
- const galleryContent = fs6.readFileSync(galleryJsonPath, "utf8");
509
- const galleryData = GalleryDataSchema.parse(JSON.parse(galleryContent));
510
- const socialMediaCardImagePath = path4.join(galleryDir, "gallery", "images", "social-media-card.jpg");
511
- const headerImagePath = path4.resolve(path4.join(galleryDir, "gallery"), galleryData.headerImage);
859
+ const galleryJsonPath = path7.join(galleryDir, "gallery", "gallery.json");
860
+ let galleryData = parseGalleryJson(galleryJsonPath, ui);
861
+ galleryData = await scanAndAppendNewFiles(galleryDir, galleryJsonPath, galleryData, ui);
862
+ const socialMediaCardImagePath = path7.join(galleryDir, "gallery", "images", "social-media-card.jpg");
863
+ const mediaBasePath = galleryData.mediaBasePath;
864
+ const mediaBaseUrl = baseUrl || galleryData.mediaBaseUrl;
865
+ const headerImagePath = mediaBasePath ? path7.join(mediaBasePath, galleryData.headerImage) : path7.resolve(galleryDir, galleryData.headerImage);
866
+ const imagesFolder = path7.join(galleryDir, "gallery", "images");
867
+ const currentHeaderBasename = path7.basename(headerImagePath, path7.extname(headerImagePath));
868
+ if (!fs8.existsSync(imagesFolder)) {
869
+ fs8.mkdirSync(imagesFolder, { recursive: true });
870
+ }
871
+ const headerImageChanged = hasOldHeaderImages(imagesFolder, currentHeaderBasename);
872
+ if (headerImageChanged) {
873
+ ui.info("Header image changed, cleaning up old assets");
874
+ cleanupOldHeaderImages(imagesFolder, currentHeaderBasename, ui);
875
+ if (fs8.existsSync(socialMediaCardImagePath)) {
876
+ fs8.unlinkSync(socialMediaCardImagePath);
877
+ ui.debug("Deleted old social media card");
878
+ }
879
+ }
512
880
  await createGallerySocialMediaCardImage(headerImagePath, galleryData.title, socialMediaCardImagePath, ui);
513
- galleryData.metadata.image = galleryData.metadata.image || `${galleryData.url || ""}/${path4.relative(galleryDir, socialMediaCardImagePath)}`;
514
- fs6.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
515
- await createOptimizedHeaderImage(headerImagePath, path4.join(galleryDir, "gallery", "images"), ui);
516
- if (!baseUrl) {
517
- const shouldCopyPhotos = galleryData.sections.some(
518
- (section) => section.images.some((image) => !checkFileIsOneFolderUp(image.path))
519
- );
520
- if (shouldCopyPhotos && await ui.prompt("All photos need to be copied. Are you sure you want to continue?", { type: "confirm" })) {
881
+ galleryData.metadata.image = galleryData.metadata.image || `${galleryData.url || ""}/${path7.relative(galleryDir, socialMediaCardImagePath)}`;
882
+ fs8.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
883
+ const { blurHash } = await createOptimizedHeaderImage(headerImagePath, imagesFolder, ui);
884
+ if (galleryData.headerImageBlurHash !== blurHash) {
885
+ ui.debug("Updating gallery.json with header image blurhash");
886
+ galleryData.headerImageBlurHash = blurHash;
887
+ fs8.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
888
+ }
889
+ if (!mediaBaseUrl && mediaBasePath) {
890
+ const shouldCopyPhotos = await ui.prompt("All photos need to be copied. Are you sure you want to continue?", {
891
+ type: "confirm"
892
+ });
893
+ if (shouldCopyPhotos) {
521
894
  ui.debug("Copying photos");
522
895
  copyPhotos(galleryData, galleryDir, ui);
523
896
  }
524
897
  }
525
- if (baseUrl) {
898
+ if (mediaBaseUrl && galleryData.mediaBaseUrl !== mediaBaseUrl) {
526
899
  ui.debug("Updating gallery.json with baseUrl");
527
- galleryData.mediaBaseUrl = baseUrl;
528
- fs6.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
900
+ galleryData.mediaBaseUrl = mediaBaseUrl;
901
+ fs8.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
529
902
  }
530
- ui.debug("Building gallery form template");
903
+ await processGalleryThumbnails(galleryDir, ui);
904
+ ui.debug("Building gallery from template");
531
905
  try {
532
- process2.env.GALLERY_JSON_PATH = galleryJsonPath;
533
- process2.env.GALLERY_OUTPUT_DIR = path4.join(galleryDir, "gallery");
906
+ process3.env.GALLERY_JSON_PATH = galleryJsonPath;
907
+ process3.env.GALLERY_OUTPUT_DIR = path7.join(galleryDir, "gallery");
534
908
  execSync("npx astro build", { cwd: templateDir, stdio: ui.level === LogLevels.debug ? "inherit" : "ignore" });
535
909
  } catch (error) {
536
910
  ui.error(`Build failed for ${galleryDir}`);
537
911
  throw error;
538
912
  }
539
- const outputDir = path4.join(galleryDir, "gallery");
540
- const buildDir = path4.join(outputDir, "_build");
913
+ const outputDir = path7.join(galleryDir, "gallery");
914
+ const buildDir = path7.join(outputDir, "_build");
541
915
  ui.debug(`Copying build output to ${outputDir}`);
542
- fs6.cpSync(buildDir, outputDir, { recursive: true });
916
+ fs8.cpSync(buildDir, outputDir, { recursive: true });
543
917
  ui.debug("Moving index.html to gallery directory");
544
- fs6.copyFileSync(path4.join(outputDir, "index.html"), path4.join(galleryDir, "index.html"));
545
- fs6.rmSync(path4.join(outputDir, "index.html"));
918
+ fs8.copyFileSync(path7.join(outputDir, "index.html"), path7.join(galleryDir, "index.html"));
919
+ fs8.rmSync(path7.join(outputDir, "index.html"));
546
920
  ui.debug("Cleaning up build directory");
547
- fs6.rmSync(buildDir, { recursive: true, force: true });
921
+ fs8.rmSync(buildDir, { recursive: true, force: true });
548
922
  ui.success(`Gallery built successfully`);
549
923
  }
550
924
  async function build(options, ui) {
@@ -552,17 +926,18 @@ async function build(options, ui) {
552
926
  const galleryDirs = findGalleries(options.gallery, options.recursive);
553
927
  if (galleryDirs.length === 0) {
554
928
  ui.error("No galleries found.");
555
- return;
929
+ return { processedGalleryCount: 0 };
556
930
  }
557
931
  const themePath = await import.meta.resolve("@simple-photo-gallery/theme-modern/package.json");
558
- const themeDir = path4.dirname(new URL(themePath).pathname);
932
+ const themeDir = path7.dirname(new URL(themePath).pathname);
559
933
  let totalGalleries = 0;
560
934
  for (const dir of galleryDirs) {
561
- const baseUrl = options.baseUrl ? `${options.baseUrl}${path4.relative(options.gallery, dir)}` : void 0;
562
- await buildGallery(path4.resolve(dir), themeDir, ui, baseUrl);
935
+ const baseUrl = options.baseUrl ? `${options.baseUrl}${path7.relative(options.gallery, dir)}` : void 0;
936
+ await buildGallery(path7.resolve(dir), themeDir, ui, baseUrl);
563
937
  ++totalGalleries;
564
938
  }
565
939
  ui.box(`Built ${totalGalleries} ${totalGalleries === 1 ? "gallery" : "galleries"} successfully`);
940
+ return { processedGalleryCount: totalGalleries };
566
941
  } catch (error) {
567
942
  if (error instanceof Error && error.message.includes("Cannot find package")) {
568
943
  ui.error("Theme package not found: @simple-photo-gallery/theme-modern/package.json");
@@ -574,20 +949,20 @@ async function build(options, ui) {
574
949
  }
575
950
  async function cleanGallery(galleryDir, ui) {
576
951
  let filesRemoved = 0;
577
- const indexHtmlPath = path4.join(galleryDir, "index.html");
578
- if (fs6.existsSync(indexHtmlPath)) {
952
+ const indexHtmlPath = path7.join(galleryDir, "index.html");
953
+ if (fs8.existsSync(indexHtmlPath)) {
579
954
  try {
580
- fs6.rmSync(indexHtmlPath);
955
+ fs8.rmSync(indexHtmlPath);
581
956
  ui.debug(`Removed: ${indexHtmlPath}`);
582
957
  filesRemoved++;
583
958
  } catch (error) {
584
959
  ui.warn(`Failed to remove index.html: ${error}`);
585
960
  }
586
961
  }
587
- const galleryPath = path4.join(galleryDir, "gallery");
588
- if (fs6.existsSync(galleryPath)) {
962
+ const galleryPath = path7.join(galleryDir, "gallery");
963
+ if (fs8.existsSync(galleryPath)) {
589
964
  try {
590
- fs6.rmSync(galleryPath, { recursive: true, force: true });
965
+ fs8.rmSync(galleryPath, { recursive: true, force: true });
591
966
  ui.debug(`Removed directory: ${galleryPath}`);
592
967
  filesRemoved++;
593
968
  } catch (error) {
@@ -599,192 +974,198 @@ async function cleanGallery(galleryDir, ui) {
599
974
  } else {
600
975
  ui.info(`No gallery files found at: ${galleryDir}`);
601
976
  }
977
+ return { processedGalleryCount: filesRemoved };
602
978
  }
603
979
  async function clean(options, ui) {
604
980
  try {
605
- const basePath = path4.resolve(options.gallery);
606
- if (!fs6.existsSync(basePath)) {
981
+ const basePath = path7.resolve(options.gallery);
982
+ if (!fs8.existsSync(basePath)) {
607
983
  ui.error(`Directory does not exist: ${basePath}`);
608
- return;
984
+ return { processedGalleryCount: 0 };
609
985
  }
610
986
  const galleryDirs = findGalleries(basePath, options.recursive);
611
987
  if (galleryDirs.length === 0) {
612
988
  ui.info("No galleries found to clean.");
613
- return;
989
+ return { processedGalleryCount: 0 };
614
990
  }
615
991
  for (const dir of galleryDirs) {
616
992
  await cleanGallery(dir, ui);
617
993
  }
618
994
  ui.box(`Successfully cleaned ${galleryDirs.length} ${galleryDirs.length === 1 ? "gallery" : "galleries"}`);
995
+ return { processedGalleryCount: galleryDirs.length };
619
996
  } catch (error) {
620
997
  ui.error("Error cleaning galleries");
621
998
  throw error;
622
999
  }
623
1000
  }
624
- function getMediaFileType(fileName) {
625
- const ext = path4.extname(fileName).toLowerCase();
626
- if (IMAGE_EXTENSIONS.has(ext)) return "image";
627
- if (VIDEO_EXTENSIONS.has(ext)) return "video";
628
- return null;
629
- }
630
- function capitalizeTitle(folderName) {
631
- return folderName.replace("-", " ").replace("_", " ").split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
632
- }
633
1001
 
634
- // src/modules/init/index.ts
635
- async function scanDirectory(dirPath, ui) {
636
- const mediaFiles = [];
637
- const subGalleryDirectories = [];
638
- try {
639
- const entries = await promises.readdir(dirPath, { withFileTypes: true });
640
- for (const entry of entries) {
641
- if (entry.isFile()) {
642
- const fullPath = path4.join(dirPath, entry.name);
643
- const mediaType = getMediaFileType(entry.name);
644
- if (mediaType) {
645
- const mediaFile = {
646
- type: mediaType,
647
- path: fullPath,
648
- width: 0,
649
- height: 0
650
- };
651
- mediaFiles.push(mediaFile);
652
- }
653
- } else if (entry.isDirectory() && entry.name !== "gallery") {
654
- subGalleryDirectories.push(path4.join(dirPath, entry.name));
655
- }
656
- }
657
- } catch (error) {
658
- if (error instanceof Error && error.message.includes("ENOENT")) {
659
- ui.error(`Directory does not exist: ${dirPath}`);
660
- } else if (error instanceof Error && error.message.includes("ENOTDIR")) {
661
- ui.error(`Path is not a directory: ${dirPath}`);
1002
+ // src/modules/telemetry/index.ts
1003
+ async function telemetry(options, ui, telemetryService2) {
1004
+ const updates = {};
1005
+ if (options.state === void 0) {
1006
+ const current = telemetryService2.getStoredPreference();
1007
+ if (current === void 0) {
1008
+ ui.info("Telemetry preference not set yet. It will be requested on next command run.");
1009
+ updates.telemetryStatus = "unset";
662
1010
  } else {
663
- ui.error(`Error scanning directory ${dirPath}:`, error);
1011
+ ui.info(`Telemetry is currently ${current ? "enabled" : "disabled"}.`);
1012
+ updates.telemetryEnabled = current;
664
1013
  }
665
- throw error;
1014
+ } else {
1015
+ telemetryService2.setStoredPreference(options.state === "1");
1016
+ ui.success(`Anonymous telemetry ${options.state === "1" ? "enabled" : "disabled"}.`);
1017
+ updates.telemetryEnabled = options.state === "1";
666
1018
  }
667
- return { mediaFiles, subGalleryDirectories };
668
- }
669
- async function getGallerySettingsFromUser(galleryName, defaultImage, ui) {
670
- ui.info(`Enter gallery settings for the gallery in folder "${galleryName}"`);
671
- const title = await ui.prompt("Enter gallery title", { type: "text", default: "My Gallery", placeholder: "My Gallery" });
672
- const description = await ui.prompt("Enter gallery description", {
673
- type: "text",
674
- default: "My gallery with fantastic photos.",
675
- placeholder: "My gallery with fantastic photos."
676
- });
677
- const url = await ui.prompt("Enter the URL where the gallery will be hosted (important for social media image)", {
678
- type: "text",
679
- default: "",
680
- placeholder: ""
681
- });
682
- const headerImageName = await ui.prompt("Enter the name of the header image", {
683
- type: "text",
684
- default: defaultImage,
685
- placeholder: defaultImage
686
- });
687
- const headerImage = path4.join("..", headerImageName);
688
- return { title, description, url, headerImage };
1019
+ return updates;
689
1020
  }
690
- async function createGalleryJson(mediaFiles, galleryJsonPath, subGalleries = [], useDefaultSettings, ui) {
691
- const galleryDir = path4.dirname(galleryJsonPath);
692
- const relativeMediaFiles = mediaFiles.map((file) => ({
693
- ...file,
694
- path: path4.relative(galleryDir, file.path)
695
- }));
696
- const relativeSubGalleries = subGalleries.map((subGallery) => ({
697
- ...subGallery,
698
- headerImage: subGallery.headerImage ? path4.relative(galleryDir, subGallery.headerImage) : ""
699
- }));
700
- let galleryData = {
701
- title: "My Gallery",
702
- description: "My gallery with fantastic photos.",
703
- headerImage: relativeMediaFiles[0]?.path || "",
704
- metadata: {},
705
- sections: [
706
- {
707
- images: relativeMediaFiles
708
- }
709
- ],
710
- subGalleries: {
711
- title: "Sub Galleries",
712
- galleries: relativeSubGalleries
1021
+ var ApiTelemetryClient = class {
1022
+ constructor() {
1023
+ this.endpoint = "https://tools.simple.photo/api/telemetry";
1024
+ }
1025
+ async record(event) {
1026
+ try {
1027
+ axios.post(this.endpoint, event, {
1028
+ headers: {
1029
+ "content-type": "application/json",
1030
+ "user-agent": `simple-photo-gallery/${event.packageVersion} (${process3.platform}; ${process3.arch})`
1031
+ }
1032
+ });
1033
+ } catch {
713
1034
  }
714
- };
715
- if (!useDefaultSettings) {
716
- galleryData = {
717
- ...galleryData,
718
- ...await getGallerySettingsFromUser(
719
- path4.basename(path4.join(galleryDir, "..")),
720
- path4.basename(mediaFiles[0]?.path || ""),
721
- ui
722
- )
723
- };
724
1035
  }
725
- await promises.writeFile(galleryJsonPath, JSON.stringify(galleryData, null, 2));
726
- }
727
- async function processDirectory(scanPath, outputPath, recursive, useDefaultSettings, ui) {
728
- ui.start(`Scanning ${scanPath}`);
729
- let totalFiles = 0;
730
- let totalGalleries = 1;
731
- const subGalleries = [];
732
- const { mediaFiles, subGalleryDirectories } = await scanDirectory(scanPath, ui);
733
- totalFiles += mediaFiles.length;
734
- if (recursive) {
735
- for (const subGalleryDir of subGalleryDirectories) {
736
- const result2 = await processDirectory(
737
- subGalleryDir,
738
- path4.join(outputPath, path4.basename(subGalleryDir)),
739
- recursive,
740
- useDefaultSettings,
741
- ui
742
- );
743
- totalFiles += result2.totalFiles;
744
- totalGalleries += result2.totalGalleries;
745
- if (result2.subGallery) {
746
- subGalleries.push(result2.subGallery);
747
- }
1036
+ };
1037
+ var ConsoleTelemetryClient = class {
1038
+ async record(event) {
1039
+ const serialized = JSON.stringify(event, null, 2);
1040
+ stdout.write(`TELEMETRY EVENT: ${serialized}
1041
+ `);
1042
+ }
1043
+ };
1044
+
1045
+ // src/modules/telemetry/service/index.ts
1046
+ var CONFIG_KEY = "telemetryEnabled";
1047
+ var TelemetryService = class {
1048
+ constructor(packageName, packageVersion, ui) {
1049
+ this.packageName = packageName;
1050
+ this.packageVersion = packageVersion;
1051
+ this.ui = ui;
1052
+ this.config = new Conf({ projectName: packageName });
1053
+ }
1054
+ /** Returns the stored telemetry preference, if any. */
1055
+ getStoredPreference() {
1056
+ return this.config.get(CONFIG_KEY);
1057
+ }
1058
+ /** Updates the persisted telemetry preference. */
1059
+ setStoredPreference(enabled) {
1060
+ this.config.set(CONFIG_KEY, enabled);
1061
+ }
1062
+ /** Determines whether telemetry should be collected for this run. */
1063
+ async shouldCollectTelemetry(override) {
1064
+ if (override) {
1065
+ return override === "1";
1066
+ }
1067
+ if (process3.env.CI || process3.env.DO_NOT_TRACK) {
1068
+ return false;
1069
+ }
1070
+ if (process3.env.SPG_TELEMETRY) {
1071
+ return process3.env.SPG_TELEMETRY === "1";
1072
+ }
1073
+ const stored = this.getStoredPreference();
1074
+ if (stored === void 0) {
1075
+ const consent = await this.promptForConsent();
1076
+ this.setStoredPreference(consent);
1077
+ return consent;
1078
+ } else {
1079
+ return stored;
748
1080
  }
749
1081
  }
750
- if (mediaFiles.length > 0 || subGalleries.length > 0) {
751
- const galleryPath = path4.join(outputPath, "gallery");
752
- const galleryJsonPath = path4.join(galleryPath, "gallery.json");
1082
+ /** Builds and dispatches a telemetry event when telemetry is enabled. */
1083
+ async emit(params) {
1084
+ const event = this.buildEvent(params);
753
1085
  try {
754
- await promises.mkdir(galleryPath, { recursive: true });
755
- await createGalleryJson(mediaFiles, galleryJsonPath, subGalleries, useDefaultSettings, ui);
756
- ui.success(
757
- `Create gallery with ${mediaFiles.length} files and ${subGalleries.length} subgalleries at: ${galleryJsonPath}`
758
- );
1086
+ const client = await this.getClient();
1087
+ if (client) {
1088
+ await client.record(event);
1089
+ }
759
1090
  } catch (error) {
760
- ui.error(`Error creating gallery.json at ${galleryJsonPath}`);
761
- throw error;
1091
+ this.ui.debug("Error recording telemetry event", error);
762
1092
  }
763
1093
  }
764
- const result = { totalFiles, totalGalleries };
765
- if (mediaFiles.length > 0 || subGalleries.length > 0) {
766
- const dirName = path4.basename(scanPath);
767
- result.subGallery = {
768
- title: capitalizeTitle(dirName),
769
- headerImage: mediaFiles[0]?.path || "",
770
- path: path4.join("..", dirName)
1094
+ /** Builds a telemetry event. */
1095
+ buildEvent({
1096
+ command,
1097
+ argumentsProvided,
1098
+ globalOptionsProvided,
1099
+ metrics,
1100
+ success,
1101
+ error,
1102
+ startedAt
1103
+ }) {
1104
+ const now = Date.now();
1105
+ const event = {
1106
+ command,
1107
+ argumentsProvided,
1108
+ globalOptionsProvided,
1109
+ timestamp: new Date(now).toISOString(),
1110
+ durationMs: now - startedAt,
1111
+ packageName: this.packageName,
1112
+ packageVersion: this.packageVersion,
1113
+ nodeVersion: process3.version,
1114
+ osPlatform: os.platform(),
1115
+ osRelease: os.release(),
1116
+ osArch: os.arch(),
1117
+ result: success ? "success" : "error"
771
1118
  };
1119
+ if (metrics && Object.keys(metrics).length > 0) {
1120
+ event.metrics = metrics;
1121
+ }
1122
+ if (!success && error) {
1123
+ event.errorName = error.name;
1124
+ event.errorMessage = error.message;
1125
+ }
1126
+ return event;
772
1127
  }
773
- return result;
774
- }
775
- async function init(options, ui) {
776
- try {
777
- const scanPath = path4.resolve(options.photos);
778
- const outputPath = options.gallery ? path4.resolve(options.gallery) : scanPath;
779
- const result = await processDirectory(scanPath, outputPath, options.recursive, options.default, ui);
780
- ui.box(
781
- `Created ${result.totalGalleries} ${result.totalGalleries === 1 ? "gallery" : "galleries"} with ${result.totalFiles} media ${result.totalFiles === 1 ? "file" : "files"}`
1128
+ /** Prompts the user for consent to collect telemetry. */
1129
+ async promptForConsent() {
1130
+ this.ui.info(
1131
+ "Simple Photo Gallery collects anonymous usage telemetry to improve the CLI. No personal data or information about your photos is collected. For more information, see https://simple.photo/telemetry."
782
1132
  );
783
- } catch (error) {
784
- ui.error("Error initializing gallery");
785
- throw error;
1133
+ const answer = await this.ui.prompt("Would you like to enable telemetry?", {
1134
+ type: "confirm",
1135
+ initial: true
1136
+ });
1137
+ if (!answer) {
1138
+ this.ui.info('Anonymous telemetry disabled. You can re-enable it with "spg telemetry 1".');
1139
+ return false;
1140
+ }
1141
+ this.ui.success("Thank you! Telemetry will help us improve Simple Photo Gallery.");
1142
+ return true;
786
1143
  }
787
- }
1144
+ /** Returns the telemetry client. */
1145
+ getClient() {
1146
+ if (!this.client) {
1147
+ switch (process3.env.SPG_TELEMETRY_PROVIDER) {
1148
+ case "none": {
1149
+ this.client = void 0;
1150
+ break;
1151
+ }
1152
+ case "console": {
1153
+ this.client = new ConsoleTelemetryClient();
1154
+ break;
1155
+ }
1156
+ case "api": {
1157
+ this.client = new ApiTelemetryClient();
1158
+ break;
1159
+ }
1160
+ default: {
1161
+ this.client = new ApiTelemetryClient();
1162
+ break;
1163
+ }
1164
+ }
1165
+ }
1166
+ return this.client;
1167
+ }
1168
+ };
788
1169
  var NPM_REGISTRY_URL = "https://registry.npmjs.org";
789
1170
  var CHECK_TIMEOUT_MS = 3e3;
790
1171
  async function fetchLatestStableVersion(packageName) {
@@ -855,11 +1236,12 @@ async function waitForUpdateCheck(checkPromise) {
855
1236
  // package.json
856
1237
  var package_default = {
857
1238
  name: "simple-photo-gallery",
858
- version: "2.0.8"};
1239
+ version: "2.0.10-rc.1"};
859
1240
 
860
1241
  // src/index.ts
861
1242
  var program = new Command();
862
- program.name("gallery").description("Simple Photo Gallery CLI").version(package_default.version).option("-v, --verbose", "Verbose output (debug level)", false).option("-q, --quiet", "Minimal output (only warnings/errors)", false).showHelpAfterError(true);
1243
+ var telemetryService = new TelemetryService(package_default.name, package_default.version, createConsolaUI(program.opts()));
1244
+ program.name("gallery").description("Simple Photo Gallery CLI").version(package_default.version).option("-v, --verbose", "Verbose output (debug level)", false).option("-q, --quiet", "Minimal output (only warnings/errors)", false).option("--telemetry <state>", "Enable (1) or disable (0) telemetry for this command", parseTelemetryOption).showHelpAfterError(true);
863
1245
  function createConsolaUI(globalOpts) {
864
1246
  let level = LogLevels.info;
865
1247
  if (globalOpts.quiet) {
@@ -871,33 +1253,62 @@ function createConsolaUI(globalOpts) {
871
1253
  level
872
1254
  }).withTag("simple-photo-gallery");
873
1255
  }
874
- function withConsolaUI(handler) {
875
- return async (opts) => {
1256
+ function collectCommandArguments(command) {
1257
+ return command.options.map((option) => ({ name: option.attributeName(), source: command.getOptionValueSource(option.attributeName()) })).filter((option) => option.source && option.source !== "default").map((option) => option.name);
1258
+ }
1259
+ function collectGlobalArguments() {
1260
+ return program.options.map((option) => ({ name: option.attributeName(), source: program.getOptionValueSource(option.attributeName()) })).filter((option) => option.source && option.source !== "default").map((option) => option.name);
1261
+ }
1262
+ function withCommandContext(handler) {
1263
+ return async (rawOpts, command) => {
1264
+ const { telemetry: telemetryOverride, ...commandOptions } = rawOpts;
876
1265
  const ui = createConsolaUI(program.opts());
1266
+ const shouldCollectTelemetry = await telemetryService.shouldCollectTelemetry(telemetryOverride);
1267
+ const startedAt = Date.now();
877
1268
  const updateCheckPromise = checkForUpdates(package_default.name, package_default.version);
1269
+ let success = false;
1270
+ let metrics;
1271
+ let errorInfo;
878
1272
  try {
879
- await handler(opts, ui);
1273
+ const result = await handler(commandOptions, ui, command);
1274
+ if (result && Object.keys(result).length > 0) {
1275
+ metrics = result;
1276
+ }
1277
+ success = true;
880
1278
  } catch (error) {
881
1279
  ui.debug(error);
882
- process2.exitCode = 1;
1280
+ errorInfo = error instanceof Error ? { name: error.name, message: error.message } : { name: "UnknownError", message: String(error) };
1281
+ process3.exitCode = 1;
883
1282
  }
884
1283
  const updateInfo = await waitForUpdateCheck(updateCheckPromise);
885
1284
  if (updateInfo) {
886
1285
  displayUpdateNotification(updateInfo, ui);
887
1286
  }
1287
+ if (shouldCollectTelemetry) {
1288
+ await telemetryService.emit({
1289
+ command: command.name(),
1290
+ argumentsProvided: collectCommandArguments(command),
1291
+ globalOptionsProvided: collectGlobalArguments(),
1292
+ metrics,
1293
+ success,
1294
+ error: errorInfo,
1295
+ startedAt
1296
+ });
1297
+ }
888
1298
  };
889
1299
  }
890
1300
  program.command("init").description("Initialize a gallery by scaning a folder for images and videos").option(
891
1301
  "-p, --photos <path>",
892
1302
  "Path to the folder where the photos are stored. Default: current working directory",
893
- process2.cwd()
1303
+ process3.cwd()
894
1304
  ).option(
895
1305
  "-g, --gallery <path>",
896
1306
  "Path to the directory where the gallery will be initialized. Default: same directory as the photos folder"
897
- ).option("-r, --recursive", "Recursively create galleries from all photos subdirectories", false).option("-d, --default", "Use default gallery settings instead of asking the user", false).action(withConsolaUI(init));
898
- program.command("thumbnails").description("Create thumbnails for all media files in the gallery").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process2.cwd()).option("-r, --recursive", "Scan subdirectories recursively", false).action(withConsolaUI(thumbnails));
899
- program.command("build").description("Build the HTML gallery in the specified directory").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process2.cwd()).option("-r, --recursive", "Scan subdirectories recursively", false).option("-b, --base-url <url>", "Base URL where the photos are hosted").action(withConsolaUI(build));
900
- program.command("clean").description("Remove all gallery files and folders (index.html, gallery/)").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process2.cwd()).option("-r, --recursive", "Clean subdirectories recursively", false).action(withConsolaUI(clean));
1307
+ ).option("-r, --recursive", "Recursively create galleries from all photos subdirectories", false).option("-d, --default", "Use default gallery settings instead of asking the user", false).option("-f, --force", "Force override existing galleries without asking", false).action(withCommandContext((options, ui) => init(options, ui)));
1308
+ program.command("thumbnails").description("Create thumbnails for all media files in the gallery").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process3.cwd()).option("-r, --recursive", "Scan subdirectories recursively", false).action(withCommandContext((options, ui) => thumbnails(options, ui)));
1309
+ program.command("build").description("Build the HTML gallery in the specified directory").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process3.cwd()).option("-r, --recursive", "Scan subdirectories recursively", false).option("-b, --base-url <url>", "Base URL where the photos are hosted").action(withCommandContext((options, ui) => build(options, ui)));
1310
+ program.command("clean").description("Remove all gallery files and folders (index.html, gallery/)").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process3.cwd()).option("-r, --recursive", "Clean subdirectories recursively", false).action(withCommandContext((options, ui) => clean(options, ui)));
1311
+ program.command("telemetry").description("Manage anonymous telemetry preferences. Use 1 to enable, 0 to disable, or no argument to check status").option("-s, --state <state>", "Enable (1) or disable (0) telemetry", parseTelemetryOption).action(withCommandContext((options, ui) => telemetry(options, ui, telemetryService)));
901
1312
  program.parse();
902
1313
  //# sourceMappingURL=index.js.map
903
1314
  //# sourceMappingURL=index.js.map