Skip to content

Commit ba83c5d

Browse files
author
UntisSync
committed
feat: intelligent holiday detection system
- Add automatic holiday detection via holidays library - Support for German state-specific public holidays (configurable) - Manual school holiday configuration via school_holidays.json - Fix: Correctly handles missing weekdays (no more day-shift bug) - Works for any weekday, not just hardcoded Wednesday - Tested with Buß- und Bettag (Nov 19, 2025) Closes: Day-shift bug when holidays exist
1 parent 73ca79c commit ba83c5d

File tree

3 files changed

+176
-38
lines changed

3 files changed

+176
-38
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,35 @@ Features:
144144

145145
## Configuration
146146

147+
### 🎊 Holiday Management
148+
149+
**Automatic Holiday Detection:**
150+
- Gesetzliche Feiertage werden automatisch für dein Bundesland erkannt
151+
- Standard: Bayern (`BY`). Ändere in `untis_sync_improved.py`:
152+
153+
```python
154+
parser = ImprovedUntisParser('weekly_data/week_1.json', bundesland='BY')
155+
# BY=Bayern, NW=NRW, BW=Baden-Württemberg, HE=Hessen, SN=Sachsen, etc.
156+
```
157+
158+
**Manual School Holidays:**
159+
Schulfreie Tage die keine gesetzlichen Feiertage sind (z.B. Buß- und Bettag außerhalb Sachsen, Brückentage, pädagogische Tage) in `school_holidays.json` eintragen:
160+
161+
```json
162+
{
163+
"custom_holidays": [
164+
"2025-11-19",
165+
"2025-05-30",
166+
"2025-03-15"
167+
]
168+
}
169+
```
170+
171+
Das System erkennt automatisch:
172+
- ✅ Fehlende Wochentage in WebUntis-Daten
173+
- ✅ Ordnet Lessons korrekt zu (ohne Day-Shift)
174+
- ✅ Funktioniert für **jeden Wochentag** (nicht nur Mittwoch)
175+
147176
### Color Coding
148177

149178
Events are color-coded in Google Calendar (default: Orange).

school_holidays.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"_comment": "Konfiguration für schulfreie Tage (zusätzlich zu gesetzlichen Feiertagen)",
3+
"_info": "Gesetzliche Feiertage werden automatisch erkannt. Hier nur schulspezifische Tage eintragen.",
4+
"_bundesland": "Bundesland wird in untis_sync_improved.py konfiguriert (Standard: BY = Bayern)",
5+
"_format": "YYYY-MM-DD",
6+
7+
"custom_holidays": [
8+
"2025-11-19"
9+
],
10+
11+
"_examples": {
12+
"_comment": "Beispiele für typische schulfreie Tage:",
13+
"brueckentage": ["2025-05-30", "2025-06-20"],
14+
"paedagogische_tage": ["2025-03-15"],
15+
"projektwoche": ["2025-07-14", "2025-07-15", "2025-07-16", "2025-07-17", "2025-07-18"]
16+
}
17+
}

untis_sync_improved.py

Lines changed: 130 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from typing import List, Dict, Optional
1313
import os
1414
import pickle
15+
import holidays
16+
from pathlib import Path
1517
from google.auth.transport.requests import Request
1618
from google.oauth2.credentials import Credentials
1719
from google_auth_oauthlib.flow import InstalledAppFlow
@@ -79,12 +81,28 @@ def to_dict(self):
7981
class ImprovedUntisParser:
8082
"""Verbesserter Parser der mehrere Tage/Wochen unterstützt"""
8183

82-
def __init__(self, json_file: str):
84+
def __init__(self, json_file: str, bundesland: str = 'BY'):
8385
with open(json_file, 'r', encoding='utf-8') as f:
8486
self.data = json.load(f)
8587

8688
self.base_date = self._extract_date_from_url()
8789

90+
# Initialisiere deutsche Feiertage für das entsprechende Bundesland
91+
year = int(self.base_date[:4])
92+
self.holidays = holidays.Germany(years=year, prov=bundesland)
93+
94+
# Lade manuelle schulfreie Tage
95+
custom_holidays_file = Path(__file__).parent / 'school_holidays.json'
96+
if custom_holidays_file.exists():
97+
with open(custom_holidays_file, 'r', encoding='utf-8') as f:
98+
custom_data = json.load(f)
99+
for date_str in custom_data.get('custom_holidays', []):
100+
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
101+
self.holidays[date_obj] = 'Schulfrei (manuell konfiguriert)'
102+
print(f" → {len(custom_data.get('custom_holidays', []))} manuelle schulfreie Tage geladen")
103+
104+
print(f" → Feiertage geladen für {bundesland} {year} + manuelle Einträge")
105+
88106
def _extract_date_from_url(self) -> str:
89107
"""Extrahiert das Datum aus der URL"""
90108
url = self.data['timetable']['url']
@@ -102,61 +120,135 @@ def parse_lessons(self) -> List[UntisLesson]:
102120
print(f"Basis-Datum aus URL: {self.base_date}")
103121

