226 lines
6.3 KiB
Python
226 lines
6.3 KiB
Python
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",
|
|
] |