Dieses Projekt stellt einen leichtgewichtigen iCal-Proxy bereit. Er lädt externe ICS-Quellen, filtert und transformiert Events optional, führt mehrere Quellen zusammen, dedupliziert sie und liefert daraus geschützte Feeds aus.
Hauptziele:
- mehrere Quellen pro Export unterstützen
- robuste Filterregeln mit klaren Reaktionen auf Treffer
- optionale Event-Transformationen pro Filterregel
- Source- und Export-Cache
- CLI-Werkzeuge für Betrieb und Debugging
- Erweiterbarkeit für spätere Admin-GUI
Voraussetzungen:
- PHP 8.3+
- Composer
Installation:
composer installHTTP-Server starten:
php -S 127.0.0.1:8080 -t publicKonfiguration prüfen:
php bin/console app:config:validateÖffentlicher Feed-Endpunkt:
GET /feed/{SECRET}/{SLUG}.ics
Beispiel:
http://127.0.0.1:8080/feed/random-secret-token/technikdienst.ics
Die Konfiguration ist hierarchisch aufgebaut:
sourcesbeschreibt die Eingangsfeedsexportsbeschreibt die auszuliefernden Feeds- unter beiden Bereichen können
filtersdefiniert werden
Grundform:
sources:
source_key:
label: "Optionaler Anzeigename"
url: "https://example.com/calendar.ics"
cache_ttl: "15m"
filters:
- type: match
match:
summary:
contains: "Intern"
on_match: remove
exports:
export_key:
title: "Export Titel"
slug: "export-slug"
token: "random-secret-token"
cache_ttl: "10m"
filters:
- type: match
match:
summary:
contains: "Technik"
on_match: remove
include_sources:
- source: source_key
filters:
- type: match
match:
summary:
contains: "Technik"
on_match: transform
transform:
- type: prefix_text
field: summary
value: "[Tech] "Wichtig:
sourceswird zuerst geladenexportsreferenziert einzelne Sources überinclude_sources- Filter werden in YAML-Reihenfolge ausgeführt
on_matchentscheidet, was nach einem Treffer passierttransformwird nur genutzt, wennon_match: transformgesetzt ist
Aus Anwendersicht läuft ein Export in dieser Reihenfolge:
- Quellen laden
- Source-Filter anwenden
- Events aller
include_sourcessammeln - Export-Filter anwenden
- Event-Migration anwenden
- Dubletten entfernen
Wichtig:
- Source-Filter betreffen nur die jeweilige Quelle
- Export-Filter betreffen den gesamten Export nach dem Zusammenführen
event_migrationarbeitet auf dem bereits gefilterten Export- Deduplication sorgt dafür, dass Events mit gleicher
UIDnur einmal im Export landen
Eine Filterregel besteht immer aus drei Ebenen:
typebzw. der Filtertypon_matchbzw. das Verhalten bei Treffertransformbzw. die optionalen Veränderungen
Beispiel:
filters:
- type: match
match:
summary:
contains: "Technik"
on_match: transform
transform:
- type: prefix_text
field: summary
value: "[Tech] "Das bedeutet:
type: matchprüft, ob die Regel greifton_match: transformsagt, dass bei Treffer transformiert werden solltransformenthält die konkrete Änderung
sources definiert externe Eingangsfeeds.
Pro Source:
labeloptionalurlPflichtcache_ttloptional (Format:30s,15m,1h,1d)filtersoptional (werden vor Export-Ebene angewendet)
exports definiert auszugebende Zielfeeds.
Pro Export:
titlePflichtslugPflicht (öffentlich sichtbarer URL-Teil)tokenPflicht (Zugriffsschutz)cache_ttloptionalinclude_sourcesPflicht (mindestens eine referenzierte Source)filtersoptional und wirken erst nach dem Merge allerinclude_sourcesfilterspro Included Source arbeiten mittype,match,on_matchund optionaltransform
Source-Filter leben unter sources.<key>.filters und betreffen nur diese einzelne Quelle, bevor sie in Exporte eingeht.
Export-Filter leben unter exports.<key>.filters und werden nach dem Merge aller inkludierten Quellen auf den kompletten Export angewendet.
Beispiel:
exports:
export_key:
filters:
- type: match
match:
summary:
contains: "Technik"
on_match: removeon_match: remove: entferne alle Events, die matchenon_match: keep: behalte nur Events, die matchenon_match: transform: führetransform[]aus und behalte das Eventmatch.any: true: diese Regel trifft auf jedes Event zu
Regeln werden strikt in YAML-Reihenfolge ausgeführt. Mehrere Bedingungen innerhalb eines match-Blocks sind mit AND verknüpft. keep ist damit ein Whitelist-Filter: Nicht treffende Events werden in dieser Regel entfernt.
Ein match-Filter prüft ein oder mehrere Felder eines Events. Die Felder werden mit den angegebenen Operatoren verglichen.
Unterstützte Felder:
summarydescriptionlocationurlcategoriesdate
Unterstützte Operatoren:
containscontains_anycontains_allnot_containsequalsnot_equalsregexempty
Datumsspezifisch (date):
fromuntil
Unterstützte Datumswerte:
now- relative Angaben wie
+12 months,-7 days - absolute Form
YYYY-MM-DD
Beispiele:
contains
match:
summary:
contains: "Technik"contains_any
match:
summary:
contains_any: ["Technik", "Ton", "Licht"]contains_all
match:
summary:
contains_all: ["Technik", "Probe"]not_contains
match:
description:
not_contains: "intern"equals
match:
location:
equals: "Kirche"match:
categories:
equals: ["Technik", "Dienst"]not_equals
match:
summary:
not_equals: "Abgesagt"regex
match:
summary:
regex: "/^(Technik|Medien)/i"empty
match:
url:
empty: trueDatum (from, until)
match:
date:
from: "2026-01-01"
until: "2026-12-31"match:
date:
from: "now"
until: "+12 months"Transformationen laufen nach erfolgreichem Match einer Regel und werden als Liste von type-Einträgen angegeben.
Unterstützt:
- Textfelder:
prefix_text,suffix_text,replace_text,replace_regex,remove_property - Kategorien:
categories_add,categories_remove - Datum:
modify_datetime - Zeitverschiebung für Start und Ende in einem Schritt:
adjust_times
Zeitverschiebung:
start.referenceundend.referencekönnencurrent_startodercurrent_endseinoffsetakzeptiert Sekunden, Minuten und Stunden mit optionalem Vorzeichen, z. B.+30s,-20m,+2h- fehlende
reference-Werte werden standardmäßig alscurrent_startfürstartundcurrent_endfürendbehandelt - All-Day-Events werden ignoriert
DTENDwird nie vorDTSTARTgeschrieben; falls nötig, wirdDTENDautomatisch aufDTSTARTkorrigiert- wenn
DURATIONvorhanden ist, wird sie zur neuen Zeitspanne passend neu berechnet
Texttransformationen arbeiten auf den Feldern summary, description, location und url.
Beispiel:
filters:
- type: match
match:
any: true
on_match: transform
transform:
- type: prefix_text
field: summary
value: "[Global] "
- type: suffix_text
field: summary
value: " (öffentlich)"
- type: replace_text
field: description
search: "intern"
replace: "extern"Kategorien werden als Liste bzw. ICS-Property behandelt.
Beispiel:
filters:
- type: match
match:
any: true
on_match: transform
transform:
- type: categories_add
value: "Standard"
- type: categories_remove
value: "Entwurf"Es gibt zwei verschiedene Zeit-Transformationen:
modify_datetimeverändertstartoderendeinzelnadjust_timesberechnet Start und Ende gemeinsam
Beispiel modify_datetime:
filters:
- type: match
match:
summary:
contains: "Workshop"
on_match: transform
transform:
- type: modify_datetime
field: start
value: "+1 day"Beispiel adjust_times:
filters:
- type: match
match:
any: true
on_match: transform
transform:
- type: adjust_times
start:
reference: current_start
offset: "-20m"
end:
reference: current_start
offset: "10m"Beispiel mit current_end und Stunden:
filters:
- type: match
match:
summary:
contains: "Workshop"
on_match: transform
transform:
- type: adjust_times
start:
reference: current_end
offset: "-1h"
end:
reference: current_end
offset: "+2h"Beispiel mit Sekunden und vorhandener DURATION:
filters:
- type: match
match:
any: true
on_match: transform
transform:
- type: adjust_times
start:
reference: current_start
offset: "+30s"
end:
reference: current_start
offset: "+90s"replace_regexersetzt per regulärem Ausdruckremove_propertyentfernt eine ICS-Property komplett
Beispiel:
filters:
- type: match
match:
any: true
on_match: transform
transform:
- type: replace_regex
field: description
pattern: "/\\s+/"
replacement: " "
- type: remove_property
field: urlMit event_migration können sich überschneidende oder zeitlich nahe Events innerhalb eines Exports zu einem gemeinsamen Termin zusammengeführt werden.
Die Migration läuft:
- nach allen Source-Filtern (
sources.<key>.filters) - nach allen Include-Filtern (
exports.<key>.include_sources[].filters) - nach allen Export-Filtern (
exports.<key>.filters) - vor der Auslieferung des Exports
Parameter pro Export:
event_migration.enabled(bool, optional, defaultfalse)event_migration.gap_tolerance(string, optional, default0s, z. B.5m)event_migration.strategy(string, optional, defaultmerge_titles_csv)
Beispiel:
exports:
handball_kinder:
title: "Handballtermine Kinder"
slug: "handball-kinder"
token: "random-secret-token"
cache_ttl: "10m"
include_sources:
- source: ananias_f
- source: ananias_e
- source: timjamin_hsg
- source: danio_e
event_migration:
enabled: true
gap_tolerance: "5m"
strategy: "merge_titles_csv"Regeln:
- Scope ist exportweit über alle eingebundenen Sources.
- All-day-Events werden getrennt von zeitgebundenen Events behandelt.
- Events werden gruppiert, wenn sie sich überschneiden oder wenn der Abstand kleiner/gleich
gap_toleranceist. - Events ohne
DTENDwerden als 0-Dauer behandelt.
Standardstrategie merge_titles_csv:
summary: Titel komma-separiert in zeitlicher Reihenfolgedtstart: frühester Startdtend: spätestes Endelocation: bei identischem Wert einmal, sonst eindeutige Werte komma-separiertdescription: Inhalte mit Trenner zusammengeführtcategories: Union (eindeutige Kategorien)url: erste verfügbare URLuid: deterministisch neu erzeugt
Zwei Ebenen:
- Source-Cache (
var/cache/feeds): normalisierte Source-Feeds nach Anwendung vonsources.<id>.filtersinkl. Transformationen - Export-Cache (
var/cache/exports): fertige serialisierte Export-Feeds
Fallback-Verhalten:
- bei HTTP-Fehlern wird, wenn vorhanden, veralteter normalisierter Source-Cache verwendet
- wenn keine Quelle erfolgreich verarbeitet werden kann, liefert der HTTP-Endpunkt
503
Konfiguration:
php bin/console app:config:validateSources anzeigen:
php bin/console app:sources:listExports anzeigen:
php bin/console app:exports:listSource-Cache vorwärmen:
php bin/console app:feeds:warm-cacheExport-Vorschau:
php bin/console app:export:preview technikdienst --limit=20
php bin/console app:export:preview technikdienst --limit=20 --no-cacheCache löschen:
php bin/console app:cache:clear
php bin/console app:cache:clear --scope=feeds
php bin/console app:cache:clear --scope=exportsCache aufräumen:
php bin/console app:cache:prune
php bin/console app:cache:prune --scope=feeds --age=3d
php bin/console app:cache:prune --scope=all --age=12h- Konfigurationsfehler: hart abbrechen
- Runtime-Fehler einzelner Quellen: loggen und nach Möglichkeit mit anderen Quellen fortfahren
- Ungültige Quellen blockieren nicht automatisch den gesamten Export
- HTTP-Endpunkt gibt keine sensiblen Interna aus
Die Struktur ist bereits auf spätere GUI-Erweiterung vorbereitet:
- serialisierbare DTOs
- klar getrennte Layer (Config, Calendar, Filter, Cache, Http)
- bestehende CLI-Funktionen als Grundlage für GUI-Aktionen
- Tokens schützen öffentliche Feed-URLs
- Tokens niemals in Logs, Tickets oder Screenshots teilen
- pro Export unterschiedliche, starke, zufällige Tokens nutzen
- kompromittierte Tokens sofort rotieren
- bei ungültigem
slug/tokenwird bewusst404geliefert, um Exporte nicht zu leaken
slugundtokenmüssen eindeutig bzw. nicht leer sein.cache_ttlmuss im Format wie30s,15m,1hoder1dangegeben werden.regex-Pattern müssen gültige PCRE-Ausdrücke sein.- Bei
adjust_timessind nurs,mundhals Offsets erlaubt. modify_datetimeverschiebt nurstartoderend, nicht beide gemeinsam.