Erweiterung von dpl.sh

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.)

  1. Öffne die ICS-Datei mit einer kompatiblen App (Dateimanager → Tippen → Öffnen mit Kalender).
  2. Wähle den Kalender, in den die Termine importiert werden sollen.
  3. Termine erscheinen automatisch. Ganztägige freie Tage sind korrekt markiert.

iPhone / iPad

  1. Datei an iCloud-Mail oder direkt an das iPhone senden.
  2. Tippe auf die ICS-Datei → „Kalender hinzufügen“.
  3. 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.