feat(calendar): Wiederholungsmuster, Terminfarbe synchronisierbar, Selbsttest

Terminfarbe: Sie kam auf dem Telefon nie an. RFC 7986 schreibt für COLOR
einen CSS3-*Namen* vor, wir schrieben Hex – DAVx5 liest die Eigenschaft
über eine Namenstabelle und verwirft alles andere, also blieb der Termin
im Samsung-Kalender farblos. Jetzt beides nebeneinander:

    COLOR:tomato            für fremde Clients (damit die Farbe ankommt)
    X-AETHER-COLOR:#ea4335  für die eigene Palette (damit sie genau bleibt)

Beim Lesen gewinnt der genaue Wert; ein von außen eingefärbter Termin
wird über die Namenstabelle übersetzt. Nächstgelegener Ton per Abstand im
RGB-Würfel (color.go).

Wiederholungen können jetzt mehr als "alle N":

- wöchentlich an ausgewählten Wochentagen (Mo–So, Mehrfachauswahl). Der
  Wochentag des Starts bleibt fest angehakt – eine Reihe, die ihren
  eigenen ersten Termin auslässt, ist keine.
- monatlich und jährlich wahlweise am festen Kalendertag (am 4.) oder am
  n-ten Wochentag (am 1. Freitag). Beide Angaben leitet der Server aus
  dem Startdatum ab, statt sie getrennt entgegenzunehmen – so kann die
  Regel gar nicht erst auf einen anderen Tag zeigen als der Termin.
- Der fünfte Wochentag eines Monats wird bewusst zu "letzter": Einen
  fünften Freitag hat nicht jeder Monat, BYDAY=5FR ließe die Reihe sonst
  in etwa jedem zweiten Monat stillschweigend ausfallen.

Regeln, die das Formular weiterhin nicht abbildet, bleiben lesbar, aber
nicht bearbeitbar (409) – unverändert.

Dazu scripts/kalender-selbsttest.py, 19 Prüfungen gegen die laufende
Instanz, in TESTING.md verlinkt. Alle grün, unter anderem:

    am 1. Freitag monatlich   -> 04.09. 02.10. 06.11. 04.12.
    am 1. Freitag im September -> 2026-09-04, 2027-09-03
    Mo+Fr wöchentlich          -> 07./11./14./18./21./25.09.
    ganztägig am 3.            -> bleibt der 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-04 01:16:06 +02:00