104122
# Versuche die Wochenstruktur zu erkennen
105-
current_day_offset = 0 # Montag = 0, Dienstag = 1, etc.
106-
last_start_time = None
107-
last_lesson_card_index = -1
123+
# Montag des base_date ermitteln (Wochenanfang)
124+
base = datetime.strptime(self.base_date, '%Y-%m-%d')
125+
# Finde den Montag dieser Woche
126+
days_since_monday = base.weekday() # 0=Monday, 6=Sunday
127+
week_start = base - timedelta(days=days_since_monday)
108128

109-
i = 0
110-
while i < len(raw_lessons):
111-
lesson = raw_lessons[i]
129+
# NEUE STRATEGIE: Gruppiere Lessons nach Tagen
130+
# Sammle erst alle Lessons mit ihrer Zeit
131+
lesson_groups = [] # [(index, start_time), ...]
132+
133+
for i, lesson in enumerate(raw_lessons):
112134
class_name = lesson.get('className', '')
113135
text = lesson.get('text', '').strip()
114136

115-
# Prüfe ob das ein Lesson-Card Container ist
116-
# Nur die Haupt-lesson-card zählt (nicht lesson-card-container, etc.)
117137
if 'lesson-card ' in class_name or class_name.startswith('lesson-card '):
118-
# Extrahiere Zeit aus diesem Lesson
119138
time_match = re.search(r'(\d{2}):(\d{2})', text)
120139
if time_match:
121-
current_time = f"{time_match.group(1)}:{time_match.group(2)}"
122-
123-
# Wenn die Zeit KLEINER ist als die letzte Zeit UND
124-
# wir haben schon mindestens eine Lesson gesehen,
125-
# dann ist das ein neuer Tag
126-
if last_start_time and current_time < last_start_time:
127-
current_day_offset += 1
128-
print(f" → Tag wechsel erkannt bei Index {i}: {last_start_time}{current_time}, jetzt Tag {current_day_offset}")
129-
130-
last_start_time = current_time
131-
132-
# Parse diese Lesson
133-
parsed = self._parse_lesson_card(raw_lessons, i, current_day_offset)
134-
if parsed:
135-
lessons.append(parsed)
136-
print(f" ✓ Tag {current_day_offset}{parsed.date} ({datetime.strptime(parsed.date, '%Y-%m-%d').strftime('%A')}): {parsed.start_time}-{parsed.end_time} {parsed.subject} @ {parsed.room}")
137-
138-
last_lesson_card_index = i
140+
start_time = f"{time_match.group(1)}:{time_match.group(2)}"
141+
lesson_groups.append((i, start_time))
142+
143+
# Erkenne Tagwechsel durch Zeit-Rücksprünge
144+
day_boundaries = [0] # Erste Lesson ist immer Start von Tag 0
145+
for idx, (i, time) in enumerate(lesson_groups[1:], start=1):
146+
prev_time = lesson_groups[idx-1][1]
147+
if time < prev_time: # Zeit springt zurück = neuer Tag
148+
day_boundaries.append(idx)
149+
150+
print(f" → Erkannte Tagesgrenzen bei Lesson-Indices: {day_boundaries}")
151+
print(f" → Das entspricht {len(day_boundaries)} Tagen in den Daten")
152+
153+
# Zähle Lessons pro Tag-Block um fehlende Tage zu erkennen
154+
day_lesson_counts = []
155+
for day_idx, boundary_start in enumerate(day_boundaries):
156+
if day_idx < len(day_boundaries) - 1:
157+
boundary_end = day_boundaries[day_idx + 1]
158+
else:
159+
boundary_end = len(lesson_groups)
160+
lesson_count = boundary_end - boundary_start
161+
day_lesson_counts.append(lesson_count)
162+
163+
print(f" → Lessons pro Tag: {day_lesson_counts}")
164+
165+
# INTELLIGENTE FEIERTAGS-ERKENNUNG
166+
# Prüfe welche Wochentage in dieser Woche Feiertage sind
167+
num_days_detected = len(day_boundaries)
168+
169+
# Erstelle Liste aller Wochentage (Mo-Fr = 0-4)
170+
all_weekdays = list(range(5)) # [0, 1, 2, 3, 4]
171+
172+
# Prüfe welche Tage Feiertage sind
173+
holiday_weekdays = []
174+
for weekday in range(5): # Mo-Fr
175+
date = week_start + timedelta(days=weekday)
176+
if date in self.holidays:
177+
holiday_name = self.holidays.get(date)
178+
print(f" 🎊 Feiertag erkannt: {date.strftime('%Y-%m-%d')} ({['Mo','Di','Mi','Do','Fr'][weekday]}) = {holiday_name}")
179+
holiday_weekdays.append(weekday)
180+
181+
# Berechne welche Tage Schultage sind (ohne Feiertage)
182+
school_days = [d for d in all_weekdays if d not in holiday_weekdays]
183+
expected_days = len(school_days)
184+
185+
print(f" → Erwartete Schultage: {expected_days} (ohne {len(holiday_weekdays)} Feiertage)")
186+
187+
# Mapping: Welcher Tag-Index gehört zu welchem Wochentag?
188+
if num_days_detected == expected_days:
189+
# Perfekt: Anzahl passt
190+
weekday_mapping = school_days
191+
print(f" ✓ Mapping: {num_days_detected} Tage → {[['Mo','Di','Mi','Do','Fr'][w] for w in weekday_mapping]}")
192+
elif num_days_detected < expected_days:
193+
# Weniger Tage als erwartet - nimm die ersten N Schultage
194+
weekday_mapping = school_days[:num_days_detected]
195+
print(f" ⚠ Nur {num_days_detected} Tage erkannt, erwartet {expected_days}")
196+
print(f" → Mapping: {[['Mo','Di','Mi','Do','Fr'][w] for w in weekday_mapping]}")
197+
else:
198+
# Mehr Tage als erwartet - sollte nicht passieren, aber fallback
199+
print(f" ⚠ Mehr Tage ({num_days_detected}) als erwartet ({expected_days})!")
200+
weekday_mapping = list(range(num_days_detected))
201+
202+
# Weise jedem Lesson-Index den richtigen Wochentag zu
203+
lesson_to_weekday = {}
204+
for day_idx, boundary_start in enumerate(day_boundaries):
205+
if day_idx < len(day_boundaries) - 1:
206+
boundary_end = day_boundaries[day_idx + 1]
207+
else:
208+
boundary_end = len(lesson_groups)
209+
210+
lesson_count = boundary_end - boundary_start
211+
actual_weekday = weekday_mapping[day_idx]
212+
weekday_name = ['Mo','Di','Mi','Do','Fr','Sa','So'][actual_weekday]
213+
214+
# Weise alle Lessons in diesem Block zum richtigen Wochentag zu
215+
for lesson_idx in range(boundary_start, boundary_end):
216+
actual_index = lesson_groups[lesson_idx][0]
217+
lesson_to_weekday[actual_index] = actual_weekday
218+
219+
print(f" → Tag-Block {day_idx} ({lesson_count} Lessons) → Wochentag {actual_weekday} ({weekday_name})")
220+
221+
# Parse alle Lessons mit dem richtigen Wochentag
222+
i = 0
223+
while i < len(raw_lessons):
224+
lesson = raw_lessons[i]
225+
class_name = lesson.get('className', '')
226+
227+
if 'lesson-card ' in class_name or class_name.startswith('lesson-card '):
228+
if i in lesson_to_weekday:
229+
weekday = lesson_to_weekday[i]
230+
parsed = self._parse_lesson_card(raw_lessons, i, week_start, weekday)
231+
if parsed:
232+
lessons.append(parsed)
233+
print(f" ✓ {parsed.date} ({datetime.strptime(parsed.date, '%Y-%m-%d').strftime('%A')}): {parsed.start_time}-{parsed.end_time} {parsed.subject} @ {parsed.room}")
139234

