Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,868 changes: 493 additions & 1,375 deletions Feiertage/baden-württemberg.ics

Large diffs are not rendered by default.

2,072 changes: 589 additions & 1,483 deletions Feiertage/bayern.ics

Large diffs are not rendered by default.

1,550 changes: 402 additions & 1,148 deletions Feiertage/berlin.ics

Large diffs are not rendered by default.

1,840 changes: 452 additions & 1,388 deletions Feiertage/brandenburg.ics

Large diffs are not rendered by default.

1,516 changes: 380 additions & 1,136 deletions Feiertage/bremen.ics

Large diffs are not rendered by default.

1,516 changes: 380 additions & 1,136 deletions Feiertage/hamburg.ics

Large diffs are not rendered by default.

1,552 changes: 380 additions & 1,172 deletions Feiertage/hessen.ics

Large diffs are not rendered by default.

1,606 changes: 422 additions & 1,184 deletions Feiertage/mecklenburg-vorpommern.ics

Large diffs are not rendered by default.

1,516 changes: 380 additions & 1,136 deletions Feiertage/niedersachsen.ics

Large diffs are not rendered by default.

1,702 changes: 416 additions & 1,286 deletions Feiertage/nordrhein-westfalen.ics

Large diffs are not rendered by default.

1,702 changes: 416 additions & 1,286 deletions Feiertage/rheinland-pfalz.ics

Large diffs are not rendered by default.

1,852 changes: 452 additions & 1,400 deletions Feiertage/saarland.ics

Large diffs are not rendered by default.

1,690 changes: 416 additions & 1,274 deletions Feiertage/sachsen-anhalt.ics

Large diffs are not rendered by default.

1,714 changes: 461 additions & 1,253 deletions Feiertage/sachsen.ics

Large diffs are not rendered by default.

1,516 changes: 380 additions & 1,136 deletions Feiertage/schleswig-holstein.ics

Large diffs are not rendered by default.

1,668 changes: 462 additions & 1,206 deletions Feiertage/thüringen.ics

Large diffs are not rendered by default.

871 changes: 233 additions & 638 deletions Ferien/baden-württemberg.ics

Large diffs are not rendered by default.

1,123 changes: 289 additions & 834 deletions Ferien/bayern.ics

Large diffs are not rendered by default.

1,201 changes: 303 additions & 898 deletions Ferien/berlin.ics

Large diffs are not rendered by default.

943 changes: 289 additions & 654 deletions Ferien/brandenburg.ics

Large diffs are not rendered by default.

1,207 changes: 317 additions & 890 deletions Ferien/bremen.ics

Large diffs are not rendered by default.

1,090 changes: 268 additions & 822 deletions Ferien/hamburg.ics

Large diffs are not rendered by default.

649 changes: 163 additions & 486 deletions Ferien/hessen.ics

Large diffs are not rendered by default.

1,381 changes: 583 additions & 798 deletions Ferien/mecklenburg-vorpommern.ics

Large diffs are not rendered by default.

1,222 changes: 324 additions & 898 deletions Ferien/niedersachsen.ics

Large diffs are not rendered by default.

793 changes: 191 additions & 602 deletions Ferien/nordrhein-westfalen.ics

Large diffs are not rendered by default.

721 changes: 163 additions & 558 deletions Ferien/rheinland-pfalz.ics

Large diffs are not rendered by default.

862 changes: 212 additions & 650 deletions Ferien/saarland.ics

Large diffs are not rendered by default.

1,042 changes: 268 additions & 774 deletions Ferien/sachsen-anhalt.ics

Large diffs are not rendered by default.

1,030 changes: 268 additions & 762 deletions Ferien/sachsen.ics

Large diffs are not rendered by default.

877 changes: 303 additions & 574 deletions Ferien/schleswig-holstein.ics

Large diffs are not rendered by default.

985 changes: 247 additions & 738 deletions Ferien/thüringen.ics

Large diffs are not rendered by default.

