Skip to content

Commit

Permalink
add "inferred zone" field to ExifDateTime (used by backfillTimezones)
Browse files Browse the repository at this point in the history
  • Loading branch information
mceachen committed Sep 13, 2023
1 parent 0aa0e07 commit b98e193
Show file tree
Hide file tree
Showing 2 changed files with 69 additions and 35 deletions.
38 changes: 25 additions & 13 deletions src/ExifDateTime.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import { expect, randomChars } from "./_chai.spec"
describe("ExifDateTime", () => {
for (const ea of [
{
desc: "no tzoffset or UTC suffix",
desc: "no zone suffix",
exif: "2016:09:12 07:28:50.768120",
exifExp: "2016:09:12 07:28:50.768",
iso: "2016-09-12T07:28:50.768",
d: {
hasZone: false,
zone: undefined,
zoneName: undefined,
year: 2016,
month: 9,
day: 12,
Expand All @@ -25,13 +25,13 @@ describe("ExifDateTime", () => {
},
},
{
desc: "with UTC suffix",
desc: "with zulu suffix",
exif: "1999:01:02 03:04:05.67890Z",
exifExp: "1999:01:02 03:04:05.678+00:00",
iso: "1999-01-02T03:04:05.678Z",
d: {
hasZone: true,
zone: "UTC",
zoneName: "UTC",
year: 1999,
month: 1,
day: 2,
Expand All @@ -44,7 +44,7 @@ describe("ExifDateTime", () => {
{
desc: "with timezone offset",
exif: "2002:10:11 13:14:15.789-03:00",
iso: "2002-10-11 13:14:15.789-03:00",
iso: "2002-10-11 13:14:15.789-03:00", // < no T!
isoExp: "2002-10-11T13:14:15.789-03:00",
d: {
hasZone: true,
Expand All @@ -60,11 +60,9 @@ describe("ExifDateTime", () => {
},
{
desc: "with no tzoffset and UTC default",
exif: ExifDateTime.fromEXIF(
"2011:01:23 18:19:20",
"UTC"
)!.toExifString()!,
iso: ExifDateTime.fromISO("2011-01-23T18:19:20", "UTC")!.toISOString()!,
exif: "2011:01:23 18:19:20Z",
iso: "2011-01-23T18:19:20Z",
exifExp: "2011:01:23 18:19:20+00:00",
d: {
hasZone: true,
zoneName: "UTC",
Expand All @@ -84,7 +82,7 @@ describe("ExifDateTime", () => {
iso: "2016-08-12T07:28:50.768",
d: {
hasZone: false,
zone: undefined,
zoneName: undefined,
year: 2016,
month: 8,
day: 12,
Expand Down Expand Up @@ -151,12 +149,26 @@ describe("ExifDateTime", () => {
]) {
describe(ea.desc + " (iso: " + ea.iso + ")", () => {
const fromExif = ExifDateTime.fromEXIF(ea.exif)
const expected = {
...ea.d,
inferredZone: false, // < because the .fromEXIF call doesn't have a default!
}
it("parses from EXIF", () => {
expect(fromExif).to.containSubset(ea.d)
expect(fromExif).to.containSubset(expected)
})
it("parses from EXIF with zone default", () => {
const edt = ExifDateTime.fromEXIF(ea.exif, "UTC+1")
expect(edt).to.haveOwnProperty("inferredZone", !expected.hasZone)
expect(edt?.zoneName).to.eql(expected.zoneName ?? "UTC+1")
})
const fromISO = ExifDateTime.fromISO(ea.iso)
it("parses from ISO", () => {
expect(fromISO).to.containSubset(ea.d)
expect(fromISO).to.containSubset(expected)
})
it("parses from ISO with zone default", () => {
const edt = ExifDateTime.fromISO(ea.iso, "UTC-5")
expect(edt).to.haveOwnProperty("inferredZone", !expected.hasZone)
expect(edt?.zoneName).to.eql(expected.zoneName ?? "UTC-5")
})
const jsDate = new Date(ea.iso)
it("renders correct epoch millis", () => {
Expand Down
66 changes: 44 additions & 22 deletions src/ExifDateTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,17 @@ export class ExifDateTime {
// so we have to do this ourselves:
const patterns = []
for (const z of [
{ fmt: "ZZ", zone: undefined },
{ fmt: "'Z'", zone: "UTC" },
{ fmt: "", zone: defaultZone },
{ fmt: "ZZ", zone: undefined, inferredZone: false },
{ fmt: "'Z'", zone: "UTC", inferredZone: false },
{ fmt: "", zone: defaultZone, inferredZone: true },
]) {
for (const sep of ["'T'", " "]) {
for (const timeFmt of TimeFmts) {
patterns.push({
fmt: `y-M-d${sep}${timeFmt.fmt}${z.fmt}`,
zone: z.zone,
unsetMilliseconds: timeFmt.unsetMilliseconds,
inferredZone: z.inferredZone,
})
}
}
Expand Down Expand Up @@ -95,6 +96,7 @@ export class ExifDateTime {
fmt: string
zone?: string | Zone | undefined
unsetMilliseconds?: boolean
inferredZone?: boolean
}[]
): Maybe<ExifDateTime> {
const s = toS(text).trim()
Expand All @@ -107,22 +109,33 @@ export class ExifDateTime {
// Unfortunately, luxon doesn't support regex.

// We only want to strip off the TZA if it isn't "UTC" or "Z"
if (null == s.match(/[.\d\s](UTC|Z)$/)) {
const zuluSuffix = s.match(/[.\d\s](UTC|Z)$/)
if (null == zuluSuffix) {
const noTza = s.replace(/ [a-z]{2,5}$/i, "")
if (noTza !== s) inputs.push(noTza)
}
// PERF: unroll first() to avoid creating closures
for (const input of inputs) {
for (const { fmt, zone, unsetMilliseconds } of fmts) {
const dt = DateTime.fromFormat(input, fmt, {
for (const ea of fmts) {
const dt = DateTime.fromFormat(input, ea.fmt, {
setZone: true,
zone: zone ?? UnsetZone,
zone: ea.zone ?? UnsetZone,
})
const edt = ExifDateTime.fromDateTime(dt, {
rawValue: s,
unsetMilliseconds: unsetMilliseconds ?? false,
})
if (edt != null) return edt
if (dt != null && dt.isValid) {
const zoneUnset = dt.zone == null || dt.zone === UnsetZone
let inferredZone = zoneUnset ? false : ea.inferredZone
if (inferredZone == null) {
// this is pretty miserable, but luxon doesn't expose how it got
// the zone, so we have to resort to this hack:
const dt2 = DateTime.fromFormat(input, ea.fmt, { setZone: true })
inferredZone = dt.zone !== dt2.zone
}
const edt = ExifDateTime.fromDateTime(dt, {
rawValue: s,
unsetMilliseconds: ea.unsetMilliseconds ?? false,
inferredZone,
})
if (edt != null) return edt
}
}
}
return
Expand All @@ -147,15 +160,16 @@ export class ExifDateTime {
const patterns = []

for (const z of [
{ fmt: "ZZ", zone: undefined },
{ fmt: "'Z'", zone: "UTC" },
{ fmt: "", zone: defaultZone },
{ fmt: "ZZ", zone: undefined, inferredZone: false },
{ fmt: "'Z'", zone: "UTC", inferredZone: false },
{ fmt: "", zone: defaultZone, inferredZone: true },
]) {
for (const timeFmt of TimeFmts) {
patterns.push({
fmt: `y:M:d ${timeFmt.fmt}${z.fmt}`,
zone: z.zone,
unsetMilliseconds: timeFmt.unsetMilliseconds,
inferredZone: z.inferredZone,
})
}
}
Expand All @@ -182,15 +196,19 @@ export class ExifDateTime {
"ccc MMM d H:m:s y",
]
return this.fromPatterns(text, [
...formats.map((fmt) => ({ fmt: fmt + "ZZ" })),
...formats.map((fmt) => ({ fmt: fmt + "ZZ", inferredZone: false })),
// And the same formats, without offsets with default zone:
...formats.map((fmt) => ({ fmt, zone })),
...formats.map((fmt) => ({ fmt, zone, inferredZone: true })),
])
}

static fromDateTime(
dt: Maybe<DateTime>,
opts?: { rawValue?: Maybe<string>; unsetMilliseconds?: boolean }
opts?: {
rawValue?: Maybe<string>
unsetMilliseconds?: boolean
inferredZone?: Maybe<boolean>
}
): Maybe<ExifDateTime> {
if (dt == null || !dt.isValid || dt.year === 0 || dt.year === 1) {
return undefined
Expand All @@ -209,7 +227,8 @@ export class ExifDateTime {
opts?.rawValue,
dt.zoneName == null || dt.zone?.name === UnsetZone.name
? undefined
: dt.zoneName
: dt.zoneName,
opts?.inferredZone
)
}

Expand Down Expand Up @@ -262,7 +281,8 @@ export class ExifDateTime {
readonly millisecond?: number,
readonly tzoffsetMinutes?: number,
readonly rawValue?: string,
readonly zoneName?: string
readonly zoneName?: string,
readonly inferredZone?: boolean
) {}

get millis() {
Expand Down Expand Up @@ -370,6 +390,7 @@ export class ExifDateTime {
tzoffsetMinutes: this.tzoffsetMinutes,
rawValue: this.rawValue,
zoneName: this.zoneName,
inferredZone: this.inferredZone,
}
}

Expand All @@ -389,7 +410,8 @@ export class ExifDateTime {
json.millisecond,
json.tzoffsetMinutes,
json.rawValue,
json.zoneName
json.zoneName,
json.inferredZone
)
}

Expand Down

0 comments on commit b98e193

Please sign in to comment.