140235
i += 1
141236

142237
# Sortiere nach Datum und Zeit
143238
lessons.sort(key=lambda l: (l.date, l.start_time))
144239

145-
print(f"\n{len(lessons)} Lessons über {current_day_offset + 1} Tage gefunden")
240+
# Zähle eindeutige Tage
241+
unique_days = len(set(l.date for l in lessons))
242+
print(f"\n{len(lessons)} Lessons über {unique_days} Tage gefunden")
146243

147244
return lessons
148245

149-
def _parse_lesson_card(self, lessons_list: List[Dict], start_index: int, day_offset: int) -> Optional[UntisLesson]:
246+
def _parse_lesson_card(self, lessons_list: List[Dict], start_index: int, week_start: datetime, weekday: int) -> Optional[UntisLesson]:
150247
"""Parst eine einzelne Lesson-Card"""
151248
try:
152-
# Berechne das Datum basierend auf day_offset
153-
base = datetime.strptime(self.base_date, '%Y-%m-%d')
154-
155-
# Wenn base_date ein Montag ist, addiere day_offset Tage
156-
# Ansonsten finde den Montag dieser Woche
157-
weekday = base.weekday() # 0 = Montag
158-
monday = base - timedelta(days=weekday)
159-
lesson_date = monday + timedelta(days=day_offset)
249+
# Berechne das echte Datum basierend auf Wochentag
250+
# week_start ist Montag, weekday 0=Mo, 1=Di, 2=Mi, ...
251+
lesson_date = week_start + timedelta(days=weekday)
160252
lesson_date_str = lesson_date.strftime('%Y-%m-%d')
161253

162254
main_text = lessons_list[start_index].get('text', '').strip()

0 commit comments

Comments
 (0)