/** * Best-effort calendar date guessed from a photo's filename / parent * folders, with a confidence label so the UI can warn the user when * the day is fabricated. * * Conventions we support (no false-positive risk): * - Samsung / Android stock 20240226_135421.jpg * - Google Pixel PXL_20240226_135421.123.jpg * - WhatsApp IMG-20240226-WA0001.jpg * - Telegram photo_2024-02-26_13-54-21.jpg * - macOS screenshot Screen Shot 2024-02-26 at 1.54.21 PM.png * - Android screenshot Screenshot_20240226-135421.png * - Manual dot-format 2024.02.26 - title.jpg * - WeChat mmexport1645900000000.jpeg (13-digit ms) * - Facebook saves FB_IMG_1583926812.jpg (10-digit s) * - Path forms 2024/02/26, 2024-02-26, 2024_02_26, * 2024.02.26, plus Y-M-only 2024/02/ * * Conventions we deliberately do NOT parse (locale-ambiguous or no * recoverable signal): * - DD-MM-YYYY / MM-DD-YYYY 26-02-2024.jpg, 02-26-2024.jpg * - 2-digit years 24-02-26.jpg * - Bare sequence numbers IMG_1234.HEIC, DSC_0123.NEF, * GOPR0123.JPG, DJI_0123.JPG * * Used by the EXIF Stripped review tab to surface a date suggestion * row in the metadata sidebar and to power the "Accept date & Keep" * bulk action. */ import { isValidISODate } from '$lib/services/photoprism'; interface Input { fileName?: string; // basename, e.g. '20240226_000000_A6D42DF3.jpg' originalName?: string; // optional second filename signal (PpPhoto.OriginalName) path?: string; // directory portion, e.g. '2024/02' } export interface DateGuess { iso: string; confidence: 'high' | 'medium'; source: | 'filename-agrees-path' | 'filename-only' | 'unix-timestamp' | 'path-ymd' | 'path-ym-default-day'; } interface YMD { y: number; m: number; d: number; } interface YM { y: number; m: number; } function pad2(n: number): string { return n < 10 ? `0${n}` : String(n); } function isoOf(ymd: YMD): string { return `${ymd.y}-${pad2(ymd.m)}-${pad2(ymd.d)}`; } function tryYMD(y: number, m: number, d: number): YMD | null { if (y < 1900 || y > 2100) return null; if (m < 1 || m > 12) return null; if (d < 1 || d > 31) return null; if (!isValidISODate(`${y}-${pad2(m)}-${pad2(d)}`)) return null; return { y, m, d }; } function tryYM(y: number, m: number): YM | null { if (y < 1900 || y > 2100) return null; if (m < 1 || m > 12) return null; return { y, m }; } // `YYYY[sep]MM[sep]DD` for basenames. sep ∈ {nothing, -, _, ., space}. The // non-digit lookbehind/ahead keeps a leading prefix like `PXL_` and a // trailing time like `_135421` from polluting the match. const BASENAME_YMD = /(? new Date().getUTCFullYear() + 1) return null; return tryYMD(y, d.getUTCMonth() + 1, d.getUTCDate()); } function parsePathYMD(path: string): YMD | null { const m = path.match(PATH_YMD); if (!m) return null; return tryYMD(Number(m[1]), Number(m[2]), Number(m[3])); } function parsePathYM(path: string): YM | null { const m = path.match(PATH_YM); if (!m) return null; return tryYM(Number(m[1]), Number(m[2])); } function ymdAgreesWithYM(ymd: YMD, ym: YM): boolean { return ymd.y === ym.y && ymd.m === ym.m; } function ymdEqual(a: YMD, b: YMD): boolean { return a.y === b.y && a.m === b.m && a.d === b.d; } /** Pick the filename-derived YMD that best aligns with the path. When * both `fileName` and `originalName` yield candidates, prefer the one * that matches the path's year+month; ties fall back to `fileName`. */ function pickFilenameYMD( fileName: string, originalName: string, pathYM: YM | null ): YMD | null { const candidates: YMD[] = []; const a = parseFilenameYMD(fileName); if (a) candidates.push(a); if (originalName && originalName !== fileName) { const b = parseFilenameYMD(originalName); if (b && !candidates.some((c) => ymdEqual(c, b))) candidates.push(b); } if (candidates.length === 0) return null; if (!pathYM) return candidates[0]; const aligned = candidates.find((c) => ymdAgreesWithYM(c, pathYM)); return aligned ?? candidates[0]; } function pickUnixTimestamp(fileName: string, originalName: string): YMD | null { return ( parseUnixTimestampInName(fileName) ?? (originalName && originalName !== fileName ? parseUnixTimestampInName(originalName) : null) ); } export function suggestDateFromPath(input: Input): DateGuess | null { const fileName = (input.fileName ?? '').trim(); const originalName = (input.originalName ?? '').trim(); const path = (input.path ?? '').trim(); const pathYMD = path ? parsePathYMD(path) : null; const pathYM = path && !pathYMD ? parsePathYM(path) : null; // 1. Filename Y-M-D corroborated by the path. const fnYMD = pickFilenameYMD(fileName, originalName, pathYM); if (fnYMD) { if (pathYMD && ymdEqual(fnYMD, pathYMD)) { return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' }; } if (pathYM && ymdAgreesWithYM(fnYMD, pathYM)) { return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' }; } // 2. Filename Y-M-D with no path signal at all. if (!pathYMD && !pathYM) { return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-only' }; } // Filename present but disagrees with path → fall through. } // 3. Unix epoch in filename, optionally corroborated. const epoch = pickUnixTimestamp(fileName, originalName); if (epoch) { if (!pathYM && !pathYMD) { return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' }; } if (pathYM && ymdAgreesWithYM(epoch, pathYM)) { return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' }; } if (pathYMD && ymdEqual(epoch, pathYMD)) { return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' }; } // disagreement → fall through to path } // 4. Path Y-M-D standalone. if (pathYMD) { return { iso: isoOf(pathYMD), confidence: 'high', source: 'path-ymd' }; } // 5. Path Y-M with synthesised day = 01. if (pathYM) { const ymd = tryYMD(pathYM.y, pathYM.m, 1); if (ymd) { return { iso: isoOf(ymd), confidence: 'medium', source: 'path-ym-default-day' }; } } return null; }