Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 59 additions & 13 deletions src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,43 +14,89 @@ beforeEach(() => {
});

describe('extractExifCaptureDate', () => {
it('parses a raw EXIF DateTimeOriginal string as UTC', 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'));

expect(mockParse).toHaveBeenCalledWith(expect.any(Buffer), {
pick: ['DateTimeOriginal', 'OffsetTimeOriginal'],
reviveValues: false,
});
});

it('computes the true UTC instant when OffsetTimeOriginal is 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) / 1000);
expect(result).toBe(
(Date.UTC(2026, 6, 9, 23, 32, 10) + 5 * 60 * 60_000) / 1000
);
});

it('requests raw (non-revived) values so the result is TZ-independent', async () => {
it('skips EXIF entirely when OffsetTimeOriginal is absent, even if DateTimeOriginal is present', async () => {
mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10' });

await extractExifCaptureDate(Buffer.from('jpeg-bytes'));
const result = await extractExifCaptureDate(Buffer.from('jpeg-bytes'));

expect(mockParse).toHaveBeenCalledWith(expect.any(Buffer), {
pick: ['DateTimeOriginal'],
reviveValues: false,
expect(result).toBeUndefined();
});

it('skips EXIF when OffsetTimeOriginal is present but malformed', async () => {
mockParse.mockResolvedValue({
DateTimeOriginal: '2026:07:09 23:32:10',
OffsetTimeOriginal: 'garbage',
});

const result = await extractExifCaptureDate(Buffer.from('jpeg-bytes'));

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'));

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'));

Expand Down
28 changes: 28 additions & 0 deletions src/lib/telegram/chat/__tests__/parseExifOffsetMs.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
18 changes: 13 additions & 5 deletions src/lib/telegram/chat/extractExifCaptureDate.ts
Original file line number Diff line number Diff line change
@@ -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})$/;

Expand All @@ -7,27 +8,34 @@ const extractExifCaptureDate = async (
): Promise<number | undefined> => {
try {
const result = await exifr.parse(buffer, {
pick: ['DateTimeOriginal'],
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;

const match = EXIF_DATE_PATTERN.exec(raw);
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),
Number(hour),
Number(minute),
Number(second)
);
return Math.floor(epochMs / 1000);

return Math.floor((localAsUtcMs - offsetMs) / 1000);
} catch {
return undefined;
}
Expand Down
13 changes: 13 additions & 0 deletions src/lib/telegram/chat/parseExifOffsetMs.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading