from __future__ import annotations import calendar import logging import re import unicodedata from dataclasses import dataclass from datetime import date, datetime from pathlib import Path from zoneinfo import ZoneInfo import frontmatter _LOGGER = logging.getLogger("tenbackward.matching") _FILENAME_PATTERN = re.compile(r"^(\d{4})-(\d{2})-(\d{2})-(.+)$") _NON_ALNUM_RE = re.compile(r"[^a-z0-9]+") _DEFAULT_TZ = "Europe/Berlin" @dataclass(frozen=True) class MatchedPost: path: str title: str date: date url: str def slugify_title(title: str) -> str: """Return a URL-safe slug derived from ``title``. Used by the matcher to derive the slug component of :attr:`MatchedPost.url` from the resolved post title. """ normalized = unicodedata.normalize("NFKD", title) ascii_only = normalized.encode("ascii", "ignore").decode("ascii") lowered = ascii_only.lower() dashed = _NON_ALNUM_RE.sub("-", lowered) return dashed.strip("-") def _today_in_berlin(tz_name: str = _DEFAULT_TZ) -> date: return datetime.now(ZoneInfo(tz_name)).date() def _is_leap_year(year: int) -> bool: return calendar.isleap(year) def _try_parse_date(value: object) -> date | None: if isinstance(value, datetime): return value.date() if isinstance(value, date): return value return None def _safe_filename_match(stem: str) -> tuple[int, int, int, str] | None: match = _FILENAME_PATTERN.match(stem) if match is None: return None year_str, month_str, day_str, slug = match.groups() try: year = int(year_str) month = int(month_str) day = int(day_str) except ValueError: return None try: date(year, month, day) except ValueError: return None if not slug: return None return year, month, day, slug def _build_url(site_url: str, post_date: date, slug: str) -> str: base = site_url.rstrip("/") return f"{base}/{post_date.year:04d}/{post_date.month:02d}/{post_date.day:02d}/{slug}/" def _is_unpublished(metadata: dict) -> bool: if "published" not in metadata: return False return metadata["published"] is False def _matches_anniversary(post_date: date, target_year: int, today: date) -> bool: if post_date.year != target_year: return False if post_date.month == 2 and post_date.day == 29: if _is_leap_year(today.year): return today.month == 2 and today.day == 29 return today.month == 3 and today.day == 1 return post_date.month == today.month and post_date.day == today.day def _process_file( md_path: Path, post_root: Path, site_url: str, target_year: int, today: date, ) -> MatchedPost | None: rel = md_path.relative_to(post_root).as_posix() try: with md_path.open("r", encoding="utf-8") as fh: text = fh.read() except (OSError, UnicodeDecodeError) as exc: _LOGGER.warning( "skipping unreadable post file", extra={"event": "post_unreadable", "path": rel, "error": type(exc).__name__}, ) return None title: str | None = None post_date: date | None = None unpublished = False frontmatter_parsed = False try: parsed = frontmatter.loads(text) frontmatter_parsed = True metadata = parsed.metadata if isinstance(parsed.metadata, dict) else {} if isinstance(metadata.get("title"), str) and metadata["title"].strip(): title = metadata["title"] post_date = _try_parse_date(metadata.get("date")) unpublished = _is_unpublished(metadata) except Exception as exc: # noqa: BLE001 — frontmatter is opaque _LOGGER.warning( "frontmatter parse failed", extra={"event": "post_frontmatter_error", "path": rel, "error": type(exc).__name__}, ) slug_match = _safe_filename_match(md_path.stem) if slug_match is None: if not frontmatter_parsed: return None _LOGGER.warning( "filename missing required YYYY-MM-DD-slug pattern", extra={"event": "post_filename_invalid", "path": rel}, ) return None fn_year, fn_month, fn_day, fn_slug = slug_match if post_date is None: post_date = date(fn_year, fn_month, fn_day) if not _matches_anniversary(post_date, target_year, today): return None if unpublished: _LOGGER.warning( "skipping unpublished post", extra={"event": "post_unpublished", "path": rel}, ) return None if title is None: title = fn_slug return MatchedPost( path=rel, title=title, date=post_date, url=_build_url(site_url, post_date, slugify_title(title)), ) def find_anniversary_matches( post_root: Path, site_url: str, *, today: date | None = None, ) -> list[MatchedPost]: """Walk the Jekyll post directory and return posts whose anniversary date is exactly 10 years before ``today`` (per the leap-day rules). """ if today is None: today = _today_in_berlin() target_year = today.year - 10 if not post_root.exists(): return [] matches: list[MatchedPost] = [] for md_path in sorted(post_root.rglob("*.md")): if not md_path.is_file(): continue try: result = _process_file(md_path, post_root, site_url, target_year, today) except Exception as exc: # noqa: BLE001 — never abort the scan rel = md_path.relative_to(post_root).as_posix() _LOGGER.warning( "unexpected error processing post", extra={ "event": "post_processing_error", "path": rel, "error": type(exc).__name__, }, ) continue if result is not None: matches.append(result) return matches def iter_anniversary_paths(post_root: Path, site_url: str, *, today: date | None = None): """Yield each matched post's relative ``path`` (the dedupe identifier).""" for match in find_anniversary_matches(post_root, site_url, today=today): yield match.path __all__ = [ "MatchedPost", "find_anniversary_matches", "iter_anniversary_paths", "slugify_title", ]