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.
This commit is contained in:
funkpower
2026-09-18 09:31:50 -04:00
parent 11bbfddbe1
commit b57a68bee1
2 changed files with 57 additions and 10 deletions
+33 -5
View File
@@ -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}
+24 -5
View File
@@ -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]