Files
mule-image/backend/app/services/date_guess.py
dtoro 30d03d8d4d 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>
2026-04-11 11:48:55 +02:00

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