#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ webcal_kalender.py ================== Turn Gramps "Web Calendar" HTML output into styled birthday/anniversary calendar pages for the website. Input : /cal//1.html .. 12.html (raw Gramps Web Calendar output) Output : 12 styled HTML pages + kalender.css (in place, or in --ud) The Web Calendar report is generated WITHOUT the "Link to Narrative Web" option, so person names are plain text and there are no links back to the family site. Language (Danish / English) is auto-detected from the attribute of the source files; override with --sprog. USAGE: ------ # From a folder that contains cal// - does everything, no arguments: python webcal_kalender.py # A specific site folder and a single year: python webcal_kalender.py --sti Henning/BogHenning --aar 2026 # Write the styled pages to a separate folder instead of in place: python webcal_kalender.py --ud Henning/BogHenning/kalender REQUIREMENTS: ------------- Python 3.6+ - no extra packages needed """ import re import json import html import calendar import argparse import datetime from pathlib import Path from html.parser import HTMLParser # ═══════════════════════════════════════════════════════ # Language packs # (English first; Danish is the translation - see preferences) # # These are the built-in defaults. You can also drop extra packs as # JSON files in a sprog/ folder next to this script - one file per # language, e.g. sprog/de.json - and they are picked up automatically. # Run --dump-sprog to write en.json + da.json as templates to copy. # ═══════════════════════════════════════════════════════ BUILTIN_LANG = { "en": { "months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], "weekdays": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], "title": "Family Calendar", "page_title": "Calendar - {month} {year}", "years": "years", "legend_alive": "Birthday (living)", "legend_dead": "Birthday (deceased)", "legend_death": "Death", "legend_wedding": "Anniversary", "legend_hint": "Click +count \U0001F81F at a date for historical events", "toggle_title": "Show historical events", # Wedding line is rebuilt from the numbers, so it is always in this # language (no matter what language Gramps wrote in the source): "wed_anniv": "{n} year anniversary", "wed_married": "married {n} years", "wed_until": "until {year}", "wed_word": "wedding", "couple_join": "and", "html_lang": "en", }, "da": { "months": ["Januar", "Februar", "Marts", "April", "Maj", "Juni", "Juli", "August", "September", "Oktober", "November", "December"], "weekdays": ["Man", "Tir", "Ons", "Tor", "Fre", "Lør", "Søn"], "title": "Slægtskalender", "page_title": "Kalender - {month} {year}", "years": "år", "legend_alive": "Fødselsdag (i live)", "legend_dead": "Fødselsdag (afdød)", "legend_death": "Dødsdag", "legend_wedding": "Bryllupsdag", "legend_hint": "Tryk +antal \U0001F81F ved datoen for historiske begivenheder", "toggle_title": "Vis historiske begivenheder", "wed_anniv": "{n} års bryllupsdag", "wed_married": "gift i {n} år", "wed_until": "indtil {year}", "wed_word": "bryllupsdag", "couple_join": "og", "html_lang": "da", }, } # Internal event-type keys -> presentational CSS class + icon. # CSS class names are kept from the previous calendar so the styling is reused. CSS_CLASS = { "birthday": "foedselsdag", "birthday_deceased": "foedselsdag_afdoed", "death": "doed", "anniversary": "bryllupsdag", } ICON = { "birthday": "🎂", "birthday_deceased": "🕯️", "death": "✝", "anniversary": "💍", } # Sort order for the extra (historical) events behind the toggle. EXTRA_ORDER = {"birthday_deceased": 0, "anniversary": 1, "death": 2} # Operator/terminal messages — Danish or English (see console_lang()). CONSOLE = { "da": { "year": "År", "source": "Kilde", "language": "Sprog", "skip_missing": " ⚠️ {f} mangler — springer over.", "no_events": " ⚠️ Ingen Web Calendar-begivenheder fundet — skriver IKKE " "(allerede bygget eller forkert mappe?).", "found": " Fundet : {n} begivenheder", "pages": " 📅 12 sider + kalender.css → {dir}", "missing_year": " ❌ FEJL: mappen for {y} mangler: {path}\n" " Lav {y} i Gramps' Web Calendar.", "index": " 🔗 index_kalender.html → {a} + {b}", "unknown_lang": " ⚠️ Ukendt sprog '{code}' — bruger 'en'.", "bad_forced": "Fejl: ukendt sprog '{code}'. Kendte: {known}", "done": "\nFærdig!\n", }, "en": { "year": "Year", "source": "Source", "language": "Language", "skip_missing": " ⚠️ {f} missing — skipping.", "no_events": " ⚠️ No Web Calendar events found — NOT writing " "(already built or wrong folder?).", "found": " Found : {n} events", "pages": " 📅 12 pages + kalender.css → {dir}", "missing_year": " ❌ ERROR: folder for {y} is missing: {path}\n" " Generate {y} in Gramps' Web Calendar.", "index": " 🔗 index_kalender.html → {a} + {b}", "unknown_lang": " ⚠️ Unknown language '{code}' — using 'en'.", "bad_forced": "Error: unknown language '{code}'. Known: {known}", "done": "\nDone!\n", }, } def load_languages(script_dir: Path) -> dict: """Built-in packs, plus any sprog/.json found next to the script. An external file with the same code overrides the built-in one, and any key it leaves out falls back to English - so a partial pack still works and you can tweak just a few words.""" langs = {code: dict(pack) for code, pack in BUILTIN_LANG.items()} sprog_dir = script_dir / "sprog" if sprog_dir.is_dir(): for f in sorted(sprog_dir.glob("*.json")): try: data = json.loads(f.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as err: print(f" ⚠️ Kunne ikke læse {f.name}: {err}") continue pack = dict(BUILTIN_LANG.get(f.stem, BUILTIN_LANG["en"])) # base pack.update(data) # overlay pack.setdefault("html_lang", f.stem) langs[f.stem] = pack return langs def dump_language_templates(script_dir: Path): """Write the built-in packs to sprog/.json so they can be copied as a starting point for a new language.""" sprog_dir = script_dir / "sprog" sprog_dir.mkdir(parents=True, exist_ok=True) for code, pack in BUILTIN_LANG.items(): f = sprog_dir / f"{code}.json" f.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8") print(f" 📝 {f}") print(f"\nKopiér fx {sprog_dir / 'en.json'} til {sprog_dir / 'de.json'} og oversæt.") # ═══════════════════════════════════════════════════════ # HTML parser (Web Calendar, no links) # ═══════════════════════════════════════════════════════ class WebCalParser(HTMLParser): """ Extract person events from one Gramps Web Calendar month page. Cell structure (no links):
1
  • Name Name, 72 years old
  • Name Name, Died 5 October 2016. (88 years, 9 months)
  • Name Name, ✝ 36 years since death
  • A and B, 10 year anniversary
