The 0525.mov-style export from Synology Photos uses 2-digit years, which
the existing patterns ignored (all required \d{4}). Result: filename
gave no signal, suggestion fell through to the YYYY/MM folder layout and
snapped to day 15. The explicit HH-MM-SS half rules out random digit
triples, so we trust YY → 2000+YY for this specific shape and surface
the actual capture time, not noon.
280 lines
9.3 KiB
TypeScript
280 lines
9.3 KiB
TypeScript
/**
|
|
* 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(
|
|
/(?<!\d)(\d{2})-(\d{2})-(\d{2})[\s_](\d{2})-(\d{2})-(\d{2})(?!\d)/,
|
|
)
|
|
if (synology) {
|
|
const yy = +synology[1], mm = +synology[2], dd = +synology[3]
|
|
const hh = +synology[4], mi = +synology[5], se = +synology[6]
|
|
if (hh < 24 && mi < 60 && se < 60) {
|
|
const date = makeDate(2000 + yy, mm, dd)
|
|
if (date) {
|
|
date.setHours(hh, mi, se, 0)
|
|
return {
|
|
date,
|
|
confidence: 'high',
|
|
matched:
|
|
`${synology[1]}-${synology[2]}-${synology[3]} ` +
|
|
`${synology[4]}:${synology[5]}:${synology[6]}`,
|
|
source,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Day-level: YYYYMMDD run bounded by non-digits ─ `IMG_20190712_153045`.
|
|
const compact = input.match(/(?<!\d)(\d{4})(\d{2})(\d{2})(?!\d)/)
|
|
if (compact) {
|
|
const date = makeDate(+compact[1], +compact[2], +compact[3])
|
|
if (date) {
|
|
return {
|
|
date,
|
|
confidence: 'high',
|
|
matched: `${compact[1]}-${compact[2]}-${compact[3]}`,
|
|
source,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Day-level: YYYY-MM-DD / YYYY_MM_DD / YYYY.MM.DD ─ `2019-07-12_vacation`.
|
|
const dashed = input.match(/(?<!\d)(\d{4})[-_.](\d{1,2})[-_.](\d{1,2})(?!\d)/)
|
|
if (dashed) {
|
|
const date = makeDate(+dashed[1], +dashed[2], +dashed[3])
|
|
if (date) {
|
|
return {
|
|
date,
|
|
confidence: 'high',
|
|
matched: `${dashed[1]}-${dashed[2]}-${dashed[3]}`,
|
|
source,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Month-level: YYYY-MM / YYYY_MM ─ snapped to day 15. Requires an
|
|
// explicit separator so a filename digit run doesn't misfire.
|
|
const monthOnly = input.match(/(?<!\d)(\d{4})[-_.](\d{1,2})(?!\d)/)
|
|
if (monthOnly) {
|
|
const date = makeDate(+monthOnly[1], +monthOnly[2], 15)
|
|
if (date) {
|
|
return {
|
|
date,
|
|
confidence: 'medium',
|
|
matched: `${monthOnly[1]}-${monthOnly[2]}`,
|
|
source,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Year-only: only enabled for folder segments. A bare year in a
|
|
// filename is too easily confused with a camera serial number.
|
|
if (allowYearOnly) {
|
|
const yearOnly = input.match(/(?<!\d)(\d{4})(?!\d)/)
|
|
if (yearOnly) {
|
|
const date = makeDate(+yearOnly[1], 7, 1)
|
|
if (date) {
|
|
return {
|
|
date,
|
|
confidence: 'low',
|
|
matched: yearOnly[1],
|
|
source,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/** Walk three consecutive path segments looking for `YYYY/MM/DD` or
|
|
* `YYYY/MM` layouts. These patterns span segment boundaries so the
|
|
* single-segment scanner above can't see them. */
|
|
function guessFromFolderLayout(folders: string[]): DateGuess | null {
|
|
// YYYY / MM / DD — day-level, preferred.
|
|
for (let i = 0; i <= folders.length - 3; i++) {
|
|
const a = folders[i]
|
|
const b = folders[i + 1]
|
|
const c = folders[i + 2]
|
|
if (/^\d{4}$/.test(a) && /^\d{1,2}$/.test(b) && /^\d{1,2}$/.test(c)) {
|
|
const date = makeDate(+a, +b, +c)
|
|
if (date) {
|
|
return {
|
|
date,
|
|
confidence: 'high',
|
|
matched: `${a}/${b}/${c}`,
|
|
source: 'folder',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// YYYY / MM — month-level.
|
|
for (let i = 0; i <= folders.length - 2; i++) {
|
|
const a = folders[i]
|
|
const b = folders[i + 1]
|
|
if (/^\d{4}$/.test(a) && /^\d{1,2}$/.test(b)) {
|
|
const date = makeDate(+a, +b, 15)
|
|
if (date) {
|
|
return {
|
|
date,
|
|
confidence: 'medium',
|
|
matched: `${a}/${b}`,
|
|
source: 'folder',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const CONFIDENCE_RANK: Record<DateGuessConfidence, number> = {
|
|
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 `<input type="datetime-local">`. */
|
|
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())}`
|
|
)
|
|
}
|