/** * Best-effort date detection from a photo's filesystem path. * * Used by the Date Taken repair flow to suggest a capture date when the * stored `taken_at` looks wrong (epoch zero, wildly off, missing). Libraries * collected by humans tend to be sorted into dated folders like * `2019-07-12_vacation/` or dumped with camera filenames like * `IMG_20190712_153045.jpg` — both are stronger signals than a reset EXIF * timestamp when the file is clearly misfiled. * * Pure, deterministic, no I/O. Returns `null` when no recognisable date * can be extracted. */ export type DateGuessConfidence = 'high' | 'medium' | 'low' export type DateGuessSource = 'folder' | 'filename' export interface DateGuess { /** The guessed capture date, noon local time for non-specific matches so * timeline-day bucketing isn't ambiguous around midnight. */ date: Date /** How specific the match was — day-level matches are `high`, month-level * `medium`, year-only `low`. */ confidence: DateGuessConfidence /** The substring of the filepath that produced the match — shown in the * UI so the user can sanity-check the guess. */ matched: string /** Whether the date came from the file's basename or an ancestor folder. */ source: DateGuessSource } const MIN_YEAR = 1970 const MAX_YEAR = new Date().getFullYear() + 1 function validYear(y: number): boolean { return Number.isInteger(y) && y >= MIN_YEAR && y <= MAX_YEAR } function validMonth(m: number): boolean { return Number.isInteger(m) && m >= 1 && m <= 12 } function validDay(d: number): boolean { return Number.isInteger(d) && d >= 1 && d <= 31 } /** Construct a Date at local noon (avoids midnight/timezone rounding * into the previous day when the UI formats to YYYY-MM-DD). Returns * null when the (y, m, d) combo rolls over (e.g. Feb 30). */ function makeDate(y: number, m: number, d: number): Date | null { if (!validYear(y) || !validMonth(m) || !validDay(d)) return null const dt = new Date(y, m - 1, d, 12, 0, 0, 0) if ( dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d ) { return null } return dt } /** Split a path into its segments regardless of OS separator. */ function segments(filepath: string): string[] { return filepath.split(/[\\/]+/).filter((s) => s.length > 0) } /** Run every pattern against a single string and return the strongest * match. Day-level > month-level > year-only; within a tier the first * pattern that fires wins (patterns are written in order of specificity). * `source` is stamped onto the returned guess so the caller can tell * filename hits from folder hits. `allowYearOnly` is off for filenames * to avoid treating a camera serial like `DSC2019` as a year match. */ function guessFromString( input: string, source: DateGuessSource, allowYearOnly: boolean, ): DateGuess | null { if (!input) return null // Day+time: YY-MM-DD HH-MM-SS — Synology Photos export. The explicit // HH-MM-SS half is what makes the 2-digit year safe to trust; a random // digit triple won't satisfy the hour/minute/second range checks below. // YY → 2000+YY (this format is a recent export convention). const synology = input.match( /(? = { high: 3, medium: 2, low: 1, } /** * Inspect the filename AND the folder chain for date signals and return * the best candidate. **Filename is the source of truth**: if the basename * yields any valid match at all, it wins — even a year-only filename hit * beats a day-level folder hit. Camera firmwares bake the shutter date * into the filename and operators tend to sort photos into broad * year/month buckets later, so the filename signal is almost always * closer to the real capture date than the folder signal. * * When the filename has nothing, we fall back to a folder scan: * deepest-folder-first for single-segment hits (e.g. `2019-07-12_trip`), * then cross-segment layouts (`/2010/07/12/`, `/2010/07/`), then a bare * year folder as the weakest last resort. */ export function guessDateFromPath(filepath: string): DateGuess | null { if (!filepath) return null const segs = segments(filepath) if (segs.length === 0) return null const filename = segs[segs.length - 1] const folders = segs.slice(0, -1) // Filename is the source of truth: any filename hit (even month-level) // wins over anything the folder tree can offer. Year-only is disabled // for filenames so camera serials don't masquerade as years. const fromFilename = guessFromString(filename, 'filename', false) if (fromFilename) return fromFilename // Folder fallback: walk deepest-first so a nested dated folder beats // an ancestor year folder. First hit wins; we keep walking only if it // was weaker than day-level, in case a shallower segment has a // stronger match (rare, but e.g. `/archive/2019-07-12/month3/`). let bestFolder: DateGuess | null = null for (let i = folders.length - 1; i >= 0; i--) { const hit = guessFromString(folders[i], 'folder', true) if (!hit) continue if (!bestFolder || CONFIDENCE_RANK[hit.confidence] > CONFIDENCE_RANK[bestFolder.confidence]) { bestFolder = hit if (hit.confidence === 'high') break } } // Cross-segment layouts like `/2010/07/12/` can only be found by a // multi-segment scanner — try it and keep whichever is stronger. const fromLayout = guessFromFolderLayout(folders) if ( fromLayout && (!bestFolder || CONFIDENCE_RANK[fromLayout.confidence] > CONFIDENCE_RANK[bestFolder.confidence]) ) { bestFolder = fromLayout } return bestFolder } /** Format a Date as the `value` of an ``. */ export function toDatetimeLocalValue(d: Date): string { const pad = (n: number) => String(n).padStart(2, '0') return ( `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + `T${pad(d.getHours())}:${pad(d.getMinutes())}` ) }