249 changes: 249 additions & 0 deletions scripts/generate_feiertage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Generate Feiertage and Ferien ICS files for all 16 German Bundesländer."""

import argparse
import hashlib
import json
import os
import urllib.parse
import urllib.request
from datetime import date, datetime, timedelta, timezone


PRODID = "ics.tools skjerns patch"
URL = "https://ics.tools"
FEIERTAGE_API = "https://feiertage-api.de/api/"
FERIEN_API = "https://openholidaysapi.org/SchoolHolidays"
MAX_BATCH_DAYS = 1000

# feiertage-api.de land codes
FEIERTAGE_CODES = {
"baden-württemberg": "BW",
"bayern": "BY",
"berlin": "BE",
"brandenburg": "BB",
"bremen": "HB",
"hamburg": "HH",
"hessen": "HE",
"mecklenburg-vorpommern": "MV",
"niedersachsen": "NI",
"nordrhein-westfalen": "NW",
"rheinland-pfalz": "RP",
"saarland": "SL",
"sachsen-anhalt": "ST",
"sachsen": "SN",
"schleswig-holstein": "SH",
"thüringen": "TH",
}

# openholidaysapi.org subdivision codes
SUBDIVISION_CODES = {
"baden-württemberg": "DE-BW",
"bayern": "DE-BY",
"berlin": "DE-BE",
"brandenburg": "DE-BB",
"bremen": "DE-HB",
"hamburg": "DE-HH",
"hessen": "DE-HE",
"mecklenburg-vorpommern": "DE-MV",
"niedersachsen": "DE-NI",
"nordrhein-westfalen": "DE-NW",
"rheinland-pfalz": "DE-RP",
"saarland": "DE-SL",
"sachsen-anhalt": "DE-ST",
"sachsen": "DE-SN",
"schleswig-holstein": "DE-SH",
"thüringen": "DE-TH",
}

STATES = list(FEIERTAGE_CODES.keys())


def fetch_feiertage_api(land_code: str, year: int) -> list[tuple[date, str]]:
"""Fetch public holidays from feiertage-api.de for a given state and year."""
params = urllib.parse.urlencode({"jahr": year, "nur_land": land_code})
req = urllib.request.Request(
f"{FEIERTAGE_API}?{params}",
headers={"Accept": "application/json"},
)
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
return sorted(
(date.fromisoformat(v["datum"]), name)
for name, v in data.items()
)


def make_uid(summary: str, dtstart: date) -> str:
raw = f"{summary}{dtstart.strftime('%Y%m%d')}"
return hashlib.sha256(raw.encode()).hexdigest()[:8]


def ics_fold(line: str) -> str:
"""RFC 5545 line folding: max 75 octets per line, continuation with CRLF + space."""
encoded = line.encode("utf-8")
if len(encoded) <= 75:
return line
chunks = []
pos = 0
limit = 75
while pos < len(encoded):
chunks.append(encoded[pos:pos + limit].decode("utf-8", errors="ignore"))
pos += limit
limit = 74 # continuation lines have 1 char less (the leading space)
return "\r\n ".join(chunks)


def vevent(summary: str, dtstart: date, dtend: date, timestamp: str) -> str:
uid = make_uid(summary, dtstart)
lines = [
"BEGIN:VEVENT",
f"DTSTART;VALUE=DATE:{dtstart.strftime('%Y%m%d')}",
f"DTEND;VALUE=DATE:{dtend.strftime('%Y%m%d')}",
f"SUMMARY:{summary}",
ics_fold(f"UID:{uid}"),
f"URL:{URL}",
f"CREATED:{timestamp}",
f"LAST-MODIFIED:{timestamp}",
f"DTSTAMP:{timestamp}",
"TRANSP:TRANSPARENT",
"END:VEVENT",
]
return "\r\n".join(lines)


def write_feiertage(state: str, year_start: int, year_end: int,
output_dir: str, timestamp: str) -> None:
cal_name = f"{state.title()} Feiertage"
code = FEIERTAGE_CODES[state]
events = []
for year in range(year_start, year_end + 1):
for day, name in fetch_feiertage_api(code, year):
dtend = day + timedelta(days=1)
events.append(vevent(name, day, dtend, timestamp))

parts = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:{PRODID}",
*events,
f"NAME:{cal_name}",
f"X-WR-CALNAME:{cal_name}",
"METHOD:PUBLISH",
"END:VCALENDAR",
]
content = "\r\n".join(parts) + "\r\n"

os.makedirs(output_dir, exist_ok=True)
out_path = os.path.join(output_dir, f"{state}.ics")
with open(out_path, "w", encoding="utf-8") as f:
f.write(content)
print(f" → {out_path}")


def fetch_ferien_api(subdivision_code: str, from_date: date, to_date: date) -> list[dict]:
"""Fetch school holidays from OpenHolidays API in batches of at most MAX_BATCH_DAYS days."""
results = []
current = from_date
while current <= to_date:
batch_end = min(current + timedelta(days=MAX_BATCH_DAYS - 1), to_date)
params = urllib.parse.urlencode({
"countryIsoCode": "DE",
"subdivisionCode": subdivision_code,
"validFrom": current.strftime("%Y-%m-%d"),
"validTo": batch_end.strftime("%Y-%m-%d"),
})
req = urllib.request.Request(
f"{FERIEN_API}?{params}",
headers={"Accept": "application/json"},
)
with urllib.request.urlopen(req) as resp:
results.extend(json.loads(resp.read()))
current = batch_end + timedelta(days=1)

# Deduplicate entries that appear in overlapping batch windows
seen: set[tuple] = set()
unique = []
for item in results:
key = (item["startDate"], item["endDate"])
if key not in seen:
seen.add(key)
unique.append(item)
return unique


def german_name(name_list: list[dict]) -> str:
for entry in name_list:
if entry.get("language") == "DE":
return entry["text"]
return name_list[0]["text"] if name_list else "Ferien"


def write_ferien(state: str, year_start: int, year_end: int,
output_dir: str, timestamp: str) -> None:
code = SUBDIVISION_CODES[state]
state_title = state.title()
cal_name = f"{state_title} Ferien"

print(f" {state} ({code}) ...", end="", flush=True)
holidays = fetch_ferien_api(code, date(year_start, 1, 1), date(year_end, 12, 31))
print(f" {len(holidays)} entries")

events = []
for h in sorted(holidays, key=lambda x: x["startDate"]):
name = german_name(h["name"])
start = date.fromisoformat(h["startDate"])
# API endDate is the last inclusive day; ICS DTEND is exclusive
end = date.fromisoformat(h["endDate"]) + timedelta(days=1)
summary = f"{name} {start.year} {state_title}"
events.append(vevent(summary, start, end, timestamp))

parts = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:{PRODID}",
*events,
f"NAME:{cal_name}",
f"X-WR-CALNAME:{cal_name}",
"METHOD:PUBLISH",
"END:VCALENDAR",
]
content = "\r\n".join(parts) + "\r\n"

os.makedirs(output_dir, exist_ok=True)
out_path = os.path.join(output_dir, f"{state}.ics")
with open(out_path, "w", encoding="utf-8") as f:
f.write(content)
print(f" → {out_path}")


def main() -> None:
parser = argparse.ArgumentParser(
description="Generate Feiertage and Ferien ICS files for all German Bundesländer."
)
parser.add_argument("--year_start", type=int, required=True, help="First year (inclusive)")
parser.add_argument("--year_end", type=int, required=True, help="Last year (inclusive)")
parser.add_argument("--feiertage_dir", default="Feiertage",
help="Output directory for Feiertage (default: Feiertage/)")
parser.add_argument("--ferien_dir", default="Ferien",
help="Output directory for Ferien (default: Ferien/)")
args = parser.parse_args()

if args.year_start > args.year_end:
parser.error("year_start must be <= year_end")

timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")

print(f"Generating Feiertage {args.year_start}–{args.year_end} → {args.feiertage_dir}/")
for state in STATES:
write_feiertage(state, args.year_start, args.year_end, args.feiertage_dir, timestamp)

print(f"\nFetching Ferien {args.year_start}–{args.year_end} → {args.ferien_dir}/")
for state in STATES:
write_ferien(state, args.year_start, args.year_end, args.ferien_dir, timestamp)

print("Done.")


if __name__ == "__main__":
main()