Mit dpl.sh wird „lediglich“ eine txt-Datei generiert, ohne dass eine weitere, sinnvolle Verarbeitung geschieht. In abgewandelter Form und mithilfe eines weiteren Skripts (in Python) erhält man eine .ics-Datei, deren Einträge sich in einen digitalen Kalender – auch und explizit in einen iCloud-Kalender – importieren lassen.
Aktuell muss die generierte Datei als Anhang an die eigene E-Mailadresse gesendet werden, mit der das iCloud-Konto verknüpft ist. Dazu kommt, dass man zwingend im Terminal arbeiten muss und da sich darin eine vollwertige Tastatur als unabdingbar herausgestellt hat ist es notwendig, ein Webinterface zu bauen.
schichten2ics.sh
#!/usr/bin/env bash
# Temporäre Datei für Python
tmpfile=$(mktemp /tmp/shifts.XXXXXX)
# Eingabe Jahr/Monat
read -p "Jahr (YYYY): " year
read -p "Monat (MM): " month
# Kalendername und Ausgabe
default_cal="Dienstplan $year-$month"
read -p "Kalendername [$default_cal]: " calname
calname=${calname:-$default_cal}
default_file="Dienstplan-$year-$month.ics"
read -p "Ausgabedatei [$default_file]: " outfile
outfile=${outfile:-$default_file}
# Anzahl Tage im Monat
days_in_month=$(cal $month $year | awk 'NF {DAYS = $NF}; END {print DAYS}')
# Funktion für Schicht-Eingabe mit Prüfung
read_shift() {
local date="$1"
local input
while true; do
read -p "Schicht für $date (Format HH-HH, HH:MM-HH:MM oder x für frei): " input
if [[ "$input" =~ ^[xX]$ ]]; then
echo "$input"
return
elif [[ "$input" =~ ^([0-2]?[0-9])(:[0-5][0-9])?-([0-2]?[0-9])(:[0-5][0-9])?$ ]]; then
# Stunden und Minuten prüfen
start_h="${BASH_REMATCH[1]}"
start_m="${BASH_REMATCH[2]:-0}"
end_h="${BASH_REMATCH[3]}"
end_m="${BASH_REMATCH[4]:-0}"
if (( start_h <= 24 && start_m <= 59 && end_h <= 24 && end_m <= 59 )); then
echo "$input"
return
fi
fi
# Fehleranzeige in rot
echo -e "\e[31mUngültiges Format! Bitte erneut eingeben.\e[0m"
done
}
# Tagesweise Eingabe
for d in $(seq -w 1 $days_in_month); do
date="$year-$month-$d"
shift_input=$(read_shift "$date")
echo "$date $shift_input" >> "$tmpfile"
done
# Python-Skript aufrufen
python3 "$(dirname "$0")/txt2ics_shift.py" -i "$tmpfile" -o "$outfile" --calname "$calname"
# Aufräumen
rm -f "$tmpfile"
echo -e "✅ Fertig: $outfile wurde erstellt (Kalendername: $calname)."
txt2ics_shift.py
#!/usr/bin/env python3
import sys
import datetime as dt
import re
from icalendar import Calendar, Event
RANGE_RE = re.compile(r'^(?P<start>\d{1,2}(?::\d{2})?)-(?P<end>\d{1,2}(?::\d{2})?)(?:;\s*(?P<title>.*))?$')
def parse_time(s):
"""Stunde und Minute aus HH oder HH:MM extrahieren"""
if ':' in s:
h, m = map(int, s.split(':'))
else:
h = int(s)
m = 0
return h, m
def parse_line(body, default_title=None):
"""Eine Zeile aus der Schichtdatei parsen"""
body = body.strip()
if not body:
return None
# Datum und Rest trennen
if ' ' not in body:
return None
d_str, rest = body.split(' ', 1)
d = dt.datetime.strptime(d_str, "%Y-%m-%d").date()
# Frei-Tage erkennen
if rest.lower().startswith('x'):
title = 'Frei'
# Ganztägiges Event markieren
return (d, (None, None), title, True)
# Zeitbereich parsen
mrange = RANGE_RE.match(rest)
if mrange:
sh = mrange.group('start')
eh = mrange.group('end')
title = mrange.group('title').strip() if mrange.group('title') else (default_title or 'Schicht')
sh_h, sh_m = parse_time(sh)
eh_h, eh_m = parse_time(eh)
t_start = dt.time(hour=sh_h, minute=sh_m)
# Sonderfall 24:00 -> 23:59
if eh_h == 24 and eh_m == 0:
t_end = dt.time(hour=23, minute=59)
else:
t_end = dt.time(hour=eh_h, minute=eh_m)
return (d, (t_start, t_end), title, False)
return None
def build_ics(lines, calendar_name="Dienstplan"):
cal = Calendar()
cal.add('prodid', '-//Schichtplan//')
cal.add('version', '2.0')
cal.add('X-WR-CALNAME', calendar_name)
for line in lines:
parsed = parse_line(line, default_title=None)
if parsed is None:
continue
d, (t_start, t_end), title, all_day = parsed
e = Event()
if all_day or t_start is None or t_end is None:
# Ganztägiges Event
e.add('dtstart', dt.datetime.combine(d, dt.time(0,0)))
e.add('dtend', dt.datetime.combine(d, dt.time(23,59)))
else:
dt_start = dt.datetime.combine(d, t_start)
dt_end = dt.datetime.combine(d, t_end)
e.add('dtstart', dt_start)
e.add('dtend', dt_end)
e.add('summary', title)
cal.add_component(e)
return cal.to_ical()
def main(argv):
import argparse
parser = argparse.ArgumentParser(description="Schichtplan TXT → ICS")
parser.add_argument('-i', '--input', required=True, help="Textdatei mit Schichten")
parser.add_argument('-o', '--output', required=True, help="Ziel .ics-Datei")
parser.add_argument('--calname', default="Dienstplan", help="Kalendername")
args = parser.parse_args(argv)
with open(args.input, 'r') as f:
lines = f.readlines()
ics = build_ics(lines, calendar_name=args.calname)
with open(args.output, 'wb') as f:
f.write(ics)
print(f"✅ Fertig: {args.output} erstellt")
if __name__ == "__main__":
main(sys.argv[1:])
Das ist ja alles schön und gut,
aber
Gehen wir von einem realistischen Usecase aus, wird mir der Dienstplan diktiert; entsprechend muss ich in die Lage versetzt werden, die Informationen auf einem Mobile Device zu erfassen und mir die benötigte ICS-Datei direkt generieren zu lassen:
index.html
<!DOCTYPE html>
<html lang="de">
<head>
<title>¯\_(ツ)_/¯ foo - dpl2ics</title>
<link rel="icon" href="https://wayne-intressierts.de/wp-content/uploads/2020/10/cropped-cropped-hal-157883_640.png" sizes="32x32" />
<link rel="icon" href="https://wayne-intressierts.de/wp-content/uploads/2020/10/cropped-cropped-hal-157883_640.png" sizes="192x192" />
<link rel="apple-touch-icon-precomposed" href="https://wayne-intressierts.de/wp-content/uploads/2020/10/cropped-cropped-hal-157883_640.png" />
<meta name="msapplication-TileImage" content="https://wayne-intressierts.de/wp-content/uploads/2020/10/cropped-cropped-hal-157883_640.png" />
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: sans-serif; background: #111; color: #eee; padding: 1em; }
table { border-collapse: collapse; width: 100%; margin-top: 1em; }
th, td { border: 1px solid #444; padding: 0.5em; text-align: center; }
input[type="text"], input[type="number"] { background: #222; color: #eee; border: 1px solid #555; width: 100%; }
button { padding: 0.5em 1em; margin-top: 1em; cursor: pointer; }
tr.free-day { background-color: #444; color: #ccc; }
</style>
</head>
<body>
<h1>¯\_(ツ)_/¯</h1>
<h1>dpl2ics</h1>
<p>Trage das Jahr und den Monat ein und die Tage des jeweiligen Monats werden dir angezeigt, damit du deine Schichten eintragen kannst.<br>
Weitere Infos zur Handhabung dieses Onlinetools findest du <a href="https://wayne-intressierts.de/dpl2ics/">hier</a>.</p>
<form id="shift-form">
<label>Jahr: <input type="number" id="year" value="2025" min="2000" max="2100"></label>
<label>Monat: <input type="number" id="month" value="9" min="1" max="12"></label>
<label>Kalender-Dateiname: <input type="text" id="calname" value="Dienstplan MM-YYYY"></label>
<button type="submit">ICS generieren</button>
<table id="shift-table">
<tr>
<th>Frei</th>
<th>Tag</th>
<th>Start</th>
<th>Ende</th>
</tr>
</table>
</form>
<script>
const table = document.getElementById('shift-table');
const timesList = [];
for(let h=0; h<24; h++){
for(let m=0; m<60; m+=15){
const hh = String(h).padStart(2,'0');
const mm = String(m).padStart(2,'0');
timesList.push(`${hh}:${mm}`);
}
}
function createTable(days){
for(let d=1; d<=days; d++){
const tr = document.createElement('tr');
const tdFree = document.createElement('td');
const chk = document.createElement('input'); chk.type='checkbox';
tdFree.appendChild(chk); tr.appendChild(tdFree);
const tdDay = document.createElement('td'); tdDay.textContent=d; tr.appendChild(tdDay);
const tdStart = document.createElement('td'); const startInput = document.createElement('input');
startInput.type='text'; startInput.setAttribute('list','times-list-'+d); tdStart.appendChild(startInput); tr.appendChild(tdStart);
const tdEnd = document.createElement('td'); const endInput = document.createElement('input');
endInput.type='text'; endInput.setAttribute('list','times-list-'+d); tdEnd.appendChild(endInput); tr.appendChild(tdEnd);
const datalist = document.createElement('datalist'); datalist.id='times-list-'+d;
timesList.forEach(t=>{ const option=document.createElement('option'); option.value=t; datalist.appendChild(option); });
document.body.appendChild(datalist);
chk.addEventListener('change', e=>{
if(chk.checked){
startInput.value=''; endInput.value=''; startInput.disabled=true; endInput.disabled=true; tr.classList.add('free-day');
} else { startInput.disabled=false; endInput.disabled=false; tr.classList.remove('free-day'); }
});
table.appendChild(tr);
}
}
function updateDays(){
table.querySelectorAll('tr:not(:first-child)').forEach(tr=>tr.remove());
const year = parseInt(document.getElementById('year').value);
const month = parseInt(document.getElementById('month').value);
const days = new Date(year, month, 0).getDate();
createTable(days);
}
document.getElementById('year').addEventListener('change', updateDays);
document.getElementById('month').addEventListener('change', updateDays);
updateDays();
document.getElementById('shift-form').addEventListener('submit', async e=>{
e.preventDefault();
const year = document.getElementById('year').value;
const month = document.getElementById('month').value;
const calname = document.getElementById('calname').value;
const rows = table.querySelectorAll('tr:not(:first-child)');
let content='';
rows.forEach((tr,i)=>{
const free=tr.querySelector('input[type="checkbox"]').checked;
const start=tr.querySelectorAll('input[type="text"]')[0].value;
const end=tr.querySelectorAll('input[type="text"]')[1].value;
if(free||(!start&&!end)) content += `${year}-${String(month).padStart(2,'0')}-${String(i+1).padStart(2,'0')} x\n`;
else content += `${year}-${String(month).padStart(2,'0')}-${String(i+1).padStart(2,'0')} ${start}-${end}\n`;
});
// POST an Bash-Skript
const formData = new FormData();
formData.append('data', content);
formData.append('calname', calname);
const res = await fetch('generate.php', { method:'POST', body: formData });
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Dienstplan-${year}-${month}.ics`;
a.click();
});
</script>
</body>
</html>
generate.php
<?php
// generate.php – Version mit Zeitspanne im Titel + automatische Schicht-Erkennung (>=12:01 = Spätschicht)
// Stand: Oktober 2025
// Teil des Projekts "dpl2ics" von ¯\_(ツ)_/¯
// -----------------------------------------
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit("Nur POST erlaubt.");
}
// Eingabedaten abholen
$data = $_POST['data'] ?? '';
$calname = $_POST['calname'] ?? 'Dienstplan MM-YYYY';
// Kalenderkopf erzeugen
$ical = "BEGIN:VCALENDAR\n";
$ical .= "VERSION:2.0\n";
$ical .= "PRODID:-//dienstplan2ics//bash//DE\n";
$ical .= "X-WR-CALNAME:$calname\n";
$ical .= "X-WR-TIMEZONE:Europe/Berlin\n";
$ical .= "CALSCALE:GREGORIAN\n";
// Zeilenweise verarbeiten
$lines = explode("\n", trim($data));
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') continue;
// Format: YYYY-MM-DD x (frei)
if (preg_match('/^(\d{4}-\d{2}-\d{2}) x$/', $line, $m)) {
continue; // kein Event für freie Tage
}
// Format: YYYY-MM-DD hh:mm-hh:mm
if (preg_match('/^(\d{4}-\d{2}-\d{2}) (\d{2}):(\d{2})-(\d{2}:\d{2})$/', $line, $m)) {
$date = $m[1];
$hour = intval($m[2]);
$minute = intval($m[3]);
$start = sprintf("%02d:%02d", $hour, $minute);
$end = $m[4];
// Schichterkennung: bis 12:00 inkl. = Frühschicht, ab 12:01 = Spätschicht
if ($hour < 12 || ($hour == 12 && $minute == 0)) {
$type = "Frühschicht";
} else {
$type = "Spätschicht";
}
// Zeitangaben für iCal
$dtstart = str_replace(['-',':'], '', "{$date}T{$start}00");
$dtend = str_replace(['-',':'], '', "{$date}T{$end}00");
// Titel inklusive Zeitspanne und Schichttyp
$summary = "$start-$end $type";
// Terminblock
$ical .= "BEGIN:VEVENT\n";
$ical .= "DTSTART;TZID=Europe/Berlin:$dtstart\n";
$ical .= "DTEND;TZID=Europe/Berlin:$dtend\n";
$ical .= "SUMMARY:$summary\n";
$ical .= "END:VEVENT\n";
}
}
// Kalenderende
$ical .= "END:VCALENDAR\n";
// Header für ICS-Dateidownload
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename=\"dienstplan.ics\"');
echo $ical;
generate_ics.sh
#!/usr/bin/env bash
# Bash ICS Generator mit Früh-/Spätschicht + CRLF-Fix + UID/DTSTAMP
set -euo pipefail
infile="$1"
outfile="$2"
calname="${3:-Dienstplan}"
# CRLF im Output (manche Apple-Parser sind pingelig)
crlf() { sed 's/$/\r/' ; }
seqno=0
now_utc() { date -u +%Y%m%dT%H%M%SZ; }
{
echo "BEGIN:VCALENDAR"
echo "VERSION:2.0"
echo "PRODID:-//$calname//Bash//DE"
echo "X-WR-CALNAME:$calname"
while IFS= read -r line; do
# --- Eingabe härten: \r (CR) entfernen, leere Zeilen überspringen
line="${line%$'\r'}"
[[ -z "$line" ]] && continue
# date = erstes Feld, rest = alles dahinter
date="${line%%[[:space:]]*}"
rest="${line#*[[:space:]]}"
rest="${rest%$'\r'}"
# frei? (nur 'x' bzw. 'X', evtl. mit Leerzeichen)
if [[ "$rest" =~ ^[[:space:]]*[xX][[:space:]]*$ ]]; then
seqno=$((seqno+1))
echo "BEGIN:VEVENT"
echo "UID:${date//-/}-$seqno@dpl2ics"
echo "DTSTAMP:$(now_utc)"
echo "SUMMARY:Frei"
echo "DTSTART:${date//-/}T000000"
echo "DTEND:${date//-/}T235900"
echo "END:VEVENT"
continue
fi
# times = erstes Wort im 'rest' (HH:MM-HH:MM)
times="${rest%%[[:space:]]*}"
start="${times%-*}"
end="${times#*-}"
# \r entfernen, Doppelpunkte raus, an ICS-Format anpassen
start="${start//$'\r'/}"; end="${end//$'\r'/}"
start_hm="${start//:/}"; end_hm="${end//:/}"
# Guard: wenn Zeit unbrauchbar → als Frei buchen (defensiv)
if [[ -z "$start_hm" || -z "$end_hm" || ! "$start_hm$end_hm" =~ ^[0-9]+$ ]]; then
seqno=$((seqno+1))
echo "BEGIN:VEVENT"
echo "UID:${date//-/}-$seqno@dpl2ics"
echo "DTSTAMP:$(now_utc)"
echo "SUMMARY:Frei"
echo "DTSTART:${date//-/}T000000"
echo "DTEND:${date//-/}T235900"
echo "END:VEVENT"
continue
fi
# Titel automatisch
if ((10#$start_hm < 1200)); then
title="Frühschicht"
else
title="Spätschicht"
fi
seqno=$((seqno+1))
echo "BEGIN:VEVENT"
echo "UID:${date//-/}-$seqno@dpl2ics"
echo "DTSTAMP:$(now_utc)"
echo "SUMMARY:$title"
echo "DTSTART:${date//-/}T${start_hm}00"
echo "DTEND:${date//-/}T${end_hm}00"
echo "END:VEVENT"
done < "$infile"
echo "END:VCALENDAR"
} | crlf > "$outfile"
Cheat Sheet

🗓 Dienstplan-ICS: Schnellstart-Anleitung
1️⃣ ICS-Datei generieren
- Gehe ins Webinterface.
- Wähle Jahr und Monat.
- Fülle die Schichtzeiten aus:
- Start / Ende: direkt eingeben oder aus Dropdown auswählen (Viertelstunden möglich).
- Frei: Checkbox anklicken → ganztägig frei markiert.
- Klicke auf ICS generieren → Datei wird heruntergeladen (
Dienstplan-YYYY-MM.ics).
2️⃣ Datei auf Smartphone importieren
Android (Google Kalender, Outlook, etc.)
- Öffne die ICS-Datei mit einer kompatiblen App (Dateimanager → Tippen → Öffnen mit Kalender).
- Wähle den Kalender, in den die Termine importiert werden sollen.
- Termine erscheinen automatisch. Ganztägige freie Tage sind korrekt markiert.
iPhone / iPad
- Datei an iCloud-Mail oder direkt an das iPhone senden.
- Tippe auf die ICS-Datei → „Kalender hinzufügen“.
- Termine werden automatisch eingetragen. Ganztägig frei wird korrekt angezeigt.
3️⃣ Tipps & Tricks
- Wenn du einen Monat fertig eingetragen hast, speichere die ICS-Datei – dann kann sie jederzeit wieder importiert werden.
- Ganztägig freie Tage erscheinen farblich anders in den meisten Kalender-Apps (grau/hell), Schichten haben die Standardfarbe.
- Viertelstunden-Auswahl hilft beim genauen Eintragen von Schichtfenstern wie
06:15-09:45.
4️⃣ Für die nächsten Monate
- Einfach wieder Webinterface öffnen → neuen Monat auswählen → Eingaben machen → ICS herunterladen → Importieren.
- Alte Dateien können behalten oder ersetzt werden – die Kalender-Apps erkennen automatisch Duplikate anhand der UID.