Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/jekyll-gh-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ page/
Feiertage/
Ferien/
website_result/
extra/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
83 changes: 83 additions & 0 deletions scripts/03_generate_calendar_weeks_ics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: 2026 Sebastian Espei <seblsebastian@aol.de>
#
# 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()
4 changes: 4 additions & 0 deletions scripts/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand All @@ -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]]",
}


Expand Down
47 changes: 44 additions & 3 deletions tests/test_calendar_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}'
10 changes: 10 additions & 0 deletions website_template/index_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down