From 4845e7a3c1b70542a8e544716bffb56e957bc66c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 13:24:26 +0200 Subject: [PATCH] feat(normalizer): feast + season resolution Co-Authored-By: Claude Opus 4.7 --- tools/import-normalizer/dates.py | 37 +++++++++++++++++++++ tools/import-normalizer/tests/test_dates.py | 17 ++++++++++ 2 files changed, 54 insertions(+) diff --git a/tools/import-normalizer/dates.py b/tools/import-normalizer/dates.py index 4df42f9f..7b3fcc20 100644 --- a/tools/import-normalizer/dates.py +++ b/tools/import-normalizer/dates.py @@ -1,5 +1,42 @@ """Tolerant historical date parsing for the family archive.""" import datetime +from enum import StrEnum +import config + + +class Precision(StrEnum): + DAY = "DAY" + MONTH = "MONTH" + SEASON = "SEASON" + YEAR = "YEAR" + RANGE = "RANGE" + APPROX = "APPROX" + UNKNOWN = "UNKNOWN" + + +def _advent_sunday(year: int, n: int) -> datetime.date: + """n-th Advent (1..4). 4th Advent = last Sunday on/before Dec 24.""" + dec24 = datetime.date(year, 12, 24) + back_to_sunday = (dec24.weekday() - 6) % 7 # Mon=0..Sun=6 + fourth = dec24 - datetime.timedelta(days=back_to_sunday) + return fourth - datetime.timedelta(days=(4 - n) * 7) + + +def resolve_feast_or_season(token: str, year: int): + """Return (iso, Precision) for a known feast/season token, else None.""" + key = " ".join(token.lower().split()).strip(" .") + if key in config.MOVABLE_FEASTS: + d = easter(year) + datetime.timedelta(days=config.MOVABLE_FEASTS[key]) + return d.isoformat(), Precision.DAY + if key in config.FIXED_FEASTS: + month, day = config.FIXED_FEASTS[key] + return datetime.date(year, month, day).isoformat(), Precision.DAY + advent = {"1. advent": 1, "2. advent": 2, "3. advent": 3, "4. advent": 4, "advent": 1} + if key in advent: + return _advent_sunday(year, advent[key]).isoformat(), Precision.DAY + if key in config.SEASON_MONTHS: + return datetime.date(year, config.SEASON_MONTHS[key], 1).isoformat(), Precision.SEASON + return None def easter(year: int) -> datetime.date: diff --git a/tools/import-normalizer/tests/test_dates.py b/tools/import-normalizer/tests/test_dates.py index d46df4d0..d834b02a 100644 --- a/tools/import-normalizer/tests/test_dates.py +++ b/tools/import-normalizer/tests/test_dates.py @@ -1,5 +1,6 @@ import datetime import dates +from dates import Precision def test_easter_known_years(): # Anonymous Gregorian algorithm — verified against published tables @@ -7,3 +8,19 @@ def test_easter_known_years(): assert dates.easter(2000) == datetime.date(2000, 4, 23) assert dates.easter(1922) == datetime.date(1922, 4, 16) assert dates.easter(1888) == datetime.date(1888, 4, 1) + +def test_resolve_feast_movable(): + assert dates.resolve_feast_or_season("Pfingsten", 1922) == ("1922-06-04", Precision.DAY) + assert dates.resolve_feast_or_season("Ostern", 2024) == ("2024-03-31", Precision.DAY) + assert dates.resolve_feast_or_season("Pfingstmontag", 1922) == ("1922-06-05", Precision.DAY) + +def test_resolve_feast_fixed(): + assert dates.resolve_feast_or_season("Weihnachten", 1900) == ("1900-12-25", Precision.DAY) + assert dates.resolve_feast_or_season("Neujahr", 1910) == ("1910-01-01", Precision.DAY) + +def test_resolve_season(): + assert dates.resolve_feast_or_season("Herbst", 1913) == ("1913-10-01", Precision.SEASON) + assert dates.resolve_feast_or_season("Sommer", 1910) == ("1910-07-01", Precision.SEASON) + +def test_resolve_unknown_token_returns_none(): + assert dates.resolve_feast_or_season("Freitag", 1919) is None