From 1af0fedfa894ee51f74a059c91386698232e34bf Mon Sep 17 00:00:00 2001 From: ziad Date: Fri, 10 Jul 2026 01:58:05 +1000 Subject: [PATCH 1/2] Use EXIF OffsetTimeOriginal for accurate saleStart when the camera provides it DateTimeOriginal alone has no timezone, so we were assuming UTC for every photo regardless of where it was actually taken. Some cameras/phones (EXIF 2.31+) also write OffsetTimeOriginal, which lets us compute the true UTC capture instant instead of guessing. Deliberately not falling back to a self-reported or profile-configured timezone when OffsetTimeOriginal is absent: saleStart is written on-chain, and an unverified guess is worse there than the existing, honest fallback to upload time. Co-Authored-By: Claude Sonnet 5 --- .../__tests__/extractExifCaptureDate.test.ts | 39 ++++++++++++++++++- .../chat/__tests__/parseExifOffsetMs.test.ts | 28 +++++++++++++ .../telegram/chat/extractExifCaptureDate.ts | 16 ++++++-- src/lib/telegram/chat/parseExifOffsetMs.ts | 13 +++++++ 4 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 src/lib/telegram/chat/__tests__/parseExifOffsetMs.test.ts create mode 100644 src/lib/telegram/chat/parseExifOffsetMs.ts diff --git a/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts b/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts index 8b25a10d..4bdf99b4 100644 --- a/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts +++ b/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts @@ -28,11 +28,48 @@ describe('extractExifCaptureDate', () => { await extractExifCaptureDate(Buffer.from('jpeg-bytes')); expect(mockParse).toHaveBeenCalledWith(expect.any(Buffer), { - pick: ['DateTimeOriginal'], + pick: ['DateTimeOriginal', 'OffsetTimeOriginal'], reviveValues: false, }); }); + it('uses OffsetTimeOriginal to compute the true UTC instant when present', async () => { + mockParse.mockResolvedValue({ + DateTimeOriginal: '2026:07:09 23:32:10', + OffsetTimeOriginal: '+09:00', + }); + + const result = await extractExifCaptureDate(Buffer.from('jpeg-bytes')); + + expect(result).toBe( + (Date.UTC(2026, 6, 9, 23, 32, 10) - 9 * 60 * 60_000) / 1000 + ); + }); + + it('subtracts a negative OffsetTimeOriginal correctly', async () => { + mockParse.mockResolvedValue({ + DateTimeOriginal: '2026:07:09 23:32:10', + OffsetTimeOriginal: '-05:00', + }); + + const result = await extractExifCaptureDate(Buffer.from('jpeg-bytes')); + + expect(result).toBe( + (Date.UTC(2026, 6, 9, 23, 32, 10) + 5 * 60 * 60_000) / 1000 + ); + }); + + it('falls back to treating the wall-clock as UTC when OffsetTimeOriginal is malformed', async () => { + mockParse.mockResolvedValue({ + DateTimeOriginal: '2026:07:09 23:32:10', + OffsetTimeOriginal: 'garbage', + }); + + const result = await extractExifCaptureDate(Buffer.from('jpeg-bytes')); + + expect(result).toBe(Date.UTC(2026, 6, 9, 23, 32, 10) / 1000); + }); + it('returns undefined when there is no DateTimeOriginal tag', async () => { mockParse.mockResolvedValue(undefined); diff --git a/src/lib/telegram/chat/__tests__/parseExifOffsetMs.test.ts b/src/lib/telegram/chat/__tests__/parseExifOffsetMs.test.ts new file mode 100644 index 00000000..521e56e3 --- /dev/null +++ b/src/lib/telegram/chat/__tests__/parseExifOffsetMs.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import parseExifOffsetMs from '../parseExifOffsetMs'; + +describe('parseExifOffsetMs', () => { + it('parses a positive offset', () => { + expect(parseExifOffsetMs('+09:00')).toBe(9 * 60 * 60_000); + }); + + it('parses a negative offset', () => { + expect(parseExifOffsetMs('-05:00')).toBe(-5 * 60 * 60_000); + }); + + it('parses a non-zero minutes offset', () => { + expect(parseExifOffsetMs('+05:30')).toBe((5 * 60 + 30) * 60_000); + }); + + it('returns undefined for undefined input', () => { + expect(parseExifOffsetMs(undefined)).toBeUndefined(); + }); + + it('returns undefined for a malformed string', () => { + expect(parseExifOffsetMs('not-an-offset')).toBeUndefined(); + }); + + it('returns undefined for a non-string value', () => { + expect(parseExifOffsetMs(540)).toBeUndefined(); + }); +}); diff --git a/src/lib/telegram/chat/extractExifCaptureDate.ts b/src/lib/telegram/chat/extractExifCaptureDate.ts index 02df4e41..412be1eb 100644 --- a/src/lib/telegram/chat/extractExifCaptureDate.ts +++ b/src/lib/telegram/chat/extractExifCaptureDate.ts @@ -1,4 +1,5 @@ import exifr from 'exifr'; +import parseExifOffsetMs from './parseExifOffsetMs'; const EXIF_DATE_PATTERN = /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/; @@ -7,7 +8,7 @@ const extractExifCaptureDate = async ( ): Promise => { try { const result = await exifr.parse(buffer, { - pick: ['DateTimeOriginal'], + pick: ['DateTimeOriginal', 'OffsetTimeOriginal'], reviveValues: false, }); const raw = result?.DateTimeOriginal; @@ -17,9 +18,7 @@ const extractExifCaptureDate = async ( if (!match) return undefined; const [, year, month, day, hour, minute, second] = match; - // EXIF DateTimeOriginal carries no timezone; treat the wall-clock value as - // UTC so the result stays deterministic regardless of the host's runtime TZ. - const epochMs = Date.UTC( + const localAsUtcMs = Date.UTC( Number(year), Number(month) - 1, Number(day), @@ -27,6 +26,15 @@ const extractExifCaptureDate = async ( Number(minute), Number(second) ); + + // DateTimeOriginal alone carries no timezone. When the camera also wrote + // OffsetTimeOriginal (EXIF 2.31+), use it to compute the true UTC instant; + // otherwise fall back to treating the wall-clock value as UTC so the + // result stays deterministic regardless of the host's runtime TZ. + const offsetMs = parseExifOffsetMs(result?.OffsetTimeOriginal); + const epochMs = + offsetMs === undefined ? localAsUtcMs : localAsUtcMs - offsetMs; + return Math.floor(epochMs / 1000); } catch { return undefined; diff --git a/src/lib/telegram/chat/parseExifOffsetMs.ts b/src/lib/telegram/chat/parseExifOffsetMs.ts new file mode 100644 index 00000000..b914eafe --- /dev/null +++ b/src/lib/telegram/chat/parseExifOffsetMs.ts @@ -0,0 +1,13 @@ +const EXIF_OFFSET_PATTERN = /^([+-])(\d{2}):(\d{2})$/; + +/** Parses an EXIF OffsetTimeOriginal string (e.g. "+09:00") into milliseconds. */ +const parseExifOffsetMs = (raw: unknown): number | undefined => { + if (typeof raw !== 'string') return undefined; + const match = EXIF_OFFSET_PATTERN.exec(raw); + if (!match) return undefined; + const [, sign, hours, minutes] = match; + const magnitudeMs = (Number(hours) * 60 + Number(minutes)) * 60_000; + return sign === '-' ? -magnitudeMs : magnitudeMs; +}; + +export default parseExifOffsetMs; From 4818007b614aa518d6c65366fab370eba8f90e1c Mon Sep 17 00:00:00 2001 From: ziad Date: Fri, 10 Jul 2026 02:00:26 +1000 Subject: [PATCH 2/2] Don't guess UTC when OffsetTimeOriginal is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit still fell back to treating DateTimeOriginal as UTC when OffsetTimeOriginal was absent — but assuming a 0 offset is just as much a guess as assuming any other timezone. Since saleStart is written on-chain, skip EXIF entirely in that case and let the caller fall back to upload time, consistent with having no capture-date signal at all. Co-Authored-By: Claude Sonnet 5 --- .../__tests__/extractExifCaptureDate.test.ts | 45 +++++++++++-------- .../telegram/chat/extractExifCaptureDate.ts | 18 ++++---- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts b/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts index 4bdf99b4..f77c7f07 100644 --- a/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts +++ b/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts @@ -14,16 +14,11 @@ beforeEach(() => { }); describe('extractExifCaptureDate', () => { - it('parses a raw EXIF DateTimeOriginal string as UTC', async () => { - mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10' }); - - const result = await extractExifCaptureDate(Buffer.from('jpeg-bytes')); - - expect(result).toBe(Date.UTC(2026, 6, 9, 23, 32, 10) / 1000); - }); - - it('requests raw (non-revived) values so the result is TZ-independent', async () => { - mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10' }); + it('requests raw (non-revived) values including OffsetTimeOriginal', async () => { + mockParse.mockResolvedValue({ + DateTimeOriginal: '2026:07:09 23:32:10', + OffsetTimeOriginal: '+09:00', + }); await extractExifCaptureDate(Buffer.from('jpeg-bytes')); @@ -33,7 +28,7 @@ describe('extractExifCaptureDate', () => { }); }); - it('uses OffsetTimeOriginal to compute the true UTC instant when present', async () => { + it('computes the true UTC instant when OffsetTimeOriginal is present', async () => { mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10', OffsetTimeOriginal: '+09:00', @@ -59,7 +54,15 @@ describe('extractExifCaptureDate', () => { ); }); - it('falls back to treating the wall-clock as UTC when OffsetTimeOriginal is malformed', async () => { + it('skips EXIF entirely when OffsetTimeOriginal is absent, even if DateTimeOriginal is present', async () => { + mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10' }); + + const result = await extractExifCaptureDate(Buffer.from('jpeg-bytes')); + + expect(result).toBeUndefined(); + }); + + it('skips EXIF when OffsetTimeOriginal is present but malformed', async () => { mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10', OffsetTimeOriginal: 'garbage', @@ -67,10 +70,10 @@ describe('extractExifCaptureDate', () => { const result = await extractExifCaptureDate(Buffer.from('jpeg-bytes')); - expect(result).toBe(Date.UTC(2026, 6, 9, 23, 32, 10) / 1000); + expect(result).toBeUndefined(); }); - it('returns undefined when there is no DateTimeOriginal tag', async () => { + it('returns undefined when there is no EXIF data at all', async () => { mockParse.mockResolvedValue(undefined); const result = await extractExifCaptureDate(Buffer.from('no-exif')); @@ -78,16 +81,22 @@ describe('extractExifCaptureDate', () => { expect(result).toBeUndefined(); }); - it('returns undefined when DateTimeOriginal is not a string', async () => { - mockParse.mockResolvedValue({ DateTimeOriginal: 12345 }); + it('returns undefined when DateTimeOriginal is not a string, even with a valid offset', async () => { + mockParse.mockResolvedValue({ + DateTimeOriginal: 12345, + OffsetTimeOriginal: '+09:00', + }); const result = await extractExifCaptureDate(Buffer.from('weird-exif')); expect(result).toBeUndefined(); }); - it('returns undefined when DateTimeOriginal does not match the expected format', async () => { - mockParse.mockResolvedValue({ DateTimeOriginal: 'not-a-date' }); + it('returns undefined when DateTimeOriginal does not match the expected format, even with a valid offset', async () => { + mockParse.mockResolvedValue({ + DateTimeOriginal: 'not-a-date', + OffsetTimeOriginal: '+09:00', + }); const result = await extractExifCaptureDate(Buffer.from('malformed')); diff --git a/src/lib/telegram/chat/extractExifCaptureDate.ts b/src/lib/telegram/chat/extractExifCaptureDate.ts index 412be1eb..16da0249 100644 --- a/src/lib/telegram/chat/extractExifCaptureDate.ts +++ b/src/lib/telegram/chat/extractExifCaptureDate.ts @@ -11,6 +11,14 @@ const extractExifCaptureDate = async ( pick: ['DateTimeOriginal', 'OffsetTimeOriginal'], reviveValues: false, }); + + // DateTimeOriginal alone carries no timezone, and saleStart is written + // on-chain — so without a real OffsetTimeOriginal (EXIF 2.31+) to compute + // the true UTC instant, we don't guess. Skip EXIF entirely and let the + // caller fall back to upload time. + const offsetMs = parseExifOffsetMs(result?.OffsetTimeOriginal); + if (offsetMs === undefined) return undefined; + const raw = result?.DateTimeOriginal; if (typeof raw !== 'string') return undefined; @@ -27,15 +35,7 @@ const extractExifCaptureDate = async ( Number(second) ); - // DateTimeOriginal alone carries no timezone. When the camera also wrote - // OffsetTimeOriginal (EXIF 2.31+), use it to compute the true UTC instant; - // otherwise fall back to treating the wall-clock value as UTC so the - // result stays deterministic regardless of the host's runtime TZ. - const offsetMs = parseExifOffsetMs(result?.OffsetTimeOriginal); - const epochMs = - offsetMs === undefined ? localAsUtcMs : localAsUtcMs - offsetMs; - - return Math.floor(epochMs / 1000); + return Math.floor((localAsUtcMs - offsetMs) / 1000); } catch { return undefined; }