292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.ExifDateTime = void 0;
|
|
const luxon_1 = require("luxon");
|
|
const DateTime_1 = require("./DateTime");
|
|
const Maybe_1 = require("./Maybe");
|
|
const Object_1 = require("./Object");
|
|
const String_1 = require("./String");
|
|
const TimeParsing_1 = require("./TimeParsing");
|
|
const Timezones_1 = require("./Timezones");
|
|
/**
|
|
* Encapsulates encoding and decoding EXIF date and time strings,
|
|
* along with timezone handling functionality.
|
|
*
|
|
* Key features:
|
|
* - Parses datetime strings in various formats (EXIF strict/loose, ISO)
|
|
* - Supports timezone inference, conversion, and matching between dates
|
|
* - Preserves original string representations when available
|
|
* - Provides conversion to/from multiple datetime formats (Luxon DateTime, JS
|
|
* Date, ISO strings)
|
|
* - Supports serialization/deserialization via JSON (see
|
|
* {@link ExifDateTime.fromJSON})
|
|
*
|
|
* EXIF datetime strings don't typically include timezone information. This
|
|
* class provides mechanisms to associate timezone data from other EXIF tags
|
|
* (like GPS position or timezone offset), and distinguishes between explicitly
|
|
* set and inferred timezone information.
|
|
*/
|
|
class ExifDateTime {
|
|
year;
|
|
month;
|
|
day;
|
|
hour;
|
|
minute;
|
|
second;
|
|
millisecond;
|
|
tzoffsetMinutes;
|
|
rawValue;
|
|
zoneName;
|
|
inferredZone;
|
|
static from(exifOrIso, defaultZone) {
|
|
return exifOrIso instanceof ExifDateTime
|
|
? exifOrIso // already an ExifDateTime
|
|
: (0, String_1.blank)(exifOrIso)
|
|
? undefined // in order of strictness:
|
|
: (this.fromExifStrict(exifOrIso, defaultZone) ??
|
|
this.fromISO(exifOrIso, defaultZone) ??
|
|
this.fromExifLoose(exifOrIso, defaultZone));
|
|
}
|
|
static fromISO(iso, defaultZone) {
|
|
if ((0, String_1.blank)(iso) || null != iso.match(/^\d+$/))
|
|
return undefined;
|
|
// Unfortunately, DateTime.fromISO() is happy to parse a date with no time,
|
|
// so we have to do this ourselves:
|
|
return this.#fromPatterns(iso, (0, TimeParsing_1.timeFormats)({
|
|
formatPrefixes: ["y-MM-dd'T'", "y-MM-dd ", "y-M-d "],
|
|
defaultZone,
|
|
}));
|
|
}
|
|
/**
|
|
* Try to parse a date-time string from EXIF. If there is not both a date
|
|
* and a time component, returns `undefined`.
|
|
*
|
|
* @param text from EXIF metadata
|
|
* @param defaultZone a "zone name" to use as a backstop, or default, if
|
|
* `text` doesn't specify a zone. This may be IANA-formatted, like
|
|
* "America/Los_Angeles", or an offset, like "UTC-3". See
|
|
* `offsetMinutesToZoneName`.
|
|
*/
|
|
static fromEXIF(text, defaultZone) {
|
|
if ((0, String_1.blank)(text))
|
|
return undefined;
|
|
return (
|
|
// .fromExifStrict() uses .fromISO() as a backstop
|
|
this.fromExifStrict(text, defaultZone) ??
|
|
this.fromExifLoose(text, defaultZone));
|
|
}
|
|
static #fromPatterns(text, fmts) {
|
|
const result = (0, TimeParsing_1.parseDateTime)(text, fmts);
|
|
return result == null
|
|
? undefined
|
|
: ExifDateTime.fromDateTime(result.dt, {
|
|
rawValue: text,
|
|
unsetMilliseconds: result.unsetMilliseconds,
|
|
inferredZone: result.inferredZone,
|
|
});
|
|
}
|
|
/**
|
|
* Parse the given date-time string, EXIF-formatted.
|
|
*
|
|
* @param text from EXIF metadata, in `y:M:d H:m:s` format (with optional
|
|
* sub-seconds and/or timezone)
|
|
|
|
* @param defaultZone a "zone name" to use as a backstop, or default, if
|
|
* `text` doesn't specify a zone. This may be IANA-formatted, like
|
|
* "America/Los_Angeles", or an offset, like "UTC-3". See
|
|
* `offsetMinutesToZoneName`.
|
|
*/
|
|
static fromExifStrict(text, defaultZone) {
|
|
if ((0, String_1.blank)(text) || !(0, String_1.isString)(text))
|
|
return undefined;
|
|
return (this.#fromPatterns(text, (0, TimeParsing_1.timeFormats)({ formatPrefixes: ["y:MM:dd ", "y:M:d "], defaultZone })) ??
|
|
// Not found yet? Maybe it's in ISO format? See
|
|
// https://github.com/photostructure/exiftool-vendored.js/issues/71
|
|
this.fromISO(text, defaultZone));
|
|
}
|
|
static *#looseExifFormats(defaultZone) {
|
|
// The following are from actual datestamps seen in the wild (!!)
|
|
const formats = [
|
|
"MMM d y HH:mm:ss",
|
|
"MMM d y, HH:mm:ss",
|
|
// Thu Oct 13 00:12:27 2016:
|
|
"ccc MMM d HH:mm:ss y",
|
|
];
|
|
const zone = (0, String_1.notBlank)(defaultZone) ? defaultZone : Timezones_1.UnsetZone;
|
|
for (const fmt of formats) {
|
|
yield { fmt: fmt, zone, inferredZone: true };
|
|
}
|
|
}
|
|
static fromExifLoose(text, defaultZone) {
|
|
return (0, String_1.blank)(text) || !(0, String_1.isString)(text)
|
|
? undefined
|
|
: this.#fromPatterns(text, this.#looseExifFormats(defaultZone));
|
|
}
|
|
static fromDateTime(dt, opts) {
|
|
if (dt == null || !dt.isValid || dt.year === 0 || dt.year === 1) {
|
|
return undefined;
|
|
}
|
|
return new ExifDateTime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.millisecond === 0 && true === opts?.unsetMilliseconds
|
|
? undefined
|
|
: dt.millisecond, dt.offset === Timezones_1.UnsetZoneOffsetMinutes ? undefined : dt.offset, opts?.rawValue, dt.zoneName == null || dt.zone?.name === Timezones_1.UnsetZone.name
|
|
? undefined
|
|
: dt.zoneName, opts?.inferredZone);
|
|
}
|
|
/**
|
|
* Create an ExifDateTime from a number of milliseconds since the epoch
|
|
* (meaning since 1 January 1970 00:00:00 UTC). Uses the default zone.
|
|
*
|
|
* @param millis - a number of milliseconds since 1970 UTC
|
|
*
|
|
* @param options.rawValue - the original parsed string input
|
|
* @param options.zone - the zone to place the DateTime into. Defaults to 'local'.
|
|
* @param options.locale - a locale to set on the resulting DateTime instance
|
|
* @param options.outputCalendar - the output calendar to set on the resulting DateTime instance
|
|
* @param options.numberingSystem - the numbering system to set on the resulting DateTime instance
|
|
*/
|
|
static fromMillis(millis, options = {}) {
|
|
if (options.zone == null ||
|
|
[Timezones_1.UnsetZoneName, Timezones_1.UnsetZone].includes(options.zone)) {
|
|
delete options.zone;
|
|
}
|
|
let dt = luxon_1.DateTime.fromMillis(millis, {
|
|
...(0, Object_1.omit)(options, "rawValue"),
|
|
});
|
|
if (options.zone == null) {
|
|
dt = dt.setZone(Timezones_1.UnsetZone, { keepLocalTime: true });
|
|
}
|
|
// TODO: is there a way to provide an invalid millisecond value?
|
|
return this.fromDateTime(dt, { rawValue: options.rawValue });
|
|
}
|
|
static now(opts = {}) {
|
|
return this.fromMillis(Date.now(), opts);
|
|
}
|
|
#dt;
|
|
zone;
|
|
constructor(year, month, day, hour, minute, second, millisecond, tzoffsetMinutes, rawValue, zoneName, inferredZone) {
|
|
this.year = year;
|
|
this.month = month;
|
|
this.day = day;
|
|
this.hour = hour;
|
|
this.minute = minute;
|
|
this.second = second;
|
|
this.millisecond = millisecond;
|
|
this.tzoffsetMinutes = tzoffsetMinutes;
|
|
this.rawValue = rawValue;
|
|
this.zoneName = zoneName;
|
|
this.inferredZone = inferredZone;
|
|
this.zone = (0, Timezones_1.getZoneName)({ zoneName, tzoffsetMinutes });
|
|
}
|
|
get millis() {
|
|
return this.millisecond;
|
|
}
|
|
get hasZone() {
|
|
return this.zone != null;
|
|
}
|
|
get unsetMilliseconds() {
|
|
return this.millisecond == null;
|
|
}
|
|
setZone(zone, opts) {
|
|
const dt = (0, TimeParsing_1.setZone)({
|
|
zone,
|
|
src: this.toDateTime(),
|
|
srcHasZone: this.hasZone,
|
|
opts,
|
|
});
|
|
return ExifDateTime.fromDateTime(dt, {
|
|
rawValue: this.rawValue,
|
|
unsetMilliseconds: this.millisecond == null,
|
|
inferredZone: opts?.inferredZone ?? true,
|
|
});
|
|
}
|
|
/**
|
|
* CAUTION: This instance will inherit the system timezone if this instance
|
|
* has an unset zone (as Luxon doesn't support "unset" timezones)
|
|
*/
|
|
toDateTime(overrideZone) {
|
|
return (this.#dt ??= luxon_1.DateTime.fromObject({
|
|
year: this.year,
|
|
month: this.month,
|
|
day: this.day,
|
|
hour: this.hour,
|
|
minute: this.minute,
|
|
second: this.second,
|
|
millisecond: this.millisecond,
|
|
}, {
|
|
zone: overrideZone ?? this.zone,
|
|
}));
|
|
}
|
|
toEpochSeconds(overrideZone) {
|
|
return this.toDateTime(overrideZone).toUnixInteger();
|
|
}
|
|
toDate() {
|
|
return this.toDateTime().toJSDate();
|
|
}
|
|
toISOString(options = {}) {
|
|
return (0, Maybe_1.denull)(this.toDateTime().toISO({
|
|
suppressMilliseconds: options.suppressMilliseconds ?? this.millisecond == null,
|
|
includeOffset: this.hasZone && options.includeOffset !== false,
|
|
}));
|
|
}
|
|
toExifString() {
|
|
return (0, DateTime_1.dateTimeToExif)(this.toDateTime(), {
|
|
includeOffset: this.hasZone,
|
|
includeMilliseconds: this.millisecond != null,
|
|
});
|
|
}
|
|
toString() {
|
|
return this.toISOString();
|
|
}
|
|
/**
|
|
* @return the epoch milliseconds of this
|
|
*/
|
|
toMillis() {
|
|
return this.toDateTime().toMillis();
|
|
}
|
|
get isValid() {
|
|
return this.toDateTime().isValid;
|
|
}
|
|
toJSON() {
|
|
return {
|
|
_ctor: "ExifDateTime", // < magick field used by the JSON parser
|
|
year: this.year,
|
|
month: this.month,
|
|
day: this.day,
|
|
hour: this.hour,
|
|
minute: this.minute,
|
|
second: this.second,
|
|
millisecond: this.millisecond,
|
|
tzoffsetMinutes: this.tzoffsetMinutes,
|
|
rawValue: this.rawValue,
|
|
zoneName: this.zoneName,
|
|
inferredZone: this.inferredZone,
|
|
};
|
|
}
|
|
/**
|
|
* @return a new ExifDateTime from the given JSON. Note that this instance **may not be valid**.
|
|
*/
|
|
static fromJSON(json) {
|
|
return new ExifDateTime(json.year, json.month, json.day, json.hour, json.minute, json.second, json.millisecond, json.tzoffsetMinutes, json.rawValue, json.zoneName, json.inferredZone);
|
|
}
|
|
maybeMatchZone(target, maxDeltaMs = 14 * DateTime_1.MinuteMs) {
|
|
const targetZone = target.zone;
|
|
if (targetZone == null || !target.hasZone)
|
|
return;
|
|
return (this.setZone(targetZone, { keepLocalTime: false })?.ifClose(target, maxDeltaMs) ??
|
|
this.setZone(targetZone, { keepLocalTime: true })?.ifClose(target, maxDeltaMs));
|
|
}
|
|
ifClose(target, maxDeltaMs = 14 * DateTime_1.MinuteMs) {
|
|
const ts = this.toMillis();
|
|
const targetTs = target.toMillis();
|
|
return Math.abs(ts - targetTs) <= maxDeltaMs ? this : undefined;
|
|
}
|
|
plus(duration) {
|
|
let dt = this.toDateTime().plus(duration);
|
|
if (!this.hasZone) {
|
|
dt = dt.setZone(Timezones_1.UnsetZone, { keepLocalTime: true });
|
|
}
|
|
return ExifDateTime.fromDateTime(dt, this);
|
|
}
|
|
}
|
|
exports.ExifDateTime = ExifDateTime;
|
|
//# sourceMappingURL=ExifDateTime.js.map
|