""" 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] # Synology Photos export: `YY-MM-DD HH-MM-SS NNNN.ext`. 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 is mapped to 2000+YY (this format is a recent export convention). _SYNOLOGY_RE = re.compile( r"(? Optional[DateGuess]: if not input: return None m = _SYNOLOGY_RE.search(input) if m: yy, mm, dd = int(m.group(1)), int(m.group(2)), int(m.group(3)) hh, mi, ss = int(m.group(4)), int(m.group(5)), int(m.group(6)) if hh < 24 and mi < 60 and ss < 60: d = _make_date(2000 + yy, mm, dd) if d: d = d.replace(hour=hh, minute=mi, second=ss) return DateGuess( date=d, confidence="high", matched=( f"{m.group(1)}-{m.group(2)}-{m.group(3)} " f"{m.group(4)}:{m.group(5)}:{m.group(6)}" ), source=source, ) 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