exiftool-vendored 33.1.0 → 33.3.0

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/Timezones.js CHANGED
@@ -161,6 +161,29 @@ const Zulus = [
161
161
  "UTC+00:00",
162
162
  "GMT+00:00",
163
163
  ];
164
+ /**
165
+ * Check if a timezone value represents UTC.
166
+ *
167
+ * Handles multiple UTC representations including Zone instances, strings, and
168
+ * numeric offsets. Recognizes common UTC aliases like "GMT", "Z", "Zulu",
169
+ * "+0", "+00:00", etc.
170
+ *
171
+ * @param zone - Timezone to check (Zone, string, or number)
172
+ * @returns true if the zone represents UTC/GMT/Zulu
173
+ *
174
+ * @example
175
+ * ```typescript
176
+ * isUTC("UTC") // true
177
+ * isUTC("GMT") // true
178
+ * isUTC("Z") // true
179
+ * isUTC("Zulu") // true
180
+ * isUTC(0) // true
181
+ * isUTC("+00:00") // true
182
+ * isUTC("UTC+0") // true
183
+ * isUTC("America/New_York") // false
184
+ * isUTC("+08:00") // false
185
+ * ```
186
+ */
164
187
  function isUTC(zone) {
165
188
  if (zone == null) {
166
189
  return false;
@@ -173,15 +196,88 @@ function isUTC(zone) {
173
196
  }
174
197
  return false;
175
198
  }
199
+ /**
200
+ * Check if a Zone is the library's sentinel "unset" value.
201
+ *
202
+ * The library uses a special Zone instance to represent unknown/unset
203
+ * timezones since Luxon doesn't officially support unset zones.
204
+ *
205
+ * @param zone - Zone instance to check
206
+ * @returns true if the zone is the UnsetZone sentinel value
207
+ *
208
+ * @see {@link UnsetZone}
209
+ * @see {@link UnsetZoneName}
210
+ * @see {@link UnsetZoneOffsetMinutes}
211
+ *
212
+ * @example
213
+ * ```typescript
214
+ * isZoneUnset(UnsetZone) // true
215
+ * isZoneUnset(Info.normalizeZone("UTC")) // false
216
+ * isZoneUnset(Info.normalizeZone("UTC+8")) // false
217
+ * ```
218
+ */
176
219
  function isZoneUnset(zone) {
177
220
  return zone.isUniversal && zone.offset(0) === exports.UnsetZoneOffsetMinutes;
178
221
  }
222
+ /**
223
+ * Type guard to check if a Zone is valid and usable.
224
+ *
225
+ * A zone is considered valid if it:
226
+ * - Is not null/undefined
227
+ * - Has `isValid === true` (Luxon requirement)
228
+ * - Is not the library's UnsetZone sentinel
229
+ * - Has an offset within ±14 hours (the valid range for real-world timezones)
230
+ *
231
+ * This is the canonical validation check used throughout the library.
232
+ *
233
+ * @param zone - Zone to validate
234
+ * @returns true if the zone is valid and usable (type guard)
235
+ *
236
+ * @example
237
+ * ```typescript
238
+ * const zone = Info.normalizeZone("America/Los_Angeles")
239
+ * if (isZoneValid(zone)) {
240
+ * // TypeScript knows zone is Zone (not Zone | undefined)
241
+ * console.log(zone.name)
242
+ * }
243
+ *
244
+ * isZoneValid(Info.normalizeZone("invalid")) // false
245
+ * isZoneValid(Info.normalizeZone("UTC+8")) // true
246
+ * isZoneValid(UnsetZone) // false
247
+ * isZoneValid(Info.normalizeZone("UTC+20")) // false (beyond ±14 hours)
248
+ * ```
249
+ */
179
250
  function isZoneValid(zone) {
180
251
  return (zone != null &&
181
252
  zone.isValid &&
182
253
  !isZoneUnset(zone) &&
183
254
  Math.abs(zone.offset(Date.now())) < 14 * 60);
184
255
  }
256
+ /**
257
+ * Type guard to check if a value is a Luxon Zone instance.
258
+ *
259
+ * Checks both `instanceof Zone` and constructor name to handle cross-module
260
+ * Zone instances that may not pass instanceof checks.
261
+ *
262
+ * @param zone - Value to check
263
+ * @returns true if the value is a Zone instance (type guard)
264
+ *
265
+ * @example
266
+ * ```typescript
267
+ * import { Info } from "luxon"
268
+ *
269
+ * const zone = Info.normalizeZone("UTC+8")
270
+ * if (isZone(zone)) {
271
+ * // TypeScript knows zone is Zone (not unknown)
272
+ * console.log(zone.offset(Date.now()))
273
+ * }
274
+ *
275
+ * isZone(Info.normalizeZone("UTC")) // true
276
+ * isZone("UTC") // false
277
+ * isZone(480) // false
278
+ * isZone(null) // false
279
+ * ```
280
+ */
185
281
  function isZone(zone) {
186
282
  return ((0, Object_1.isObject)(zone) && (zone instanceof luxon_1.Zone || zone.constructor.name === "Zone"));
187
283
  }
@@ -197,7 +293,9 @@ exports.defaultVideosToUTC = "defaultVideosToUTC";
197
293
  const IanaFormatRE = /^\w{2,15}(?:\/\w{3,15}){0,2}$/;
198
294
  // Luxon requires fixed-offset zones to look like "UTC+H", "UTC-H",
199
295
  // "UTC+H:mm", "UTC-H:mm":
200
- const FixedFormatRE = /^UTC(?<sign>[+-])(?<hours>\d+)(?::(?<minutes>\d{2}))?$/;
296
+ // Also handles Unicode minus (−, U+2212)
297
+ const FixedFormatRE = /^UTC(?<sign>[+−-])(?<hours>\d+)(?::(?<minutes>\d{2}))?$/;
298
+ const MinusRE = /[−-]/;
201
299
  function parseFixedOffset(str) {
202
300
  const match = FixedFormatRE.exec(str)?.groups;
203
301
  if (match == null)
@@ -206,12 +304,56 @@ function parseFixedOffset(str) {
206
304
  const m = (0, Number_1.toInt)(match.minutes) ?? 0;
207
305
  if (h == null || h < 0 || h > 14 || m < 0 || m >= 60)
208
306
  return;
209
- const result = (match.sign === "-" ? -1 : 1) * (h * 60 + m);
307
+ // Handle both ASCII minus (-) and Unicode minus (−, U+2212)
308
+ const result = (MinusRE.test(match.sign ?? "") ? -1 : 1) * (h * 60 + m);
210
309
  return (validOffsetMinutes().has(result) ? result : undefined) ?? undefined;
211
310
  }
212
311
  /**
213
- * @param input must be either a number, which is the offset in minutes, or a
214
- * string in the format "UTC+H" or "UTC+HH:mm"
312
+ * Normalize a timezone input to a valid Luxon Zone.
313
+ *
314
+ * Accepts multiple input formats and returns a validated Zone instance, or
315
+ * undefined if the input cannot be normalized to a valid timezone.
316
+ *
317
+ * Supported input formats:
318
+ * - **Numbers**: Timezone offset in minutes (e.g., 480 = UTC+8, -300 = UTC-5)
319
+ * - **Strings**: ISO offsets ("+08:00", "-05:00"), IANA zones
320
+ * ("America/Los_Angeles"), UTC variants ("UTC", "GMT", "Z", "Zulu")
321
+ * - **Zone instances**: Validated and returned if valid
322
+ *
323
+ * The function respects Settings:
324
+ * - {@link Settings.allowArchaicTimezoneOffsets} for pre-1982 offsets
325
+ * - {@link Settings.allowBakerIslandTime} for UTC-12:00
326
+ *
327
+ * @param input - Timezone in various formats
328
+ * @returns Valid Zone instance, or undefined if invalid
329
+ *
330
+ * @example
331
+ * ```typescript
332
+ * // Numbers (offset in minutes)
333
+ * normalizeZone(480)?.name // "UTC+8"
334
+ * normalizeZone(-300)?.name // "UTC-5"
335
+ * normalizeZone(0)?.name // "UTC"
336
+ *
337
+ * // ISO offset strings
338
+ * normalizeZone("+08:00")?.name // "UTC+8"
339
+ * normalizeZone("-05:30")?.name // "UTC-5:30"
340
+ * normalizeZone("UTC+7")?.name // "UTC+7"
341
+ *
342
+ * // IANA timezone names
343
+ * normalizeZone("America/Los_Angeles")?.name // "America/Los_Angeles"
344
+ * normalizeZone("Asia/Tokyo")?.name // "Asia/Tokyo"
345
+ *
346
+ * // UTC aliases
347
+ * normalizeZone("UTC")?.name // "UTC"
348
+ * normalizeZone("GMT")?.name // "UTC"
349
+ * normalizeZone("Z")?.name // "UTC"
350
+ * normalizeZone("Zulu")?.name // "UTC"
351
+ *
352
+ * // Invalid inputs return undefined
353
+ * normalizeZone("invalid") // undefined
354
+ * normalizeZone("+25:00") // undefined (beyond ±14 hours)
355
+ * normalizeZone(null) // undefined
356
+ * ```
215
357
  */
216
358
  function normalizeZone(input) {
217
359
  if (input == null ||
@@ -251,13 +393,73 @@ function normalizeZone(input) {
251
393
  }
252
394
  }
253
395
  /**
254
- * @param ts must be provided if the zone is not a fixed offset
255
- * @return the zone offset (in "±HH:MM" format) for the given zone, or "" if
256
- * the zone is invalid
396
+ * Convert a timezone to its short offset format (e.g., "+08:00", "-05:00").
397
+ *
398
+ * Useful for displaying timezone offsets in a standardized format. For IANA
399
+ * zones with daylight saving time, provide a timestamp to get the correct
400
+ * offset for that moment.
401
+ *
402
+ * @param zone - Timezone as Zone, string, or offset in minutes
403
+ * @param ts - Optional timestamp (milliseconds) for IANA zone offset calculation.
404
+ * Defaults to current time if not provided.
405
+ * @returns Zone offset in "+HH:MM" format, or "" if zone is invalid
406
+ *
407
+ * @example
408
+ * ```typescript
409
+ * // Fixed offsets
410
+ * zoneToShortOffset("UTC+8") // "+08:00"
411
+ * zoneToShortOffset(480) // "+08:00"
412
+ * zoneToShortOffset("UTC-5:30") // "-05:30"
413
+ *
414
+ * // IANA zones (offset depends on DST)
415
+ * const winter = new Date("2023-01-15").getTime()
416
+ * const summer = new Date("2023-07-15").getTime()
417
+ * zoneToShortOffset("America/Los_Angeles", winter) // "-08:00" (PST)
418
+ * zoneToShortOffset("America/Los_Angeles", summer) // "-07:00" (PDT)
419
+ *
420
+ * // Invalid zones return empty string
421
+ * zoneToShortOffset("invalid") // ""
422
+ * zoneToShortOffset(null) // ""
423
+ * ```
257
424
  */
258
425
  function zoneToShortOffset(zone, ts) {
259
426
  return normalizeZone(zone)?.formatOffset(ts ?? Date.now(), "short") ?? "";
260
427
  }
428
+ /**
429
+ * Type guard to check if a numeric offset (in minutes) represents a valid timezone.
430
+ *
431
+ * Validates that the offset:
432
+ * - Is a number (not null/undefined)
433
+ * - Is not the UnsetZone sentinel value (-1)
434
+ * - Matches a real-world timezone offset (respects Settings for archaic offsets)
435
+ *
436
+ * Use this for exact validation without rounding. For error-tolerant rounding to
437
+ * the nearest valid offset, use {@link inferLikelyOffsetMinutes} instead.
438
+ *
439
+ * @param tzOffsetMinutes - Offset in minutes to validate (e.g., 480 for UTC+8)
440
+ * @returns true if the offset is exactly valid (type guard)
441
+ *
442
+ * @see {@link inferLikelyOffsetMinutes} for error-tolerant rounding
443
+ *
444
+ * @example
445
+ * ```typescript
446
+ * validTzOffsetMinutes(480) // true (UTC+8)
447
+ * validTzOffsetMinutes(-300) // true (UTC-5)
448
+ * validTzOffsetMinutes(330) // true (UTC+5:30, India)
449
+ * validTzOffsetMinutes(345) // true (UTC+5:45, Nepal)
450
+ *
451
+ * validTzOffsetMinutes(481) // false (not a valid timezone)
452
+ * validTzOffsetMinutes(-1) // false (UnsetZone sentinel)
453
+ * validTzOffsetMinutes(null) // false
454
+ *
455
+ * // Archaic offsets require Settings
456
+ * Settings.allowArchaicTimezoneOffsets.value = false
457
+ * validTzOffsetMinutes(-630) // false (Hawaii -10:30, archaic)
458
+ *
459
+ * Settings.allowArchaicTimezoneOffsets.value = true
460
+ * validTzOffsetMinutes(-630) // true (Hawaii -10:30, archaic)
461
+ * ```
462
+ */
261
463
  function validTzOffsetMinutes(tzOffsetMinutes) {
262
464
  return (tzOffsetMinutes != null &&
263
465
  (0, Number_1.isNumber)(tzOffsetMinutes) &&
@@ -287,8 +489,9 @@ function tzHourToOffset(n) {
287
489
  }
288
490
  // Accept "Z", "UTC+2", "UTC+02", "UTC+2:00", "UTC+02:00", "+2", "+02", and
289
491
  // "+02:00". Also accepts seconds like "-00:25:21" for archaic offsets.
492
+ // Handles Unicode minus (−, U+2212)
290
493
  // Require the sign (+ or -) and a ":" separator if there are minutes.
291
- const tzRe = /(?<Z>Z)|((UTC)?(?<sign>[+-])(?<hours>\d\d?)(?::(?<minutes>\d\d)(?::(?<seconds>\d\d))?)?)$/;
494
+ const tzRe = /(?<Z>Z)|((UTC)?(?<sign>[+−-])(?<hours>\d\d?)(?::(?<minutes>\d\d)(?::(?<seconds>\d\d))?)?)$/;
292
495
  function extractOffsetFromHours(hourOffset) {
293
496
  return (0, Number_1.isNumber)(hourOffset)
294
497
  ? (0, Maybe_1.map)(tzHourToOffset(hourOffset), (zone) => ({
@@ -301,12 +504,66 @@ function extractOffsetFromHours(hourOffset) {
301
504
  : undefined;
302
505
  }
303
506
  /**
304
- * Parse a timezone offset and return the offset minutes
507
+ * Extract timezone information from various value types.
508
+ *
509
+ * Handles multiple input formats and performs intelligent parsing:
510
+ * - **Strings**: ISO offsets ("+08:00"), IANA zones, UTC variants, timestamps
511
+ * with embedded timezones ("2023:01:15 10:30:00-08:00")
512
+ * - **Numbers**: Hour offsets (e.g., -8 for UTC-8)
513
+ * - **Arrays**: Uses first non-null value
514
+ * - **ExifDateTime/ExifTime instances**: Extracts their zone property
515
+ *
516
+ * By default, strips timezone abbreviations (PST, PDT, etc.) as they are
517
+ * ambiguous. Returns provenance information indicating which parsing method
518
+ * succeeded.
519
+ *
520
+ * Supports Unicode minus signs (−, U+2212) and plus-minus signs (±, U+00B1)
521
+ * in addition to ASCII +/-.
522
+ *
523
+ * @param value - Value to extract timezone from
524
+ * @param opts.stripTZA - Whether to strip timezone abbreviations (default: true).
525
+ * TZAs like "PST" are ambiguous and usually stripped.
526
+ * @returns TzSrc with zone name and provenance, or undefined if no timezone found
527
+ *
528
+ * @example
529
+ * ```typescript
530
+ * // ISO offset strings
531
+ * extractZone("+08:00")
532
+ * // { zone: "UTC+8", tz: "UTC+8", src: "offsetMinutesToZoneName" }
305
533
  *
306
- * @param opts.stripTZA If false, do not strip off the timezone abbreviation
307
- * (TZA) from the value. Defaults to true.
534
+ * extractZone("UTC-5:30")
535
+ * // { zone: "UTC-5:30", tz: "UTC-5:30", src: "normalizeZone" }
308
536
  *
309
- * @return undefined if the value cannot be parsed as a valid timezone offset
537
+ * // IANA zone names
538
+ * extractZone("America/Los_Angeles")
539
+ * // { zone: "America/Los_Angeles", tz: "America/Los_Angeles", src: "normalizeZone" }
540
+ *
541
+ * // Timestamps with embedded timezones
542
+ * extractZone("2023:01:15 10:30:00-08:00")
543
+ * // { zone: "UTC-8", tz: "UTC-8", src: "offsetMinutesToZoneName",
544
+ * // leftovers: "2023:01:15 10:30:00" }
545
+ *
546
+ * // Unicode minus signs
547
+ * extractZone("−08:00") // Unicode minus (U+2212)
548
+ * // { zone: "UTC-8", tz: "UTC-8", src: "offsetMinutesToZoneName" }
549
+ *
550
+ * // Numeric hour offsets
551
+ * extractZone(-8)
552
+ * // { zone: "UTC-8", tz: "UTC-8", src: "hourOffset" }
553
+ *
554
+ * // Arrays (uses first non-null)
555
+ * extractZone([null, "+05:30", undefined])
556
+ * // { zone: "UTC+5:30", tz: "UTC+5:30", src: "offsetMinutesToZoneName" }
557
+ *
558
+ * // Strips timezone abbreviations by default
559
+ * extractZone("2023:01:15 10:30:00-08:00 PST")
560
+ * // { zone: "UTC-8", tz: "UTC-8", src: "offsetMinutesToZoneName",
561
+ * // leftovers: "2023:01:15 10:30:00" }
562
+ *
563
+ * // Invalid inputs return undefined
564
+ * extractZone("invalid") // undefined
565
+ * extractZone(null) // undefined
566
+ * ```
310
567
  */
311
568
  function extractZone(value, opts) {
312
569
  if (value == null ||
@@ -375,7 +632,8 @@ function extractZone(value, opts) {
375
632
  const hours = parseInt(capturedGroups.hours ?? "0");
376
633
  const minutes = parseInt(capturedGroups.minutes ?? "0");
377
634
  const seconds = parseInt(capturedGroups.seconds ?? "0");
378
- const sign = capturedGroups.sign === "-" ? -1 : 1;
635
+ // Handle both ASCII minus (-) and Unicode minus (−, U+2212)
636
+ const sign = MinusRE.test(capturedGroups.sign ?? "") ? -1 : 1;
379
637
  const offsetMinutes = sign * (hours * 60 + minutes + seconds / 60);
380
638
  const zone = offsetMinutesToZoneName(offsetMinutes);
381
639
  if (zone != null) {
@@ -417,6 +675,47 @@ function incrementZone(z, minutes) {
417
675
  ? luxon_1.FixedOffsetZone.instance(fixed + minutes)
418
676
  : undefined;
419
677
  }
678
+ /**
679
+ * Extract timezone offset from standard EXIF timezone tags.
680
+ *
681
+ * Checks timezone tags in priority order:
682
+ * 1. TimeZone
683
+ * 2. OffsetTimeOriginal (for DateTimeOriginal)
684
+ * 3. OffsetTimeDigitized (for CreateDate)
685
+ * 4. TimeZoneOffset
686
+ *
687
+ * Handles camera-specific quirks like Nikon's DaylightSavings tag, which
688
+ * requires adjusting the TimeZone offset forward by one hour during DST.
689
+ *
690
+ * @param t - EXIF tags object
691
+ * @param opts.adjustTimeZoneIfDaylightSavings - Optional function to adjust
692
+ * timezone for DST. Defaults to handling Nikon's DaylightSavings quirk.
693
+ * @returns TzSrc with zone and provenance, or undefined if no timezone found
694
+ *
695
+ * @see {@link TimezoneOffsetTagnames} for the list of tags checked
696
+ * @see https://github.com/photostructure/exiftool-vendored.js/issues/215
697
+ *
698
+ * @example
699
+ * ```typescript
700
+ * const tags = await exiftool.read("photo.jpg")
701
+ *
702
+ * const tzSrc = extractTzOffsetFromTags(tags)
703
+ * if (tzSrc) {
704
+ * console.log(`Timezone: ${tzSrc.zone}`)
705
+ * console.log(`Source: ${tzSrc.src}`) // e.g., "OffsetTimeOriginal"
706
+ * }
707
+ *
708
+ * // Nikon DST handling
709
+ * const nikonTags = {
710
+ * TimeZone: "-08:00",
711
+ * DaylightSavings: "Yes",
712
+ * Make: "NIKON CORPORATION"
713
+ * }
714
+ * extractTzOffsetFromTags(nikonTags)
715
+ * // { zone: "UTC-7", tz: "UTC-7",
716
+ * // src: "TimeZone (adjusted for DaylightSavings)" }
717
+ * ```
718
+ */
420
719
  function extractTzOffsetFromTags(t, opts) {
421
720
  const adjustFn = opts?.adjustTimeZoneIfDaylightSavings ??
422
721
  DefaultExifToolOptions_1.defaultAdjustTimeZoneIfDaylightSavings;
@@ -506,12 +805,67 @@ Settings_1.Settings.allowArchaicTimezoneOffsets.onChange(() => {
506
805
  Settings_1.Settings.allowBakerIslandTime.onChange(() => {
507
806
  likelyOffsetMinutes.clear();
508
807
  });
509
- function inferLikelyOffsetMinutes(deltaMinutes) {
510
- const nearest = (0, Array_1.leastBy)(likelyOffsetMinutes(), (ea) => Math.abs(ea - deltaMinutes));
511
- // Reject timezone offsets more than 30 minutes away from the nearest:
512
- return nearest != null && Math.abs(nearest - deltaMinutes) < 30
513
- ? nearest
514
- : undefined;
808
+ /**
809
+ * Round an arbitrary offset to the nearest valid timezone offset.
810
+ *
811
+ * This is error-tolerant timezone inference, useful for:
812
+ * - GPS-based timezone calculation (where GPS time drift may cause errors)
813
+ * - Handling clock drift in timestamp comparisons
814
+ * - Fuzzy timezone matching
815
+ *
816
+ * By default, uses {@link Settings.maxValidOffsetMinutes} (30 minutes) as the
817
+ * maximum distance from a valid timezone. This threshold handles GPS acquisition
818
+ * lag and clock drift while preventing false matches.
819
+ *
820
+ * Respects Settings for archaic offsets, Baker Island time, and max offset tolerance.
821
+ *
822
+ * @param deltaMinutes - Offset in minutes to round (can be fractional)
823
+ * @param maxValidOffsetMinutes - Maximum distance (in minutes) from a valid
824
+ * timezone to accept. Defaults to {@link Settings.maxValidOffsetMinutes}.
825
+ * @returns Nearest valid offset in minutes, or undefined if too far from any
826
+ * valid timezone
827
+ *
828
+ * @see {@link validTzOffsetMinutes} for exact validation without rounding
829
+ * @see {@link Settings.maxValidOffsetMinutes} to configure the default threshold
830
+ *
831
+ * @example
832
+ * ```typescript
833
+ * // Exact matches
834
+ * inferLikelyOffsetMinutes(480) // 480 (UTC+8, exact)
835
+ * inferLikelyOffsetMinutes(-300) // -300 (UTC-5, exact)
836
+ *
837
+ * // Rounding within default threshold (30 minutes)
838
+ * inferLikelyOffsetMinutes(485) // 480 (UTC+8, rounded from 485)
839
+ * inferLikelyOffsetMinutes(-295) // -300 (UTC-5, rounded from -295)
840
+ * inferLikelyOffsetMinutes(330.5) // 330 (UTC+5:30, rounded)
841
+ *
842
+ * // GPS-based inference with clock drift (within 30 min default)
843
+ * const gpsTime = "2023:01:15 19:30:45" // UTC
844
+ * const localTime = "2023:01:15 11:32:12" // Local with 1.5min drift
845
+ * const deltaMinutes = 480 + 1.5 // ~481.5 minutes
846
+ * inferLikelyOffsetMinutes(deltaMinutes) // 480 (UTC+8)
847
+ *
848
+ * // GPS lag up to 23 minutes still works (within 30 min threshold)
849
+ * inferLikelyOffsetMinutes(443) // 420 (UTC-7, ~23 min from actual)
850
+ *
851
+ * // Beyond threshold returns undefined
852
+ * inferLikelyOffsetMinutes(100) // undefined (not near any valid offset)
853
+ *
854
+ * // Custom threshold
855
+ * inferLikelyOffsetMinutes(495, 30) // 480 (UTC+8 with 30min threshold)
856
+ * inferLikelyOffsetMinutes(495, 15) // undefined (beyond 15min threshold)
857
+ *
858
+ * // Adjust global default
859
+ * Settings.maxValidOffsetMinutes.value = 15 // Stricter matching
860
+ * inferLikelyOffsetMinutes(443) // undefined (beyond 15min threshold)
861
+ * ```
862
+ */
863
+ function inferLikelyOffsetMinutes(deltaMinutes, maxValidOffsetMinutes = Settings_1.Settings.maxValidOffsetMinutes.value) {
864
+ return (0, Array_1.leastBy)(likelyOffsetMinutes(), (ea) => {
865
+ const diff = Math.abs(ea - deltaMinutes);
866
+ // Reject timezone offsets more than maxValidOffsetMinutes minutes away:
867
+ return diff > maxValidOffsetMinutes ? undefined : diff;
868
+ });
515
869
  }
516
870
  /**
517
871
  * Convert blank strings to undefined.
@@ -519,6 +873,62 @@ function inferLikelyOffsetMinutes(deltaMinutes) {
519
873
  function toNotBlank(x) {
520
874
  return x == null || (typeof x === "string" && (0, String_1.blank)(x)) ? undefined : x;
521
875
  }
876
+ /**
877
+ * Infer timezone offset by comparing local time with GPS/UTC time.
878
+ *
879
+ * Calculates the timezone by finding the difference between:
880
+ * - A "captured at" timestamp (DateTimeOriginal, CreateDate, etc.) assumed to
881
+ * be in local time
882
+ * - A UTC timestamp (GPSDateTime, DateTimeUTC, or combined GPSDateStamp +
883
+ * GPSTimeStamp)
884
+ *
885
+ * Uses {@link inferLikelyOffsetMinutes} to handle minor clock drift and round
886
+ * to the nearest valid timezone offset.
887
+ *
888
+ * This is a fallback when explicit timezone tags are not available.
889
+ *
890
+ * @param t - Tags object with timestamp fields
891
+ * @returns TzSrc with inferred timezone and provenance, or undefined if
892
+ * inference is not possible
893
+ *
894
+ * @see {@link extractTzOffsetFromTags} to check explicit timezone tags first
895
+ *
896
+ * @example
897
+ * ```typescript
898
+ * // GPS-based inference
899
+ * const tags = {
900
+ * DateTimeOriginal: "2023:01:15 11:30:00", // Local time (PST)
901
+ * GPSDateTime: "2023:01:15 19:30:00" // UTC
902
+ * }
903
+ * extractTzOffsetFromUTCOffset(tags)
904
+ * // { zone: "UTC-8", tz: "UTC-8",
905
+ * // src: "offset between DateTimeOriginal and GPSDateTime" }
906
+ *
907
+ * // DateTimeUTC-based inference
908
+ * const tags2 = {
909
+ * CreateDate: "2023:07:20 14:15:30", // Local time (JST)
910
+ * DateTimeUTC: "2023:07:20 05:15:30" // UTC
911
+ * }
912
+ * extractTzOffsetFromUTCOffset(tags2)
913
+ * // { zone: "UTC+9", tz: "UTC+9",
914
+ * // src: "offset between CreateDate and DateTimeUTC" }
915
+ *
916
+ * // Handles clock drift
917
+ * const tags3 = {
918
+ * DateTimeOriginal: "2023:01:15 11:30:45", // Local with drift
919
+ * GPSDateTime: "2023:01:15 19:29:58" // UTC (old GPS fix)
920
+ * }
921
+ * extractTzOffsetFromUTCOffset(tags3)
922
+ * // Still infers UTC-8 despite ~1 minute drift
923
+ *
924
+ * // No UTC timestamp available
925
+ * const tags4 = {
926
+ * DateTimeOriginal: "2023:01:15 11:30:00"
927
+ * // No GPS or UTC timestamp
928
+ * }
929
+ * extractTzOffsetFromUTCOffset(tags4) // undefined
930
+ * ```
931
+ */
522
932
  function extractTzOffsetFromUTCOffset(t) {
523
933
  const utcSources = {
524
934
  ...(0, Pick_1.pick)(t, "GPSDateTime", "DateTimeUTC", "SonyDateTime2"),
@@ -571,12 +981,66 @@ function extractTzOffsetFromUTCOffset(t) {
571
981
  src: `offset between ${dt.tagName} and ${utc.tagName}`,
572
982
  }));
573
983
  }
574
- function equivalentZones(a, b) {
984
+ /**
985
+ * Check if two timezone values are equivalent at a specific point in time.
986
+ *
987
+ * Two zones are considered equivalent if they:
988
+ * - Are the same zone (via Luxon's Zone.equals()), OR
989
+ * - Have the same offset at the specified timestamp
990
+ *
991
+ * This is useful for:
992
+ * - De-duplicating timezone records
993
+ * - Comparing zones in different formats ("UTC+5" vs "UTC+05:00")
994
+ * - Matching IANA zones to their offset at a specific time
995
+ *
996
+ * For IANA zones with DST, you can specify a timestamp to evaluate equivalence
997
+ * at that moment. This is important when comparing historical records or future
998
+ * events where DST transitions matter.
999
+ *
1000
+ * @param a - First timezone (Zone, string, or offset in minutes)
1001
+ * @param b - Second timezone (Zone, string, or offset in minutes)
1002
+ * @param at - Timestamp in milliseconds to evaluate zone offsets.
1003
+ * Defaults to current time (Date.now()).
1004
+ * @returns true if zones are equivalent at the specified time
1005
+ *
1006
+ * @example
1007
+ * ```typescript
1008
+ * // Same zone, different formats
1009
+ * equivalentZones("UTC+5", "UTC+05:00") // true
1010
+ * equivalentZones("UTC-8", -480) // true (480 minutes = 8 hours)
1011
+ * equivalentZones("GMT", "UTC") // true
1012
+ * equivalentZones("Z", 0) // true
1013
+ *
1014
+ * // IANA zones matched by current offset (default behavior)
1015
+ * equivalentZones("America/New_York", "UTC-5") // true in winter (EST)
1016
+ * equivalentZones("America/New_York", "UTC-4") // true in summer (EDT)
1017
+ *
1018
+ * // IANA zones at specific times
1019
+ * const winter = new Date("2023-01-15").getTime()
1020
+ * const summer = new Date("2023-07-15").getTime()
1021
+ * equivalentZones("America/New_York", "UTC-5", winter) // true (EST)
1022
+ * equivalentZones("America/New_York", "UTC-4", winter) // false (not EDT in winter)
1023
+ * equivalentZones("America/New_York", "UTC-4", summer) // true (EDT)
1024
+ * equivalentZones("America/New_York", "UTC-5", summer) // false (not EST in summer)
1025
+ *
1026
+ * // Compare two IANA zones at a specific time
1027
+ * equivalentZones("America/New_York", "America/Toronto", winter) // true (both EST)
1028
+ * equivalentZones("America/New_York", "America/Los_Angeles", winter) // false (EST vs PST)
1029
+ *
1030
+ * // Different zones
1031
+ * equivalentZones("UTC+8", "UTC+9") // false
1032
+ *
1033
+ * // Invalid zones return false
1034
+ * equivalentZones("invalid", "UTC") // false
1035
+ * equivalentZones(null, "UTC") // false
1036
+ * ```
1037
+ */
1038
+ function equivalentZones(a, b, at = Date.now()) {
575
1039
  const az = normalizeZone(a);
576
1040
  const bz = normalizeZone(b);
577
1041
  return (az != null &&
578
1042
  bz != null &&
579
- (az.equals(bz) || az.offset(Date.now()) === bz.offset(Date.now())));
1043
+ (az.equals(bz) || az.offset(at) === bz.offset(at)));
580
1044
  }
581
1045
  function getZoneName(args = {}) {
582
1046
  const result = normalizeZone(args.zone)?.name ??