exiftool-vendored
Version:
Efficient, cross-platform access to ExifTool
296 lines (295 loc) • 11.6 kB
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?
const result = this.fromDateTime(dt, { rawValue: options.rawValue });
if (result == null) {
throw new Error(`Failed to create ExifDateTime from millis: ${millis}`);
}
return result;
}
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