Skip to content

Commit

Permalink
Use the actual IANA zone, not the offset time (which may be surprisin…
Browse files Browse the repository at this point in the history
…g: see Kiev from 1900-1924)!
  • Loading branch information
mceachen committed Feb 22, 2021
1 parent f00e5c0 commit 1f52ebb
Show file tree
Hide file tree
Showing 4 changed files with 68 additions and 38 deletions.
25 changes: 23 additions & 2 deletions src/ExifDateTime.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect } from "./_chai.spec"
import { ExifDateTime } from "./ExifDateTime"
import { expect } from "./_chai.spec"

/* eslint-disable @typescript-eslint/no-non-null-assertion */
describe("ExifDateTime", () => {
Expand All @@ -8,6 +8,14 @@ describe("ExifDateTime", () => {
const iso = "2016-08-12T07:28:50.768"
const dt = ExifDateTime.fromEXIF(raw)!
const dtIso = ExifDateTime.fromEXIF(iso)!
it("doesn't set the zone from EXIF", () => {
expect(dt.hasZone).to.eql(false)
expect(dt.zone).to.eql(undefined)
})
it("doesn't set the zone from ISO", () => {
expect(dtIso.hasZone).to.eql(false)
expect(dtIso.zone).to.eql(undefined)
})
it("parses year/month/day", () => {
expect([dt.year, dt.month, dt.day]).to.eql([2016, 8, 12])
})
Expand Down Expand Up @@ -43,7 +51,9 @@ describe("ExifDateTime", () => {
)
})
it("Round-trips from ISO", () => {
expect(ExifDateTime.fromISO(iso, undefined, dt.rawValue)).to.eql(dt)
const zone = undefined
const actual = ExifDateTime.fromISO(iso, zone, dt.rawValue)!
expect(actual.toISOString()).to.eql(dt.toISOString())
})
})

Expand Down Expand Up @@ -251,4 +261,15 @@ describe("ExifDateTime", () => {
const dt = edt.toDateTime()
expect(dt.toISO()).to.eql("2019-03-08T14:24:54.000-08:00")
})

it("parses non-standard timezone offset", () => {
// 1900-1923, Kyiv had an offset of UTC +2:02:04
const edt = ExifDateTime.fromExifStrict(
"1904:02:03 13:14:15",
"Europe/Kiev"
)!
expect(edt.hasZone).to.eql(true)
expect(edt.isValid).to.eql(true)
expect(edt.toISOString()).to.eql("1904-02-03T13:14:15.000+02:02")
})
})
65 changes: 32 additions & 33 deletions src/ExifDateTime.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,27 @@
import { DateTime, ISOTimeOptions } from "luxon"
import { DateTime, ISOTimeOptions, Zone } from "luxon"
import { dateTimeToExif } from "./DateTime"
import { denull, first, firstDefinedThunk, map, Maybe, orElse } from "./Maybe"
import { blank, notBlank, toS } from "./String"
import { offsetMinutesToZoneName } from "./Timezones"

// Not in typings:
const { FixedOffsetZone } = require("luxon")
const unsetZoneOffsetMinutes = -24 * 60
const unsetZone = new FixedOffsetZone(unsetZoneOffsetMinutes)
import {
offsetMinutesToZoneName,
UnsetZone,
UnsetZoneOffsetMinutes,
} from "./Timezones"