The month is taken from the file name and the day from
, so the (localized) id is never needed. The event type is decided purely from structure: = anniversary, = a deceased person's birthday, a bare ✝ = a death anniversary, otherwise a living birthday. """ def __init__(self, month: int, year: int): super().__init__() self.month = month self.year = year self.events = [] self._clickable = 0 self._day = None self._want_day = False self._reset_li() def _reset_li(self): self._in_li = False self._name_open = False self._name = "" self._in_em = False self._em = "" self._is_death = False self._is_deceased = False self._is_married = False # ── start tags ────────────────────────────────────── def handle_starttag(self, tag, attrs): a = dict(attrs) cls = a.get("class", "") if tag == "div": if "clickable" in cls: self._clickable = 1 elif self._clickable: self._clickable += 1 if self._clickable and "date" in cls: self._want_day = True if not self._clickable: return if tag == "li": self._reset_li() self._in_li = True self._name_open = True elif self._in_li: if tag == "span" and "yearsmarried" in cls: self._is_married = True elif tag == "font": self._name_open = False self._is_deceased = True elif tag == "em": self._name_open = False self._in_em = True self._em = "" # ── text ──────────────────────────────────────────── def handle_data(self, data): s = data.strip() if not s: return if self._want_day: if s.isdigit(): self._day = int(s) self._want_day = False return if not self._in_li: return if self._in_em: self._em += s return # A bare ✝ (not inside ) marks a death anniversary if "✝" in s and not self._is_deceased: self._is_death = True if self._name_open: self._name += (" " if self._name else "") + s # ── end tags ──────────────────────────────────────── def handle_endtag(self, tag): if tag == "em": self._in_em = False elif tag == "li" and self._in_li: self._save() self._reset_li() elif tag == "div" and self._clickable: self._clickable -= 1 # ── store one event ───────────────────────────────── def _save(self): name = self._name.replace("✝", "").strip().rstrip(",").strip() if not name or self._day is None: return em = self._em.strip() age = None death_text = None anniv_text = None if self._is_married: etype = "anniversary" anniv_text = em or None # raw; localised in event_html elif self._is_deceased: etype = "birthday_deceased" # "Døde 5 oktober 2016. (…)" / "Died ca. 22 juni 1973. (…)" # — drop the leading word and the trailing "(age)" part. parts = em.split(None, 1) rest = parts[1].split("(")[0] if len(parts) > 1 else "" death_text = rest.strip().rstrip(".").strip() or None elif self._is_death: etype = "death" m = re.search(r"(\d+)", em) if m: death_text = str(self.year - int(m.group(1))) else: etype = "birthday" m = re.search(r"(\d+)", em) if m: age = int(m.group(1)) self.events.append({ "month": self.month, "day": self._day, "name": name, "type": etype, "age": age, "death_text": death_text, "anniv_text": anniv_text, }) # ═══════════════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════════════ def detect_language(sample_file: Path) -> str: """Return the code of a source page (e.g. 'da', 'en').""" try: head = sample_file.read_text(encoding="utf-8", errors="ignore")[:2000] except OSError: return "" m = re.search(r']*\blang="([a-z]{2})', head) return m.group(1) if m else "" def console_lang(forced: str, cal_dir: Path, target_years: list) -> str: """Which language the terminal messages use: 'da' or 'en'. Follows --sprog when it is da/en, else the detected source language, else English.""" if forced in ("da", "en"): return forced if not forced: for y in target_years: f = cal_dir / str(y) / "1.html" if f.exists(): return "da" if detect_language(f) == "da" else "en" return "en" def wedding_line(em: str, L: dict) -> str: """Rebuild the anniversary text in the calendar's own language from the numbers in the source, so it never leaks the source language. Classification is structural (language-independent): starts with a number -> anniversary ("105 år ..." / "105 year ...") starts with a word -> married/ended ("Gift 62 år ..." / "Married 62 ...") no number -> plain wedding """ s = (em or "").strip() num = re.search(r"\d+", s) if not num: return L["wed_word"] n = int(num.group()) if s[0].isdigit(): return L["wed_anniv"].format(n=n) line = L["wed_married"].format(n=n) # The "until" date sits in parentheses; the year is its 4-digit part. # Works for "(Indtil 6 marts 2007)" and ISO "(Until 2007-03-06)". paren = re.search(r"\(([^)]*)\)", s) if paren: y = re.search(r"\d{4}", paren.group(1)) if y: line += " (" + L["wed_until"].format(year=y.group()) + ")" return line def extract_year(year_dir: Path, year: int, C: dict) -> list: """Read all 12 Web Calendar files for one year and return the events.""" events = [] for month in range(1, 13): fil = year_dir / f"{month}.html" if not fil.exists(): print(C["skip_missing"].format(f=fil.name)) continue parser = WebCalParser(month, year) parser.feed(fil.read_text(encoding="utf-8")) events.extend(parser.events) b = sum(1 for e in parser.events if e["type"].startswith("birthday")) d = sum(1 for e in parser.events if e["type"] == "death") a = sum(1 for e in parser.events if e["type"] == "anniversary") print(f" ✅ {fil.name:8s}: {len(parser.events):3d} (🎂 {b} ✝ {d} 💍 {a})") return events def year_switcher(year: int, month: int, nav_years: list) -> str: """‹ 2026 › — arrows link to the same month in the adjacent existing year. An arrow is greyed out (no link) when there is no year that direction, so it can never point at a folder that does not exist.""" ys = sorted(set(nav_years)) prev_y = next_y = None if year in ys: i = ys.index(year) if i > 0: prev_y = ys[i - 1] if i < len(ys) - 1: next_y = ys[i + 1] left = (f'' if prev_y else '') right = (f'' if next_y else '') return (f'{left}' f'{year}{right}') def write_index(sti: Path, base_year: int): """Write index_kalender.html at the site root. It only ever points at the current year or next year: the current year if it is one of the two, otherwise the base year. Self-updating via getFullYear().""" years_js = f"[{base_year},{base_year + 1}]" html_doc = f""" Kalender

