From b57a68bee1759d5a2382c6fb5933c831b0c251e5 Mon Sep 17 00:00:00 2001 From: funkpower Date: Fri, 18 Sep 2026 09:31:50 -0400 Subject: [PATCH] a fetched menaion stands in for the common one, and its festal markers become the cycle Two things went wrong when the Ruthenian set was overlaid on the packaged menologion. Every day listed each saint twice, once in each set's wording, because the overlay only ever appended. And MCI writes the festal cycle inline as though it were a commemoration -- 'Post-festive Day of the Exaltation' -- so it crowded out the saint that makes the day, which is the problem the structured cycle field existed to solve. A set can now declare mode=replace in a reserved _meta key, which the fetcher writes because a published calendar of saints is a complete calendar, not a few additions. Rank and cycle still come from the common set, since a published list carries neither and the fasting rules need the rank. A set without the key still appends, now without duplicating. The MCI parser turns the inline markers into the cycle field instead of leaving them in the list. --- typikon/menaia.py | 38 +++++++++++++++++++++++++++++++++----- typikon/menologion.py | 29 ++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/typikon/menaia.py b/typikon/menaia.py index 9154a51..87c9a9b 100644 --- a/typikon/menaia.py +++ b/typikon/menaia.py @@ -72,20 +72,42 @@ def _parse_mci(html): dated = re.compile(r"^(" + "|".join(MONTHS) + r")\s+(\d{1,2})\s*(.*)$") heading = re.compile(r"^(" + "|".join(MONTHS) + r")\.?$") + # MCI writes the festal cycle inline, as a commemoration among the saints: + # "Pre-festive Day of the Theophany". It repeats for days at a time and is + # not a saint, so it becomes the day's cycle and leaves the list alone. + festal = re.compile(r"^(Pre-festive|Post-festive|Fore-feast|After-feast|" + r"Leave-taking)\b[ ,]*(?:Day\s+)?(?:of\s+)?(.*)$", re.I) + cycle_word = {"pre-festive": "forefeast", "fore-feast": "forefeast", + "post-festive": "afterfeast", "after-feast": "afterfeast", + "leave-taking": "leave-taking"} + out, cur, buf, corrections = {}, None, [], [] prev_month = prev_day = None def flush(): if not (cur and buf): return - items = [] + items, cycle = [], None for piece in buf: for part in re.split(r"(?<=\.)\s+(?=[A-Z(])", piece): part = part.strip(" .;") - if len(part) > 3 and not dated.match(part) and not heading.match(part): - items.append(part) + if len(part) <= 3 or dated.match(part) or heading.match(part): + continue + m = festal.match(part) + if m: + if cycle is None: + word = cycle_word[m.group(1).lower()] + rest = m.group(2).strip(" .,") + cycle = f"{word} of {rest}" if rest else word + continue + items.append(part) + entry = {} if items: - out[cur] = {"commemorations": items} + entry["commemorations"] = items + if cycle: + entry["cycle"] = cycle + if entry: + out[cur] = entry for line in lines: if heading.match(line): @@ -135,9 +157,15 @@ def update(name): pass missing = sorted(expected - set(entries)) + # This is a complete calendar published by that church, so it stands in + # for the common commemorations rather than piling on top of them. + payload = {"_meta": {"mode": "replace", "source": source["url"], + "title": source["title"]}} + payload.update(entries) + path = os.path.join(target_dir(), f"{name}.json") with open(path, "w", encoding="utf-8") as fh: - json.dump(entries, fh, ensure_ascii=False, indent=1, sort_keys=True) + json.dump(payload, fh, ensure_ascii=False, indent=1, sort_keys=True) return {"path": path, "days": len(entries), "corrections": corrections, "missing": missing, "source": source} diff --git a/typikon/menologion.py b/typikon/menologion.py index 86321c3..f7a7dc7 100644 --- a/typikon/menologion.py +++ b/typikon/menologion.py @@ -19,23 +19,42 @@ _cache = {} class Menologion: + """A fixed calendar, plus whatever a jurisdiction overlays on it. + + ``_meta`` is a reserved key, not a day. It carries ``mode``: + + add the set's commemorations join the common ones (the default, + right for a local set that adds a few of its own saints) + replace the set's commemorations stand in for them, because it is + itself a complete calendar published by that church + + Either way the common set's ``rank`` and ``cycle`` survive unless the + overlay gives its own, since a published list of saints usually carries + neither and the fasting rules need the rank. + """ + def __init__(self, entries): - self.entries = entries + self.meta = entries.get("_meta", {}) + self.entries = {k: v for k, v in entries.items() if k != "_meta"} def get(self, month, day): return self.entries.get(f"{month:02d}-{day:02d}", {}) def overlay(self, other): - """Add another set on top of this one, keeping both days' entries.""" + replace = other.meta.get("mode") == "replace" merged = {k: dict(v) for k, v in self.entries.items()} for key, add in other.entries.items(): if key not in merged: merged[key] = dict(add) continue base = merged[key] - base["commemorations"] = (list(base.get("commemorations", [])) - + list(add.get("commemorations", []))) - # A supplementary set may raise a day's rank but never lower it. + extra = list(add.get("commemorations", [])) + if replace: + base["commemorations"] = extra or base.get("commemorations", []) + else: + seen = list(base.get("commemorations", [])) + base["commemorations"] = seen + [c for c in extra if c not in seen] + # A supplementary set may state a rank or a cycle; it never erases one. for field in ("rank", "cycle"): if add.get(field): base[field] = add[field]