/**
* Encodes an ExifDateTime with an optional tz offset in minutes.
*/
export class ExifDateTime {
static fromISO(
iso: string,
defaultZone?: Maybe<string>,
zone?: Maybe<string>,
rawValue?: string
): Maybe<ExifDateTime> {
if (blank(iso) || null != iso.match(/^\d+$/)) return undefined
return this.fromDateTime(
DateTime.fromISO(iso, {
setZone: true,
zone: orElse(defaultZone, unsetZone),
zone: zone ?? UnsetZone,
}),
orElse(rawValue, iso)
)
Expand Down Expand Up @@ -51,7 +50,7 @@ export class ExifDateTime {

private static fromPatterns(
text: string,
fmts: { fmt: string; zone?: string }[]
fmts: { fmt: string; zone?: string | Zone }[]
) {
const s = toS(text).trim()
const inputs = [s]
Expand All @@ -69,9 +68,12 @@ export class ExifDateTime {
}

return first(inputs, (input) =>
first(fmts, ({ fmt, zone: fmtZone }) =>
first(fmts, ({ fmt, zone }) =>
map(
DateTime.fromFormat(input, fmt, { setZone: true, zone: fmtZone }),
DateTime.fromFormat(input, fmt, {
setZone: true,
zone: zone ?? UnsetZone,
}),
(dt) => this.fromDateTime(dt, s)
)
)
Expand All @@ -80,10 +82,9 @@ export class ExifDateTime {

static fromExifStrict(
text: Maybe<string>,
defaultZone?: Maybe<string>
zone?: Maybe<string>
): Maybe<ExifDateTime> {
if (blank(text)) return undefined
const zone = notBlank(defaultZone) ? defaultZone : unsetZone
return this.fromPatterns(text, [
// if it specifies a zone, use it:
{ fmt: "y:M:d H:m:s.uZZ" },
Expand Down Expand Up @@ -118,7 +119,7 @@ export class ExifDateTime {
defaultZone?: Maybe<string>
): Maybe<ExifDateTime> {
if (blank(text)) return undefined
const zone = notBlank(defaultZone) ? defaultZone : unsetZone
const zone = notBlank(defaultZone) ? defaultZone : UnsetZone
return this.fromPatterns(text, [
// FWIW, the following are from actual datestamps seen in the wild:
{ fmt: "MMM d y H:m:sZZZ" },
Expand Down Expand Up @@ -149,8 +150,9 @@ export class ExifDateTime {
dt.minute,
dt.second,
dt.millisecond,
dt.offset === unsetZoneOffsetMinutes ? undefined : dt.offset,
rawValue
dt.offset === UnsetZoneOffsetMinutes ? undefined : dt.offset,
rawValue,
dt.zone.name === UnsetZone.name ? undefined : dt.zoneName
)
}

Expand All @@ -163,40 +165,33 @@ export class ExifDateTime {
readonly second: number,
readonly millisecond?: number,
readonly tzoffsetMinutes?: number,
readonly rawValue?: string
readonly rawValue?: string,
readonly zoneName?: string
) {}

get millis() {
return this.millisecond
}

get hasZone() {
return (
this.tzoffsetMinutes != null &&
this.tzoffsetMinutes !== unsetZoneOffsetMinutes
)
return notBlank(this.zone)
}

get zone() {
return this.hasZone
? offsetMinutesToZoneName(this.tzoffsetMinutes)
: undefined
return this.zoneName ?? offsetMinutesToZoneName(this.tzoffsetMinutes)
}

toDateTime(): DateTime {
const o: any = {
toDateTime() {
return DateTime.fromObject({
year: this.year,
month: this.month,
day: this.day,
hour: this.hour,
minute: this.minute,
second: this.second,
}
map(this.millisecond, (ea) => (o.millisecond = ea))
if (this.hasZone) {
map(this.tzoffsetMinutes, (ea) => (o.zone = offsetMinutesToZoneName(ea)))
}
return DateTime.fromObject(o)
millisecond: this.millisecond,
zone: this.zone,
})
}

toDate(): Date {
Expand All @@ -222,4 +217,8 @@ export class ExifDateTime {
toString() {
return this.toISOString()
}

get isValid() {
return this.toDateTime().isValid
}
}
2 changes: 1 addition & 1 deletion src/ExifTool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ describe("ExifTool", function () {
expect(tags.DateTimeCreated).to.eql(
ExifDateTime.fromISO(
"2016-08-12T13:28:50",
"UTC+8",
"Asia/Hong_Kong",
"2016:08:12 13:28:50"
)
)
Expand Down
14 changes: 12 additions & 2 deletions src/Timezones.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Info } from "luxon"
import { FixedOffsetZone, Info } from "luxon"
import { compact } from "./Array"
import { MinuteMs } from "./DateTime"
import { ExifDateTime } from "./ExifDateTime"
Expand All @@ -10,6 +10,11 @@ import { blank, isString, pad2 } from "./String"
// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
export const MaxTzOffsetHours = 14

// Not in typings:
export const UnsetZoneOffsetMinutes = -1
export const UnsetZone = FixedOffsetZone.instance(UnsetZoneOffsetMinutes)
export const UnsetZoneName = UnsetZone.name

export function reasonableTzOffsetMinutes(
tzOffsetMinutes: Maybe<number>
): boolean {
Expand All @@ -25,7 +30,12 @@ export function reasonableTzOffsetMinutes(
export function offsetMinutesToZoneName(
offsetMinutes: Maybe<number>
): Maybe<string> {
if (offsetMinutes == null || !isNumber(offsetMinutes)) return undefined
if (
offsetMinutes == null ||
!isNumber(offsetMinutes) ||
offsetMinutes === UnsetZoneOffsetMinutes
)
return undefined
if (offsetMinutes === 0) return "UTC"
const sign = offsetMinutes < 0 ? "-" : "+"
const absMinutes = Math.abs(offsetMinutes)
Expand Down

0 comments on commit 1f52ebb

Please sign in to comment.