1 parent 79a7cc6a3d
commit 0c9cc69493
7 files changed
+608 -23

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Selbsttest der Kalender-Schnittstelle gegen eine laufende Instanz.
python3 scripts/kalender-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
Voreinstellung: http://localhost:8090, admin/admin123 (Entwicklungsumgebung).
Prüft die Stellen, an denen bereits einmal etwas schiefging – allen voran das
Ganztags-Datum, das in einer Zeitzone östlich von UTC einen Tag zurücksprang –
und die Wiederholungsmuster, deren Fehler man sonst erst Wochen später
bemerkt ("jeder 1. Freitag" darf nicht im Monat ohne fünften Freitag
ausfallen).
**Achtung: legt Termine im ersten Kalender des Kontos an** und löscht am Ende
*alle* Termine darin. Nicht gegen einen Kalender laufen lassen, in dem echte
Termine stehen.
"""
import http.cookiejar
import json
import sys
import urllib.request
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090"
USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
ok_count = 0
fail = []
def api(method, path, body=None, headers=None, raw=False):
url = BASE + path
data = None
hdrs = dict(headers or {})
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
try:
with OPENER.open(req) as r:
payload = r.read().decode()
if raw:
return r.status, payload
return r.status, (json.loads(payload).get("data") if payload else None)
except urllib.error.HTTPError as e:
return e.code, None
def check(name, cond, detail=""):
global ok_count
if cond:
ok_count += 1
print(f" \033[32mok\033[0m {name}")
else:
fail.append(name)
print(f" \033[31mFEHL\033[0m {name} {detail}")
jar = http.cookiejar.CookieJar()
OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
# ── Anmelden ────────────────────────────────────────────────────────────
st, data = api("POST", "/api/v1/auth/login",
{"username": USER, "password": PASSWORT})
assert st == 200, f"Anmeldung fehlgeschlagen: {st}"
CSRF = {"X-CSRF-Token": data["csrf_token"]}
st, cals = api("GET", "/api/v1/calendars")
CAL = cals[0]["id"]
EV = f"/api/v1/calendars/{CAL}/events"
print(f"Kalender: {cals[0]['name']}\n")
def create(**body):
st, d = api("POST", EV, body, CSRF)
assert st == 201, f"Anlegen fehlgeschlagen ({st}): {body.get('summary')}"
return d
def events(frm, to):
st, d = api("GET", f"{EV}?from={frm}&to={to}")
return d or []
def starts(summary, frm="2026-01-01T00:00:00Z", to="2027-12-31T00:00:00Z"):
return [e["start"][:10] for e in events(frm, to) if e["summary"] == summary]
print("── Ganztägig: Datum darf nicht verrutschen ──")
e = create(summary="T ganztag", start="2026-09-03T00:00:00Z",
end="2026-09-03T00:00:00Z", all_day=True)
check("ein Tag bleibt der 3.", e["start"][:10] == "2026-09-03", e["start"])
check("Ende ist derselbe Tag", e["end"][:10] == "2026-09-03", e["end"])
e2 = create(summary="T mehrtags", start="2026-09-10T00:00:00Z",
end="2026-09-12T00:00:00Z", all_day=True)
check("mehrtägig 10.–12.", e2["start"][:10] == "2026-09-10" and e2["end"][:10] == "2026-09-12",
f'{e2["start"]}..{e2["end"]}')
print("\n── Uhrzeit: unverändert durchreichen ──")
e3 = create(summary="T uhrzeit", start="2026-09-15T12:30:00Z",
end="2026-09-15T13:30:00Z", all_day=False)
check("Zeitpunkt unverändert", e3["start"] == "2026-09-15T12:30:00Z", e3["start"])
check("nicht als ganztägig gelesen", e3["all_day"] is False)
print("\n── Farbe: Name für fremde Clients, Hex für uns ──")
e4 = create(summary="T farbe", start="2026-09-16T10:00:00Z",
end="2026-09-16T11:00:00Z", all_day=False, color="#ea4335")
check("Hex kommt unverändert zurück", e4.get("color") == "#ea4335", e4.get("color"))
st, ics = api("GET", f"/api/v1/calendars/{CAL}/export", raw=True)
block = [b for b in ics.split("BEGIN:VEVENT") if "T farbe" in b]
check("COLOR als CSS3-Name im .ics", block and "COLOR:tomato" in block[0],
[l for l in (block[0].splitlines() if block else []) if "COLOR" in l])
check("X-AETHER-COLOR mit genauem Wert",
block and "#ea4335" in "".join(l for l in block[0].splitlines() if "X-AETHER-COLOR" in l),
[l for l in (block[0].splitlines() if block else []) if "COLOR" in l])
print("\n── Wöchentlich an bestimmten Tagen ──")
# 7. Sept 2026 ist ein Montag. Reihe Mo+Fr, 3 Wochen.
create(summary="T wochentage", start="2026-09-07T08:00:00Z",
end="2026-09-07T09:00:00Z", all_day=False,
recurrence={"freq": "WEEKLY", "interval": 1, "count": 6,
"byday": ["MO", "FR"]})
got = starts("T wochentage")
check("Mo+Fr, 6 Termine", got == ["2026-09-07", "2026-09-11", "2026-09-14",
"2026-09-18", "2026-09-21", "2026-09-25"], got)
print("\n── Monatlich: am festen Tag vs. am n-ten Wochentag ──")
# 4. Sept 2026 ist der 1. Freitag des Monats.
create(summary="T monat tag", start="2026-09-04T08:00:00Z",
end="2026-09-04T09:00:00Z", all_day=False,
recurrence={"freq": "MONTHLY", "interval": 1, "count": 4, "mode": "DAY"})
got = starts("T monat tag")
check("am 4. jedes Monats", got == ["2026-09-04", "2026-10-04", "2026-11-04", "2026-12-04"], got)
create(summary="T monat wochentag", start="2026-09-04T10:00:00Z",
end="2026-09-04T11:00:00Z", all_day=False,
recurrence={"freq": "MONTHLY", "interval": 1, "count": 4, "mode": "WEEKDAY"})
got = starts("T monat wochentag")
# jeweils der 1. Freitag: 4.9. / 2.10. / 6.11. / 4.12.
check("am 1. Freitag jedes Monats", got == ["2026-09-04", "2026-10-02", "2026-11-06", "2026-12-04"], got)
print("\n── Jährlich: beide Muster ──")
create(summary="T jahr tag", start="2026-09-04T08:00:00Z",
end="2026-09-04T09:00:00Z", all_day=False,
recurrence={"freq": "YEARLY", "interval": 1, "count": 2, "mode": "DAY"})
check("am 4. September", starts("T jahr tag") == ["2026-09-04", "2027-09-04"], starts("T jahr tag"))
create(summary="T jahr wochentag", start="2026-09-04T12:00:00Z",
end="2026-09-04T13:00:00Z", all_day=False,
recurrence={"freq": "YEARLY", "interval": 1, "count": 2, "mode": "WEEKDAY"})
# 1. Freitag im September: 2026-09-04, 2027-09-03
check("am 1. Freitag im September",
starts("T jahr wochentag") == ["2026-09-04", "2027-09-03"], starts("T jahr wochentag"))
print("\n── Regel bleibt beim Bearbeiten erhalten ──")
ser = [e for e in events("2026-09-01T00:00:00Z", "2026-12-31T00:00:00Z")
if e["summary"] == "T monat wochentag"][0]
check("Regel wird als bearbeitbar gemeldet", ser.get("recurrence", {}).get("mode") == "WEEKDAY",
ser.get("recurrence"))
check("Reihenbeginn mitgeliefert", ser.get("series_start", "")[:10] == "2026-09-04",
ser.get("series_start"))
st, _ = api("PATCH", f"{EV}/{ser['uid']}",
{"summary": "T monat wochentag", "start": "2026-09-04T10:00:00Z",
"end": "2026-09-04T11:00:00Z", "all_day": False,
"recurrence": {"freq": "MONTHLY", "interval": 2, "count": 3, "mode": "WEEKDAY"}}, CSRF)
check("Bearbeiten erlaubt (200)", st == 200, st)
got = starts("T monat wochentag")
check("jetzt jeden 2. Monat, 1. Freitag", got == ["2026-09-04", "2026-11-06", "2027-01-01"], got)
print("\n── Zeitfenster begrenzt wirklich ──")
win = [e for e in events("2026-09-14T00:00:00Z", "2026-09-20T00:00:00Z")
if e["summary"] == "T wochentage"]
check("nur die eine Woche", [e["start"][:10] for e in win] == ["2026-09-14", "2026-09-18"],
[e["start"][:10] for e in win])
print("\n── Aufräumen ──")
seen = {e["uid"] for e in events("2020-01-01T00:00:00Z", "2030-01-01T00:00:00Z")}
for uid in seen:
api("DELETE", f"{EV}/{uid}", None, CSRF)
rest = events("2020-01-01T00:00:00Z", "2030-01-01T00:00:00Z")
check("Kalender wieder leer", rest == [], rest)
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
if fail:
print("Fehlgeschlagen: " + ", ".join(fail))
sys.exit(1 if fail else 0)