feat: editable taken_at + folder-based date repair and filter
Lets operators fix corrupted capture dates at scale. Adds an editable Date Taken field with a folder/filename-derived suggestion hint, a bulk Date Taken section in the multi-select sidebar that either applies one date to the whole selection or infers a per-photo date from each path, a warning badge on thumbnails whose stored date disagrees with the path, and a "Date issues" filter pill so suspicious photos can be surfaced and fixed as a group. Edits are written back to EXIF on disk so rescans don't clobber the fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
253
frontend/src/lib/guessDateFromPath.ts
Normal file
253
frontend/src/lib/guessDateFromPath.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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-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())}`
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user