#!/usr/bin/env python3 """ PDF zu ICS Konverter für Dienstpläne Extrahiert Schichtinformationen aus PDFs und erstellt iCalendar-Dateien """ import pdfplumber import re from datetime import datetime, timedelta from icalendar import Calendar, Event from pathlib import Path import pytz def extract_dienstplan_data(pdf_path): """ Extrahiert Dienstplan-Daten aus einer PDF-Datei """ dienstplan = { 'name': None, 'vorname': None, 'personalnummer': None, 'betriebshof': None, 'sollarbeitszeit': None, 'monat_start': None, 'monat_end': None, 'events': [] } with pdfplumber.open(pdf_path) as pdf: if not pdf.pages: return dienstplan page = pdf.pages[0] text = page.extract_text() # Extrahiere Metadaten match = re.search(r'Nachname\s+(\S+)\s+Sollarbeitszeit\s+([\d:]+)', text) if match: dienstplan['name'] = match.group(1) dienstplan['sollarbeitszeit'] = match.group(2) match = re.search(r'Vorname\s+(\S+)', text) if match: dienstplan['vorname'] = match.group(1) match = re.search(r'Personalnummer\s+(\d+)', text) if match: dienstplan['personalnummer'] = match.group(1) match = re.search(r'Betriebshof\s+(\S+)', text) if match: dienstplan['betriebshof'] = match.group(1) # Extrahiere Datum-Range match = re.search(r'(\d+)\.\s+(\w+)\s+(\d{4})\s+-\s+(\d+)\.\s+(\w+)\s+(\d{4})', text) if match: start_date_str = f"{match.group(1)}.{match.group(2)}.{match.group(3)}" dienstplan['monat_start'] = start_date_str # Extrahiere Events aus der Tabelle tables = page.extract_tables() if len(tables) >= 2: events = parse_dienstplan_table(tables[1], dienstplan['monat_start']) dienstplan['events'] = events return dienstplan def parse_dienstplan_table(table, month_start_str): """ Parst die Dienstplan-Tabelle und extrahiert Events """ events = [] if not month_start_str: return events # Parse das Startdatum (z.B. "1.März.2026") date_parts = month_start_str.split('.') if len(date_parts) != 3: return events try: day = int(date_parts[0]) month_name = date_parts[1] year = int(date_parts[2]) except: return events # Konvertiere Monatsnamen zu Nummern months = { 'Januar': 1, 'Februar': 2, 'März': 3, 'April': 4, 'Mai': 5, 'Juni': 6, 'Juli': 7, 'August': 8, 'September': 9, 'Oktober': 10, 'November': 11, 'Dezember': 12 } month = months.get(month_name, 1) # Erstelle Basis-Datum base_date = datetime(year, month, day) # Überspringe die Header-Zeile (Montag, Dienstag, etc.) for row_idx in range(1, len(table)): row = table[row_idx] # Iteriere über die 7 Wochentage for day_idx, cell in enumerate(row): if cell is None: continue # Zelle kann mehrere Zeilen enthalten (Tag\nDienst\nZeit) lines = cell.strip().split('\n') if not lines or not lines[0]: continue # Erste Zeile ist der Tag try: day_num = int(lines[0].strip()) except: continue # Berechne das Datum event_date = base_date + timedelta(days=day_num - 1) # Extrahiere Dienstart und Zeit service_code = "" start_time = None end_time = None if len(lines) > 1: # Suche nach Zeitangaben (HH:MM-HH:MM) for line in lines[1:]: time_match = re.match(r'(\d{2}):(\d{2})-(\d{2}):(\d{2})', line.strip()) if time_match: start_time = f"{time_match.group(1)}:{time_match.group(2)}" end_time = f"{time_match.group(3)}:{time_match.group(4)}" else: # Das ist der Dienstart if not service_code: service_code = line.strip() # Erstelle Event event = { 'date': event_date, 'service': service_code, 'start_time': start_time, 'end_time': end_time } events.append(event) return events def create_ics_from_dienstplan(dienstplan, output_path=None, exclude_rest=False, exclude_vacation=False): """ Erstellt eine ICS-Datei aus den Dienstplan-Daten Args: dienstplan: Dictionary mit Dienstplan-Daten output_path: Pfad für Output-Datei exclude_rest: Wenn True, werden Ruhepausen nicht exportiert exclude_vacation: Wenn True, wird Urlaub (060/0060) nicht exportiert """ # Erstelle Calendar cal = Calendar() cal.add('prodid', '-//Dienstplan Importer//de') cal.add('version', '2.0') cal.add('calscale', 'GREGORIAN') cal.add('method', 'PUBLISH') # Timezone tz = pytz.timezone('Europe/Berlin') # Service-Typen die als "Ruhe" angezeigt werden rest_types = ['R56', 'R36', 'vRWF48', 'RWE', 'vR48'] # Füge Events hinzu for event_data in dienstplan['events']: if not event_data['service']: continue service_type = event_data['service'] normalized_service_type = service_type.lstrip('0') or '0' event = Event() # Titel - nur den Dienstart # Spezielle Service-Typen mit aussagekräftigen Namen if normalized_service_type == '60': title = "Urlaub" elif service_type in rest_types: title = "Ruhe" else: title = service_type # Überspringe Ruhepausen wenn gewünscht (nach Titel-Erstellung) if exclude_rest and title == "Ruhe": continue # Überspringe Urlaub wenn gewünscht (060/0060) if exclude_vacation and title == "Urlaub": continue event.add('summary', title) # Beschreibung description = f"Dienstart: {service_type}" if dienstplan['betriebshof']: description += f"\nBetriebshof: {dienstplan['betriebshof']}" event.add('description', description) # Datum und Zeit event_date = event_data['date'] if event_data['start_time'] and event_data['end_time']: # Mit Uhrzeit try: start_hour = int(event_data['start_time'][:2]) start_min = int(event_data['start_time'][3:5]) end_hour = int(event_data['end_time'][:2]) end_min = int(event_data['end_time'][3:5]) # Wenn Endzeit kleiner als Startzeit, läuft Schicht in nächsten Tag if end_hour < start_hour: end_date = event_date + timedelta(days=1) else: end_date = event_date start_dt = event_date.replace(hour=start_hour, minute=start_min, second=0) end_dt = end_date.replace(hour=end_hour, minute=end_min, second=0) event.add('dtstart', tz.localize(start_dt)) event.add('dtend', tz.localize(end_dt)) except: event.add('dtstart', event_date.date()) else: # Nur Datum (Ganztagesveranstaltung) event.add('dtstart', event_date.date()) event.add('dtend', (event_date + timedelta(days=1)).date()) # UID und Metadaten event.add('uid', f"{event_date.isoformat()}-{event_data['service']}-{dienstplan.get('personalnummer', 'unknown')}@dienstplan") event.add('created', datetime.now(tz)) event.add('dtstamp', datetime.now(tz)) cal.add_component(event) # Speichere ICS-Datei if not output_path: output_path = 'dienstplan.ics' with open(output_path, 'wb') as f: f.write(cal.to_ical()) return output_path def main(): """ Hauptfunktion mit CLI-Argumenten """ import sys import argparse parser = argparse.ArgumentParser( description='PDF zu ICS Konverter - Konvertiere Dienstplan-PDFs zu iCalendar-Dateien', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Beispiele: python3 pdf_to_ics.py # Konvertiere alle PDFs im aktuellen Verzeichnis python3 pdf_to_ics.py --input ./pdfs --output ./ics # PDFs aus ./pdfs → ICS zu ./ics python3 pdf_to_ics.py --input ./pdfs --exclude-rest # Schließe Ruhetage aus python3 pdf_to_ics.py --input ./pdfs --exclude-vacation # Schließe Urlaub (060) aus python3 pdf_to_ics.py file.pdf # Konvertiere einzelne Datei """ ) parser.add_argument( 'pdf_file', nargs='?', help='Einzelne PDF-Datei zum Konvertieren (optional)' ) parser.add_argument( '-i', '--input', type=str, default='.', help='Eingabe-Verzeichnis mit PDF-Dateien (Standard: aktuelles Verzeichnis)' ) parser.add_argument( '-o', '--output', type=str, help='Ausgabe-Verzeichnis für ICS-Dateien (Standard: Eingabe-Verzeichnis)' ) parser.add_argument( '-e', '--exclude-rest', action='store_true', help='Ruhetage ausschließen (Ruhe, R56, R36, vRWF48, RWE, vR48)' ) parser.add_argument( '-u', '--exclude-vacation', action='store_true', help='Urlaub ausschließen (060, 0060)' ) parser.add_argument( '-v', '--verbose', action='store_true', help='Detaillierte Ausgabe' ) args = parser.parse_args() # Bestimme Eingabe-Verzeichnis if args.pdf_file: # Einzelne Datei pdf_file = Path(args.pdf_file) if not pdf_file.exists(): print(f"✗ Fehler: Datei nicht gefunden: {pdf_file}") sys.exit(1) pdf_files = [pdf_file] input_dir = pdf_file.parent else: # Verzeichnis input_dir = Path(args.input) if not input_dir.exists(): print(f"✗ Fehler: Verzeichnis nicht gefunden: {input_dir}") sys.exit(1) pdf_files = sorted(input_dir.glob('*.pdf')) # Bestimme Ausgabe-Verzeichnis output_dir = Path(args.output) if args.output else input_dir output_dir.mkdir(parents=True, exist_ok=True) if not pdf_files: print(f"⚠ Keine PDF-Dateien gefunden in: {input_dir}") return if args.verbose: print(f"📂 Eingabe-Verzeichnis: {input_dir}") print(f"📂 Ausgabe-Verzeichnis: {output_dir}") print(f"📄 PDF-Dateien gefunden: {len(pdf_files)}") if args.exclude_rest: print("🧘 Ruhetage werden ausgeschlossen") if args.exclude_vacation: print("🏖️ Urlaub (060) wird ausgeschlossen") print() success_count = 0 error_count = 0 for pdf_file in pdf_files: print(f"\n▶ Verarbeite: {pdf_file.name}") try: # Extrahiere Daten dienstplan = extract_dienstplan_data(str(pdf_file)) if args.verbose: print(f" Name: {dienstplan['vorname']} {dienstplan['name']}") print(f" Personalnummer: {dienstplan['personalnummer']}") print(f" Betriebshof: {dienstplan['betriebshof']}") print(f" Anzahl der Events: {len(dienstplan['events'])}") if not dienstplan['events']: print(f" ⚠️ Warnung: Keine Events gefunden!") error_count += 1 continue # Erstelle ICS-Datei ics_filename = pdf_file.stem + '.ics' ics_path = output_dir / ics_filename create_ics_from_dienstplan( dienstplan, str(ics_path), exclude_rest=args.exclude_rest, exclude_vacation=args.exclude_vacation ) print(f" ✓ ICS erstellt: {ics_path}") success_count += 1 except Exception as e: print(f" ✗ Fehler: {str(e)}") if args.verbose: import traceback traceback.print_exc() error_count += 1 # Zusammenfassung print("\n" + "="*50) print(f"✅ Fertig!") print(f" Erfolgreich: {success_count}") print(f" Fehler: {error_count}") print("="*50) if __name__ == '__main__': main()