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:
2026-04-11 11:48:55 +02:00
parent 339e1be510
commit 30d03d8d4d
19 changed files with 1281 additions and 26 deletions

View 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())}`
)
}