Går til kalender... Klik her

""" (sti / "index_kalender.html").write_text(html_doc, encoding="utf-8") # ═══════════════════════════════════════════════════════ # CSS # ═══════════════════════════════════════════════════════ def write_css(out_dir: Path): css = """\ /* ================================================ kalender.css - family calendar ================================================ */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { --bg: #f5f7fb; --ink: #0f172a; } html, body { margin: 0; height: 100%; } body { font: 16px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Arial; color: var(--ink); background: radial-gradient(80% 40% at 10% -10%, rgba(37,99,235,.10), transparent 60%), radial-gradient(80% 40% at 110% 10%, rgba(14,165,233,.08), transparent 55%), var(--bg); background-attachment: fixed; min-height: 100vh; } /* ── Top bar ── */ .kalender-header { background: #4d4c57; color: #fffdf7; padding: 14px 24px; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 6px rgba(0,0,0,.4); } .header-inner { text-align: center; } .kalender-header h1 { font-size: 1.9rem; letter-spacing: .04em; font-weight: normal; } /* Year switcher: ‹ 2026 › */ .aar-skifter { display: inline-flex; align-items: center; gap: 8px; vertical-align: middle; margin-left: 8px; } .aar-nu { font-weight: normal; } .aar-pil { color: #fffdf7; text-decoration: none; font-size: 1.15rem; line-height: 1; border: 1px solid rgba(255,255,255,.4); border-radius: 4px; padding: 0 9px; transition: background .2s; } .aar-pil:hover { background: rgba(255,255,255,.18); } .aar-pil.off { opacity: .3; pointer-events: none; } /* Month navigation under the title — never wraps */ .header-nav { display: flex; justify-content: center; gap: 4px; flex-wrap: nowrap; margin-top: 8px; overflow: hidden; } .header-nav a { color: #fffdf7; text-decoration: none; padding: 3px 7px; border: 1px solid rgba(255,255,255,.35); border-radius: 4px; font-size: .98rem; transition: background .2s; white-space: nowrap; flex-shrink: 0; } .header-nav a:hover { background: rgba(255,255,255,.15); } .header-nav a.aktiv { background: rgba(255,255,255,.25); border-color: rgba(255,255,255,.7); font-weight: bold; } /* ── Calendar grid ── */ .kalender-wrapper { padding: 20px 16px 32px; max-width: 1200px; margin: 0 auto; box-sizing: border-box; width: 100%; } .kalender-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; min-width: 0; width: 100%; } /* Weekday headers */ .ugedag-header { background: #327986; color: #f5e6c8; text-align: center; padding: 8px 4px; font-size: .85rem; font-weight: bold; letter-spacing: .05em; border-radius: 4px 4px 0 0; min-width: 0; } .ugedag-header.weekend { background: #8d432f; } /* Day cells */ .dag-celle { background: #FFFEF9; border: 1px solid #d4c4a0; border-radius: 4px; min-height: 100px; padding: 4px 6px 6px; vertical-align: top; position: relative; min-width: 0; overflow: hidden; } .dag-celle.tom { background: #d6d6d6; border-color: #e0d8c4; min-height: 100px; } .dag-celle.i-dag { border: 2px solid #8b4513; background: #fff8ee; } .dag-celle.weekend { background: #FFFFF5; } /* Date line at the top */ .dag-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; min-height: 24px; } .dag-nummer { font-size: .95rem; font-weight: bold; color: #4d4c57; line-height: 1; flex-shrink: 0; } .dag-celle.i-dag .dag-nummer { background: #916a84; color: #fff; width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .8rem; } /* Toggle button — the whole line is clickable, discreet */ .toggle-knap { background: none; border: none; cursor: pointer; font-size: .75rem; color: #4d4c57; padding: 1px 0; line-height: 1; font-family: inherit; display: flex; align-items: center; gap: 4px; transition: opacity .15s; white-space: nowrap; flex: 1; justify-content: flex-end; } .toggle-knap:hover { opacity: .7; } .toggle-knap .pil { font-size: 1.15rem; line-height: 1; transition: transform .25s; display: inline-block; color: #4d4c57; } .toggle-knap.aktiv .pil { transform: rotate(180deg); } .toggle-knap .antal { font-weight: bold; } /* Events */ .begivenhed { font-size: .72rem; line-height: 1.3; margin-bottom: 3px; padding: 2px 4px; border-radius: 3px; word-break: break-word; } .begivenhed.foedselsdag { background: #d4edda; color: #155724; border-left: 3px solid #28a745; } .begivenhed.foedselsdag_afdoed { background: #e2d9f3; color: #4a2c7a; border-left: 3px solid #6f42c1; } .begivenhed.doed { background: #e8e0d0; color: #4a3828; border-left: 3px solid #8b7355; } .begivenhed.bryllupsdag { background: #fde8f0; color: #7a1040; border-left: 3px solid #e83e8c; } .begivenhed .ikon { margin-right: 2px; } .begivenhed .info { font-size: .65rem; color: rgba(0,0,0,.55); display: block; } /* Extra events — hidden until toggled */ .ekstra-beg { overflow: hidden; max-height: 0; margin-top: 0; border-top: 0px solid transparent; transition: max-height .35s ease, margin-top .25s ease, border-top .25s ease; } .ekstra-beg.aaben { max-height: 2000px; border-top: 1px dashed #d4c4a0; margin-top: 4px; } /* ── Legend ── */ .forklaring { display: flex; gap: 16px; flex-wrap: wrap; margin: 16px 0 8px; font-size: .8rem; align-items: center; } .forklaring-punkt { display: flex; align-items: center; gap: 6px; } .forklaring-farve { width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; } /* iPad portrait and narrower */ @media (max-width: 820px) { .kalender-header { padding: 10px 10px; } .kalender-header h1 { font-size: 1.9rem; } .header-nav { gap: 2px; margin-top: 6px; } .header-nav a { font-size: .88rem; padding: 2px 6px; } } @media (max-width: 700px) { .kalender-wrapper { padding: 12px 8px 24px; } .kalender-grid { gap: 2px; } .dag-celle { min-height: 70px; padding: 2px 3px 4px; } .begivenhed { font-size: .62rem; } .dag-nummer { font-size: .8rem; } .toggle-knap { font-size: .65rem; } .toggle-knap .pil { font-size: .95rem; } } """ (out_dir / "kalender.css").write_text(css, encoding="utf-8") # ═══════════════════════════════════════════════════════ # HTML generator # ═══════════════════════════════════════════════════════ def event_html(e: dict, L: dict) -> str: icon = ICON[e["type"]] cls = CSS_CLASS[e["type"]] name = e["name"] if e["type"] == "anniversary": # The couple connector comes from the source ("and"/"og"); use the # calendar's own word so e.g. a German page reads "und". name = re.sub(r"\s+(?:and|og)\s+", f" {L['couple_join']} ", name, count=1) name = html.escape(name) info = [] if e.get("age") is not None: info.append(f'{e["age"]} {L["years"]}') if e.get("death_text"): info.append("† " + html.escape(e["death_text"])) if e.get("anniv_text"): info.append(html.escape(wedding_line(e["anniv_text"], L))) info_html = f'{" · ".join(info)}' if info else "" return (f'
' f'{icon}{name}{info_html}
') JS = """ """ def generate_year(events: list, out_dir: Path, year: int, L: dict, nav_years: list, C: dict): """Write 12 styled month pages + kalender.css into out_dir.""" out_dir.mkdir(parents=True, exist_ok=True) write_css(out_dir) by_day = {} for e in events: by_day.setdefault((e["month"], e["day"]), []).append(e) for month in range(1, 13): month_name = L["months"][month - 1] header_nav = "" for nr in range(1, 13): active = "aktiv" if nr == month else "" header_nav += f'{L["months"][nr-1][:3]}\n' weekday_headers = "" for i, navn in enumerate(L["weekdays"]): cls = " weekend" if i >= 5 else "" weekday_headers += f'
{navn}
\n' grid = weekday_headers counter = 0 for week in calendar.monthcalendar(year, month): for dow, day in enumerate(week): if day == 0: grid += '
\n' continue extra_cls = " weekend" if dow >= 5 else "" all_ev = by_day.get((month, day), []) primary = [e for e in all_ev if e["type"] == "birthday"] extra = [e for e in all_ev if e["type"] != "birthday"] extra.sort(key=lambda e: EXTRA_ORDER.get(e["type"], 9)) primary_html = "".join(event_html(e, L) for e in primary) if extra: counter += 1 kid = f"c{month}_{day}_{counter}" n = len(extra) extra_html = "".join(event_html(e, L) for e in extra) top = ( f'
' f'
{day}
' f'' f'
' ) extra_block = f'
{extra_html}
' else: top = f'
{day}
' extra_block = "" grid += ( f'
' f'{top}{primary_html}{extra_block}
\n' ) legend = ( f'
' f'
🎂 {L["legend_alive"]}
' f'
🕯️ {L["legend_dead"]}
' f'
✝ {L["legend_death"]}
' f'
💍 {L["legend_wedding"]}
' f'
' f'{L["legend_hint"]}
' f'
' ) page = f""" {L["page_title"].format(month=month_name, year=year)}

{L["title"]} {year_switcher(year, month, nav_years)}

{legend}
{grid}
{JS % year} """ (out_dir / f"{month}.html").write_text(page, encoding="utf-8") print(C["pages"].format(dir=out_dir)) # ═══════════════════════════════════════════════════════ # CLI # ═══════════════════════════════════════════════════════ def main(): ap = argparse.ArgumentParser( description="Byg stylet kalender ud fra Gramps 'Web Calendar'-output.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) ap.add_argument("--sti", default=".", help="Mappe der indeholder cal/ (standard: nuværende mappe)") ap.add_argument("--aar", type=int, default=None, metavar="ÅR", help="Basisår = indeværende år + næste år (standard: indeværende år). " "Sæt det hvis Gramps' startår ikke er indeværende år") ap.add_argument("--sprog", default=None, help="Tving sprogkode, fx da/en (standard: auto fra html lang)") ap.add_argument("--ud", default=None, metavar="MAPPE", help="Skriv de stylede sider hertil/<år> (standard: i cal/<år>)") ap.add_argument("--json", action="store_true", help="Gem også kalender_data.json pr. år (til fejlsøgning)") ap.add_argument("--no-index", action="store_true", help="Skriv IKKE index_kalender.html (standard: den skrives i --sti)") ap.add_argument("--dump-sprog", action="store_true", help="Skriv de indbyggede sprogpakker til sprog/*.json og afslut") args = ap.parse_args() script_dir = Path(__file__).resolve().parent if args.dump_sprog: dump_language_templates(script_dir) return languages = load_languages(script_dir) sti = Path(args.sti).resolve() cal_dir = sti / "cal" # Fixed scope: this year + next year (base year overridable with --aar). base_year = args.aar or datetime.date.today().year target_years = [base_year, base_year + 1] # Terminal messages in Danish or English C = CONSOLE[console_lang(args.sprog, cal_dir, target_years)] if args.sprog and args.sprog not in languages: print(C["bad_forced"].format(code=args.sprog, known=", ".join(sorted(languages)))) return output_base = Path(args.ud).resolve() if args.ud else cal_dir # Report any missing target-year folder, then build the rest. missing = [y for y in target_years if not (cal_dir / str(y)).exists()] for y in missing: print(C["missing_year"].format(y=y, path=cal_dir / str(y))) present_years = [y for y in target_years if y not in missing] # The switcher only links between the years that actually exist. nav_years = present_years built = [] for year in present_years: year_dir = cal_dir / str(year) # Language: forced, or auto-detected from January's page code = args.sprog or detect_language(year_dir / "1.html") if code not in languages: if code: print(C["unknown_lang"].format(code=code)) code = "en" L = languages[code] print(f"\n{'='*55}") print(f" {C['year']} : {year}") print(f" {C['source']} : {year_dir}") print(f" {C['language']} : {code}") print(f"{'='*55}") events = extract_year(year_dir, year, C) # Safety: never overwrite with an empty calendar (e.g. re-run in place # on already-styled files, or wrong folder). if not events: print(C["no_events"]) continue out_dir = output_base / str(year) if args.json: events_sorted = sorted(events, key=lambda e: (e["month"], e["day"], e["name"])) out_dir.mkdir(parents=True, exist_ok=True) (out_dir / "kalender_data.json").write_text( json.dumps(events_sorted, ensure_ascii=False, indent=2), encoding="utf-8") print(C["found"].format(n=len(events))) generate_year(events, out_dir, year, L, nav_years, C) built.append(year) # Self-updating entry page (written by default; only base year + next year) if not args.no_index and built: write_index(sti, base_year) print(C["index"].format(a=base_year, b=base_year + 1)) print(C["done"]) if __name__ == "__main__": main()