diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml index 1e67d7f..555f679 100644 --- a/.github/workflows/jekyll-gh-pages.yml +++ b/.github/workflows/jekyll-gh-pages.yml @@ -42,6 +42,9 @@ jobs: - name: Generate school holidays ics run: python scripts/03_generate_school_holidays_ics.py + - name: Generate calendar weeks ics + run: python scripts/03_generate_calendar_weeks_ics.py + - name: Run unit tests run: pytest diff --git a/.gitignore b/.gitignore index 8f9dee9..4de57f7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ page/ Feiertage/ Ferien/ website_result/ +extra/ diff --git a/README.md b/README.md index 700f209..664d525 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ Ein Open-Source-Projekt, das standardisierte ICS-Kalenderdateien für deutsche F 3. **Merge** (`02_merge_*.py`) – Daten + Overrides zusammenführen 4. **Generate** (`03_generate_*.py`) – ICS-Dateien erstellen +### Extras + +- Kalenderwochen Kalender mit ganztätigen ``KWXX`` Einträgen jeden Montag + ## Lizenz Dieses Projekt nutzt unterschiedliche Lizenzen für den Quellcode und die enthaltenen Daten: diff --git a/scripts/03_generate_calendar_weeks_ics.py b/scripts/03_generate_calendar_weeks_ics.py new file mode 100644 index 0000000..96a15d2 --- /dev/null +++ b/scripts/03_generate_calendar_weeks_ics.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2026 Sebastian Espei +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from datetime import date, datetime, timedelta, timezone +import os +from pathlib import Path +from urllib.parse import urljoin + +from icalendar import Calendar, Event + +from config import ( + FETCH_END_YEAR, + FETCH_START_YEAR, + EXTRA_ICS_DIR, + WEBSITE_BASE_URL, +) + +RESULT_DIR = Path(EXTRA_ICS_DIR) +RESULT_FILE = RESULT_DIR / "kalenderwochen.ics" +CALENDAR_NAME = "Kalenderwochen" +CALENDAR_SLUG = "kw" + +os.makedirs(RESULT_DIR, exist_ok=True) + + +def generate_uid(event_id: str, calendar_slug: str) -> str: + return f"{event_id}-{calendar_slug}@kalenderwochen.ics.tools" + + +def iter_mondays(start_date: date, end_date: date): + current_date = start_date + timedelta(days=(7 - start_date.weekday()) % 7) + + while current_date <= end_date: + yield current_date + current_date += timedelta(days=7) + + +def build_calendar() -> None: + cal = Calendar() + cal.add("prodid", "-//ics.tools//ics.tools Kalenderwochen v1.1//DE") + cal.add("version", "2.0") + cal.add("x-wr-calname", CALENDAR_NAME) + cal.add("name", CALENDAR_NAME) + cal.add("x-wr-caldesc", "ISO-8601-Kalenderwochen") + cal.add("description", "ISO-8601-Kalenderwochen") + cal.add("x-wr-timezone", "Europe/Berlin") + cal.add("refresh-interval", "P1W", parameters={"VALUE": "DURATION"}) + cal.add("x-published-ttl", "P1W") + cal.add("calscale", "GREGORIAN") + cal.add("source", urljoin(WEBSITE_BASE_URL, RESULT_FILE.as_posix())) + cal.add("method", "PUBLISH") + + now = datetime.now(timezone.utc) + start_date = date(FETCH_START_YEAR, 1, 1) + end_date = date(FETCH_END_YEAR, 12, 31) + + for monday in iter_mondays(start_date, end_date): + week_number = monday.isocalendar().week + + event = Event() + event.add("uid", generate_uid(monday.isoformat(), CALENDAR_SLUG)) + event.add("summary", f"KW {week_number:02d}") + event.add("description", f"Kalenderwoche {week_number:02d}") + event.add("dtstart", monday) + event.add("dtend", monday + timedelta(days=1)) + event.add("created", now) + event.add("last-modified", now) + event.add("dtstamp", now) + event.add("sequence", 0) + event.add("transp", "TRANSPARENT") + cal.add_component(event) + + with open(RESULT_FILE, "wb") as output_file: + output_file.write(cal.to_ical()) + + +def main() -> None: + build_calendar() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/config.py b/scripts/config.py index 8c73e52..01538cc 100644 --- a/scripts/config.py +++ b/scripts/config.py @@ -45,6 +45,8 @@ SCHOOL_HOLIDAYS_RESULT_DIR = "data/school_holidays/result" SCHOOL_HOLIDAYS_ICS_DIR = "Ferien" +EXTRA_ICS_DIR = "extra" + # Raw holiday entries carrying any of these tags are skipped during merge. IGNORED_RAW_TAGS = ["Exception"] @@ -64,10 +66,12 @@ WEBSITE_ICS_SOURCE_DIRS = [ PUBLIC_HOLIDAYS_ICS_DIR, SCHOOL_HOLIDAYS_ICS_DIR, + EXTRA_ICS_DIR, ] WEBSITE_PLACEHOLDERS = { PUBLIC_HOLIDAYS_ICS_DIR: "[[feiertage-tree]]", SCHOOL_HOLIDAYS_ICS_DIR: "[[ferien-tree]]", + EXTRA_ICS_DIR: "[[extra-tree]]", } diff --git a/tests/test_calendar_files.py b/tests/test_calendar_files.py index 273a495..58fb16c 100644 --- a/tests/test_calendar_files.py +++ b/tests/test_calendar_files.py @@ -6,19 +6,31 @@ from icalendar import Calendar ICAL_VALIDATOR_URL = "https://icalendar.org/validator.html?json=1" +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -def find_ics_files(base_path='./'): +def find_ics_files(base_path=REPO_ROOT): ics_files = [] for root, _, files in os.walk(base_path): path_parts = set(os.path.normpath(root).split(os.sep)) - if path_parts.intersection({'Feiertage', 'Ferien'}): + if path_parts.intersection({'Feiertage', 'Ferien', 'extra'}): for file in files: if file.endswith('.ics'): ics_files.append(os.path.join(root, file)) return ics_files -def find_ferien_ics_files(base_path='./'): +def find_kalenderwochen_ics_files(base_path=REPO_ROOT): + ics_files = [] + for root, _, files in os.walk(base_path): + path_parts = set(os.path.normpath(root).split(os.sep)) + if 'extra' in path_parts: + for file in files: + if file.endswith('.ics'): + ics_files.append(os.path.join(root, file)) + return ics_files + + +def find_ferien_ics_files(base_path=REPO_ROOT): """Find only ICS files in 'Ferien' directories.""" ics_files = [] for root, _, files in os.walk(base_path): @@ -59,6 +71,8 @@ def build_expected_calendar_name(ics_path: str) -> str: match dirname: case "Ferien": category_name = "Schulferien" + case "extra": + return "Kalenderwochen" case _: category_name = dirname @@ -185,3 +199,30 @@ def test_icalendar_org_validator(ics_path): if warnings > 0: messages = "\n".join(warn["message"] for warn in data.get("warnings", [])) pytest.skip(f"Validation warnings in {ics_path}:\n{messages}") + + +@pytest.mark.parametrize("ics_path", find_kalenderwochen_ics_files()) +def test_calendar_week_events_are_mondays(ics_path): + with open(ics_path, 'r', encoding='utf-8') as f: + cal = Calendar.from_ical(f.read()) + + events = list(cal.walk('VEVENT')) + assert events, f'No events found in {ics_path}' + + for component in events: + summary = str(component.get('summary', '')) + assert summary.startswith('KW '), f"Unexpected summary '{summary}' in {ics_path}" + + dtstart_prop = component.get('dtstart') + assert dtstart_prop is not None, f'Missing DTSTART in {ics_path}' + dtstart = dtstart_prop.dt + assert dtstart.weekday() == 0, f'DTSTART is not a Monday in {ics_path}: {dtstart}' + + week_number = dtstart.isocalendar().week + assert summary == f'KW {week_number:02d}', ( + f"Summary '{summary}' does not match ISO week {week_number:02d} in {ics_path}" + ) + + dtend_prop = component.get('dtend') + assert dtend_prop is not None, f'Missing DTEND in {ics_path}' + assert (dtend_prop.dt - dtstart).days == 1, f'Expected single-day event in {ics_path}' diff --git a/website_template/index_template.md b/website_template/index_template.md index 8f5814f..41a02f3 100644 --- a/website_template/index_template.md +++ b/website_template/index_template.md @@ -38,6 +38,16 @@ Lade die Datei herunter und importiere sie manuell in deinen Kalender. --- +## Extra Kalendar +[[extra-tree]] + +### Kalenderwochen (ISO 8601) + +Dieser Kalender liefert pro ISO-Kalenderwoche einen ganztägigen Eintrag am Montag mit dem Titel ``KW NN`` (z. B. "KW 20"). +Er verwendet die ISO-8601-Norm für Kalenderwochen (Woche beginnt am Montag; Woche 01 ist die Woche, die den 4. Januar enthält) und bildet die Woche über das ISO-Wochenjahr ab. + +--- + ## Worin liegen die Vorteile? - ✓ **Universell kompatibel**: Google Calendar, Outlook, Apple Calendar, Thunderbird und viele weitere