Files
mule-image/web/src/lib/utils/suggestDateFromPath.ts
dtoro c134afe023 web(review): confidence-aware date guesser with combined filename + path signals
Refactor suggestDateFromPath to combine multiple signals instead of
trying patterns in priority order:

- Filename Y-M-D corroborated by path Y-M/Y-M-D → HIGH (filename-
  agrees-path). Fixes the case where a Samsung-style 20240226_xxx.jpg
  under 2024/02/ was returning the path-only 2024-02-01.
- Filename Y-M-D with no path signal → HIGH (filename-only).
- 10/13-digit Unix epoch in basename → HIGH (unix-timestamp) —
  covers WeChat (mmexport...) and FB saves.
- Path Y-M-D → HIGH (path-ymd).
- Path Y-M only → MEDIUM (path-ym-default-day, synthesised day=01).
  Sidebar row labels these "(estimated day)" so the user knows.

Filename parser now accepts `.` and space separators (covers macOS
screenshots, manual 2024.02.26 renames). Path parser accepts `.` too.
OriginalName participates as a secondary filename signal when present
and different from the on-disk basename.

Patterns we explicitly DO NOT parse, to avoid silent date flips:
DD-MM-YYYY / MM-DD-YYYY, 2-digit years, bare camera sequence numbers.

Add photoNameAndDir(p) helper next to primaryFile so RightSidebar,
BulkActionBar, photoActions, and gridKeyNav all derive {fileName,
path} the same way — fixes the bug where photo.FileName was
undefined on the single-photo detail endpoint and the basename branch
was being skipped entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:04:48 +02:00

222 lines
7.3 KiB
TypeScript

/**
* 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 = /(?<!\d)(\d{4})[-_. ]?(\d{2})[-_. ]?(\d{2})(?!\d)/;
// Path Y-M-D and Y-M. Includes `/` for directory separators and `.` for
// rare dot-organised libraries (`Photos/2024.02/...`).
const PATH_YMD = /(?<!\d)(\d{4})[-_/.](\d{2})[-_/.](\d{2})(?!\d)/;
const PATH_YM = /(?<!\d)(\d{4})[-_/.](\d{2})(?!\d)/;
// 10- or 13-digit Unix epoch, anchored. Years widened to [1990, current+1]
// to dodge accidental matches on phone numbers, hex hashes containing
// digits, etc. — but 10-digit seconds still has to round-trip into a
// plausible calendar year before we trust it.
const BASENAME_EPOCH = /(?<!\d)(\d{10}|\d{13})(?!\d)/;
function parseFilenameYMD(name: string): YMD | null {
const m = name.match(BASENAME_YMD);
if (!m) return null;
return tryYMD(Number(m[1]), Number(m[2]), Number(m[3]));
}
function parseUnixTimestampInName(name: string): YMD | null {
const m = name.match(BASENAME_EPOCH);
if (!m) return null;
const digits = m[1];
const ms = digits.length === 13 ? Number(digits) : Number(digits) * 1000;
if (!Number.isFinite(ms)) return null;
const d = new Date(ms);
if (Number.isNaN(d.getTime())) return null;
const y = d.getUTCFullYear();
if (y < 1990 || y > 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;
}