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:
1 parent
79a7cc6a3d
commit
0c9cc69493
7 files changed
+608
-23
No files matched your search
Executable
+186
@@ -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)
|
||||
Reference in new issue
Block a user