Files
pdf_to_ics/pdf_to_ics.py
webfarben d1d1788a3c feat: CLI-Argumente und verbesserte Kommandozeilenverarbeitung
- Add argparse für flexible CLI-Optionen
- Add --input/-i für Eingabe-Verzeichnis
- Add --output/-o für Ausgabe-Verzeichnis
- Add --exclude-rest/-e für Ruhetage ausschließen
- Add --verbose/-v für detaillierte Ausgabe
- Unterstütze einzelne Datei als Argument
- Bessere Fehlerbehandlung und Zusammenfassung
- Update README.md mit CLI-Dokumentation und Beispielen
- Add Optionen-Tabelle für schnelle Referenz
2026-02-23 20:01:10 +01:00

387 lines
12 KiB
Python

#!/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):
"""
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
"""
# 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']
event = Event()
# Titel - nur den Dienstart
# Spezielle Service-Typen mit aussagekräftigen Namen
if service_type == '0060':
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
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 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(
'-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")
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)
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()