Skip to content

Commit 30df449

Browse files
committed
feat(time): parse POSIX locale datestamps. Add TZA to fixed UTC offsets lookups.
1 parent ba76286 commit 30df449

7 files changed

Lines changed: 361 additions & 2 deletions

File tree

.claude/settings.local.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"WebFetch(domain:www.color.org)",
2626
"WebFetch(domain:www.iptc.org)",
2727
"WebFetch(domain:photostructure.com)",
28-
"WebSearch",
28+
"WebSearch"
2929
],
3030
"deny": [],
3131
"ask": [],

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ vendored versions of ExifTool match the version that they vendor.
3535

3636
## History
3737

38+
### v35.14.1
39+
40+
- 🐞 Fixed parsing of POSIX locale date strings like `"Tue 17 Jun 2025 09:29:01 PM PDT"` (emitted by gnome-screenshot's `CreationTime` tag). Previously these fell through to raw strings, losing the time and timezone.
41+
-`extractZone()` now resolves common unambiguous timezone abbreviations (PDT, EST, CEST, JST, NZDT, etc.) to fixed UTC offsets (e.g. PDT → `UTC-7`) as a last-resort fallback when no numeric UTC offset is present. Ambiguous abbreviations (CST, IST, BST, AST) are intentionally omitted.
42+
- ✨ New `Settings.tzAbbreviationOffsets` allows users to resolve ambiguous abbreviations for their region (e.g. `{ CST: -6 * 60 }` for US Central, `{ IST: 5 * 60 + 30 }` for India).
43+
3844
### v35.14.0
3945

4046
- 🌱 Upgraded ExifTool to version [13.53](https://exiftool.org/history.html#13.53).

src/ExifDateTime.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,62 @@ describe("ExifDateTime", () => {
242242
expect(dt.zone).to.eql("UTC-7");
243243
});
244244

245+
describe("POSIX locale date parsing (gnome-screenshot, etc.)", () => {
246+
it("parses 12-hour POSIX locale with PDT", () => {
247+
const raw = "Tue 17 Jun 2025 09:29:01 PM PDT";
248+
const dt = ExifDateTime.from(raw)!;
249+
expect(dt).to.not.eql(undefined);
250+
expect(dt.year).to.eql(2025);
251+
expect(dt.month).to.eql(6);
252+
expect(dt.day).to.eql(17);
253+
expect(dt.hour).to.eql(21); // 9 PM = 21
254+
expect(dt.minute).to.eql(29);
255+
expect(dt.second).to.eql(1);
256+
expect(dt.zone).to.eql("UTC-7");
257+
expect(dt.tzoffsetMinutes).to.eql(-420);
258+
expect(dt.inferredZone).to.eql(false);
259+
});
260+
261+
it("parses 12-hour POSIX locale with PST", () => {
262+
const raw = "Thu 16 Jan 2025 10:00:00 AM PST";
263+
const dt = ExifDateTime.from(raw)!;
264+
expect(dt).to.not.eql(undefined);
265+
expect(dt.year).to.eql(2025);
266+
expect(dt.month).to.eql(1);
267+
expect(dt.day).to.eql(16);
268+
expect(dt.hour).to.eql(10);
269+
expect(dt.minute).to.eql(0);
270+
expect(dt.second).to.eql(0);
271+
expect(dt.zone).to.eql("UTC-8");
272+
expect(dt.tzoffsetMinutes).to.eql(-480);
273+
});
274+
275+
it("parses 24-hour POSIX locale with CEST", () => {
276+
const raw = "Tue 15 Jul 2025 14:30:00 CEST";
277+
const dt = ExifDateTime.from(raw)!;
278+
expect(dt).to.not.eql(undefined);
279+
expect(dt.year).to.eql(2025);
280+
expect(dt.month).to.eql(7);
281+
expect(dt.day).to.eql(15);
282+
expect(dt.hour).to.eql(14);
283+
expect(dt.minute).to.eql(30);
284+
expect(dt.second).to.eql(0);
285+
expect(dt.zone).to.eql("UTC+2");
286+
expect(dt.tzoffsetMinutes).to.eql(120);
287+
});
288+
289+
it("parses POSIX locale without weekday prefix", () => {
290+
const raw = "17 Jun 2025 09:29:01 PM PDT";
291+
const dt = ExifDateTime.from(raw)!;
292+
expect(dt).to.not.eql(undefined);
293+
expect(dt.year).to.eql(2025);
294+
expect(dt.month).to.eql(6);
295+
expect(dt.day).to.eql(17);
296+
expect(dt.hour).to.eql(21);
297+
expect(dt.zone).to.eql("UTC-7");
298+
});
299+
});
300+
245301
it("try to repro issue #46", () => {
246302
const edt = new ExifDateTime(2019, 3, 8, 14, 24, 54, 0, -480);
247303
const dt = edt.toDateTime();

src/ExifDateTime.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,13 @@ export class ExifDateTime {
143143
"MMM d y, HH:mm:ss",
144144
// Thu Oct 13 00:12:27 2016:
145145
"ccc MMM d HH:mm:ss y",
146+
// POSIX locale dates from gnome-screenshot, etc.
147+
// "Tue 17 Jun 2025 09:29:01 PM" (after TZA stripping):
148+
"ccc d MMM y h:mm:ss a",
149+
"ccc d MMM y HH:mm:ss",
150+
// Without weekday prefix:
151+
"d MMM y h:mm:ss a",
152+
"d MMM y HH:mm:ss",
146153
];
147154
const zone = notBlank(defaultZone) ? defaultZone : UnsetZone;
148155
for (const fmt of formats) {

src/Settings.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,38 @@ export const Settings = {
129129
*/
130130
maxValidOffsetMinutes: new Setting(30),
131131

132+
/**
133+
* Additional timezone abbreviation → UTC offset (in minutes) mappings.
134+
*
135+
* These are merged on top of the built-in table, with user values taking
136+
* precedence. This allows you to:
137+
* - Resolve ambiguous abbreviations for your region
138+
* - Override built-in mappings
139+
* - Add abbreviations not in the default table
140+
*
141+
* Keys must be uppercase. Values are UTC offset in minutes.
142+
*
143+
* @default `{}` (only built-in unambiguous mappings are used)
144+
*
145+
* @example
146+
* ```typescript
147+
* import { Settings } from "exiftool-vendored"
148+
*
149+
* // US Central Standard Time (UTC-6):
150+
* Settings.tzAbbreviationOffsets.value = { CST: -6 * 60 }
151+
*
152+
* // India Standard Time (UTC+5:30):
153+
* Settings.tzAbbreviationOffsets.value = { IST: 5 * 60 + 30 }
154+
*
155+
* // Multiple at once:
156+
* Settings.tzAbbreviationOffsets.value = {
157+
* CST: -6 * 60,
158+
* IST: 5 * 60 + 30,
159+
* }
160+
* ```
161+
*/
162+
tzAbbreviationOffsets: new Setting<Readonly<Record<string, number>>>({}),
163+
132164
/**
133165
* Logger instance used throughout exiftool-vendored.
134166
*

src/Timezones.spec.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,131 @@ describe("Timezones", () => {
240240
}
241241
});
242242

243+
describe("TZA abbreviation resolution", () => {
244+
it("resolves PDT to UTC-7 from POSIX locale timestamp", () => {
245+
const result = extractZone("Tue 17 Jun 2025 09:29:01 PM PDT");
246+
expect(result?.zone).to.equal("UTC-7");
247+
expect(result?.src).to.equal("tzAbbreviation");
248+
expect(result?.leftovers).to.equal("Tue 17 Jun 2025 09:29:01 PM");
249+
});
250+
251+
it("resolves PST to UTC-8 from POSIX locale timestamp", () => {
252+
const result = extractZone("Thu 16 Jan 2025 10:00:00 AM PST");
253+
expect(result?.zone).to.equal("UTC-8");
254+
expect(result?.src).to.equal("tzAbbreviation");
255+
expect(result?.leftovers).to.equal("Thu 16 Jan 2025 10:00:00 AM");
256+
});
257+
258+
it("resolves EST to UTC-5", () => {
259+
const result = extractZone("2025:01:15 10:30:00 EST");
260+
expect(result?.zone).to.equal("UTC-5");
261+
expect(result?.src).to.equal("tzAbbreviation");
262+
expect(result?.leftovers).to.equal("2025:01:15 10:30:00");
263+
});
264+
265+
it("resolves CEST to UTC+2", () => {
266+
const result = extractZone("2025:07:15 14:00:00 CEST");
267+
expect(result?.zone).to.equal("UTC+2");
268+
expect(result?.src).to.equal("tzAbbreviation");
269+
});
270+
271+
it("resolves NZDT to UTC+13", () => {
272+
const result = extractZone("2025:01:15 10:30:00 NZDT");
273+
expect(result?.zone).to.equal("UTC+13");
274+
expect(result?.src).to.equal("tzAbbreviation");
275+
});
276+
277+
it("resolves NST to UTC-3:30 (Newfoundland)", () => {
278+
const result = extractZone("2025:01:15 10:30:00 NST");
279+
expect(result?.zone).to.equal("UTC-3:30");
280+
expect(result?.src).to.equal("tzAbbreviation");
281+
});
282+
283+
it("resolves JST to UTC+9", () => {
284+
const result = extractZone("2025:07:15 14:00:00 JST");
285+
expect(result?.zone).to.equal("UTC+9");
286+
expect(result?.src).to.equal("tzAbbreviation");
287+
});
288+
289+
it("does not resolve ambiguous CST", () => {
290+
const result = extractZone("2025:01:15 10:30:00 CST");
291+
expect(result).to.eql(undefined);
292+
});
293+
294+
it("does not resolve ambiguous IST", () => {
295+
const result = extractZone("2025:01:15 10:30:00 IST");
296+
expect(result).to.eql(undefined);
297+
});
298+
299+
it("does not resolve ambiguous BST", () => {
300+
const result = extractZone("2025:01:15 10:30:00 BST");
301+
expect(result).to.eql(undefined);
302+
});
303+
304+
it("prefers numeric offset over TZA when both present", () => {
305+
// This already had a numeric offset — TZA is just stripped
306+
const result = extractZone("2014:07:17 08:46:27-07:00 DST");
307+
expect(result?.zone).to.equal("UTC-7");
308+
expect(result?.src).to.equal("offsetMinutesToZoneName");
309+
});
310+
311+
it("returns undefined for unknown abbreviations", () => {
312+
const result = extractZone("2025:01:15 10:30:00 XYZW");
313+
expect(result).to.eql(undefined);
314+
});
315+
316+
it("does not resolve TZA when stripTZA is false", () => {
317+
const result = extractZone("Tue 17 Jun 2025 09:29:01 PM PDT", {
318+
stripTZA: false,
319+
});
320+
expect(result).to.eql(undefined);
321+
});
322+
323+
describe("Settings.tzAbbreviationOffsets", () => {
324+
afterEach(() => Settings.reset());
325+
326+
it("resolves ambiguous CST when user provides override", () => {
327+
Settings.tzAbbreviationOffsets.value = { CST: -6 * 60 };
328+
const result = extractZone("2025:01:15 10:30:00 CST");
329+
expect(result?.zone).to.equal("UTC-6");
330+
expect(result?.src).to.equal("tzAbbreviation");
331+
});
332+
333+
it("resolves ambiguous IST to India when user provides override", () => {
334+
Settings.tzAbbreviationOffsets.value = { IST: 5 * 60 + 30 };
335+
const result = extractZone("2025:01:15 10:30:00 IST");
336+
expect(result?.zone).to.equal("UTC+5:30");
337+
expect(result?.src).to.equal("tzAbbreviation");
338+
});
339+
340+
it("user override takes precedence over built-in", () => {
341+
// PDT is built-in as UTC-7; override to something else
342+
Settings.tzAbbreviationOffsets.value = { PDT: -8 * 60 };
343+
const result = extractZone("Tue 17 Jun 2025 09:29:01 PM PDT");
344+
expect(result?.zone).to.equal("UTC-8");
345+
});
346+
347+
it("reverts to built-in after reset", () => {
348+
Settings.tzAbbreviationOffsets.value = { PDT: -8 * 60 };
349+
Settings.reset();
350+
const result = extractZone("Tue 17 Jun 2025 09:29:01 PM PDT");
351+
expect(result?.zone).to.equal("UTC-7");
352+
});
353+
354+
it("does not affect unrelated abbreviations", () => {
355+
Settings.tzAbbreviationOffsets.value = { CST: -6 * 60 };
356+
const result = extractZone("2025:01:15 10:30:00 EST");
357+
expect(result?.zone).to.equal("UTC-5");
358+
});
359+
360+
it("rejects invalid offset from user override", () => {
361+
Settings.tzAbbreviationOffsets.value = { CST: 99999 };
362+
const result = extractZone("2025:01:15 10:30:00 CST");
363+
expect(result).to.eql(undefined);
364+
});
365+
});
366+
});
367+
243368
describe("Unicode timezone signs", () => {
244369
it("should handle Unicode minus sign (U+2212) in offset", () => {
245370
const result = extractZone("−08:00"); // Unicode minus

0 commit comments

Comments
 (0)