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>
215 lines
6.5 KiB
Python
215 lines
6.5 KiB
Python
"""
|
|
Folder/filename-based date guessing and "taken_at looks wrong" detection.
|
|
|
|
Direct Python port of `frontend/src/lib/guessDateFromPath.ts` — the logic
|
|
must stay in sync because the frontend renders the suggestion hint in the
|
|
info panel while the backend owns the `has_date_warning` flag that the
|
|
filter bar queries. Any heuristic change has to be applied to both files.
|
|
|
|
The guesser walks a filepath, tries the filename first as the source of
|
|
truth, then falls back to folder segments (deepest first) and multi-
|
|
segment layouts. Returns ``None`` when no recognisable date can be
|
|
extracted. `has_date_warning()` compares the guess to a stored `taken_at`
|
|
and reports whether the difference is large enough to flag.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Literal, Optional
|
|
|
|
|
|
Confidence = Literal["high", "medium", "low"]
|
|
Source = Literal["folder", "filename"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DateGuess:
|
|
date: datetime
|
|
confidence: Confidence
|
|
matched: str
|
|
source: Source
|
|
|
|
|
|
_MIN_YEAR = 1970
|
|
# Bump the ceiling annually via `datetime.now()` rather than a literal so
|
|
# we don't ship a time bomb. `+1` allows near-future timestamps (cameras
|
|
# with a slightly advanced clock at year end) without opening the door to
|
|
# 4-digit serial numbers that happen to start with "30xx".
|
|
def _max_year() -> int:
|
|
return datetime.now().year + 1
|
|
|
|
|
|
def _valid_year(y: int) -> bool:
|
|
return _MIN_YEAR <= y <= _max_year()
|
|
|
|
|
|
def _make_date(y: int, m: int, d: int) -> Optional[datetime]:
|
|
if not _valid_year(y):
|
|
return None
|
|
if not (1 <= m <= 12):
|
|
return None
|
|
if not (1 <= d <= 31):
|
|
return None
|
|
try:
|
|
# Noon local so downstream day-bucketing is stable across timezone
|
|
# rounding. The frontend mirrors this.
|
|
return datetime(y, m, d, 12, 0, 0)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _segments(filepath: str) -> list[str]:
|
|
return [s for s in re.split(r"[\\/]+", filepath) if s]
|
|
|
|
|
|
_COMPACT_RE = re.compile(r"(?<!\d)(\d{4})(\d{2})(\d{2})(?!\d)")
|
|
_DASHED_RE = re.compile(r"(?<!\d)(\d{4})[-_.](\d{1,2})[-_.](\d{1,2})(?!\d)")
|
|
_MONTH_RE = re.compile(r"(?<!\d)(\d{4})[-_.](\d{1,2})(?!\d)")
|
|
_YEAR_RE = re.compile(r"(?<!\d)(\d{4})(?!\d)")
|
|
_FOUR_DIGITS = re.compile(r"^\d{4}$")
|
|
_ONE_OR_TWO = re.compile(r"^\d{1,2}$")
|
|
|
|
|
|
def _guess_from_string(
|
|
input: str,
|
|
source: Source,
|
|
allow_year_only: bool,
|
|
) -> Optional[DateGuess]:
|
|
if not input:
|
|
return None
|
|
|
|
m = _COMPACT_RE.search(input)
|
|
if m:
|
|
d = _make_date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
if d:
|
|
return DateGuess(
|
|
date=d,
|
|
confidence="high",
|
|
matched=f"{m.group(1)}-{m.group(2)}-{m.group(3)}",
|
|
source=source,
|
|
)
|
|
|
|
m = _DASHED_RE.search(input)
|
|
if m:
|
|
d = _make_date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
if d:
|
|
return DateGuess(
|
|
date=d,
|
|
confidence="high",
|
|
matched=f"{m.group(1)}-{m.group(2)}-{m.group(3)}",
|
|
source=source,
|
|
)
|
|
|
|
m = _MONTH_RE.search(input)
|
|
if m:
|
|
d = _make_date(int(m.group(1)), int(m.group(2)), 15)
|
|
if d:
|
|
return DateGuess(
|
|
date=d,
|
|
confidence="medium",
|
|
matched=f"{m.group(1)}-{m.group(2)}",
|
|
source=source,
|
|
)
|
|
|
|
if allow_year_only:
|
|
m = _YEAR_RE.search(input)
|
|
if m:
|
|
d = _make_date(int(m.group(1)), 7, 1)
|
|
if d:
|
|
return DateGuess(
|
|
date=d,
|
|
confidence="low",
|
|
matched=m.group(1),
|
|
source=source,
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def _guess_from_folder_layout(folders: list[str]) -> Optional[DateGuess]:
|
|
# YYYY / MM / DD
|
|
for i in range(len(folders) - 2):
|
|
a, b, c = folders[i], folders[i + 1], folders[i + 2]
|
|
if _FOUR_DIGITS.match(a) and _ONE_OR_TWO.match(b) and _ONE_OR_TWO.match(c):
|
|
d = _make_date(int(a), int(b), int(c))
|
|
if d:
|
|
return DateGuess(
|
|
date=d,
|
|
confidence="high",
|
|
matched=f"{a}/{b}/{c}",
|
|
source="folder",
|
|
)
|
|
# YYYY / MM
|
|
for i in range(len(folders) - 1):
|
|
a, b = folders[i], folders[i + 1]
|
|
if _FOUR_DIGITS.match(a) and _ONE_OR_TWO.match(b):
|
|
d = _make_date(int(a), int(b), 15)
|
|
if d:
|
|
return DateGuess(
|
|
date=d,
|
|
confidence="medium",
|
|
matched=f"{a}/{b}",
|
|
source="folder",
|
|
)
|
|
return None
|
|
|
|
|
|
_CONFIDENCE_RANK: dict[Confidence, int] = {"high": 3, "medium": 2, "low": 1}
|
|
|
|
|
|
def guess_date_from_path(filepath: str) -> Optional[DateGuess]:
|
|
"""Filename wins when it has any viable match; otherwise walk folder
|
|
segments deepest-first and pick the strongest hit."""
|
|
if not filepath:
|
|
return None
|
|
|
|
segs = _segments(filepath)
|
|
if not segs:
|
|
return None
|
|
filename = segs[-1]
|
|
folders = segs[:-1]
|
|
|
|
from_filename = _guess_from_string(filename, "filename", allow_year_only=False)
|
|
if from_filename:
|
|
return from_filename
|
|
|
|
best: Optional[DateGuess] = None
|
|
for seg in reversed(folders):
|
|
hit = _guess_from_string(seg, "folder", allow_year_only=True)
|
|
if not hit:
|
|
continue
|
|
if not best or _CONFIDENCE_RANK[hit.confidence] > _CONFIDENCE_RANK[best.confidence]:
|
|
best = hit
|
|
if hit.confidence == "high":
|
|
break
|
|
|
|
from_layout = _guess_from_folder_layout(folders)
|
|
if from_layout and (
|
|
not best or _CONFIDENCE_RANK[from_layout.confidence] > _CONFIDENCE_RANK[best.confidence]
|
|
):
|
|
best = from_layout
|
|
|
|
return best
|
|
|
|
|
|
_ONE_DAY = 24 * 60 * 60
|
|
|
|
|
|
def has_date_warning(filepath: str, taken_at: Optional[datetime]) -> bool:
|
|
"""True when the path-based guess disagrees with ``taken_at`` by more
|
|
than 24h, or when ``taken_at`` is missing and the path would supply
|
|
one. This is the authoritative flag stored on `photos.has_date_warning`
|
|
and queried by the timeline filter."""
|
|
guess = guess_date_from_path(filepath)
|
|
if not guess:
|
|
return False
|
|
if taken_at is None:
|
|
return True
|
|
try:
|
|
diff = abs((taken_at - guess.date).total_seconds())
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return diff > _ONE_DAY
|