feat: Kalenderfarbe (calendar-color) automatisch setzen
This commit is contained in:
@@ -2,8 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -270,3 +272,156 @@ func (bev birthdayEvent) generateICAL(calendar string, notificationEnabled bool,
|
||||
cal.Children = append(cal.Children, event)
|
||||
return fmt.Sprintf("%s/%s.ics", calendar, id), cal
|
||||
}
|
||||
|
||||
// XML-Strukturen für das Parsen der PROPFIND-Antwort (calendar-color).
|
||||
type multistatusResponse struct {
|
||||
XMLName xml.Name `xml:"DAV: multistatus"`
|
||||
Responses []davResponse `xml:"DAV: response"`
|
||||
}
|
||||
|
||||
type davResponse struct {
|
||||
Href string `xml:"DAV: href"`
|
||||
PropStats []davPropStat `xml:"DAV: propstat"`
|
||||
}
|
||||
|
||||
type davPropStat struct {
|
||||
Prop davProp `xml:"DAV: prop"`
|
||||
Status string `xml:"DAV: status"`
|
||||
}
|
||||
|
||||
type davProp struct {
|
||||
CalendarColor string `xml:"http://apple.com/ns/ical/ calendar-color"`
|
||||
}
|
||||
|
||||
// normalizeColor bringt eine Kalenderfarbe in ein einheitliches Format (#rrggbb,
|
||||
// Kleinbuchstaben, ohne Alpha-Kanal), damit zuverlässig verglichen werden kann.
|
||||
func normalizeColor(c string) string {
|
||||
c = strings.TrimSpace(strings.ToLower(c))
|
||||
if len(c) == 9 && strings.HasPrefix(c, "#") {
|
||||
c = c[:7]
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// getCalendarColor liest die calendar-color-Property (Apple-Namespace) des
|
||||
// Geburtstagskalenders per PROPFIND aus und gibt sie normalisiert zurück.
|
||||
func (d *Daemon) getCalendarColor(ctx context.Context, httpClient webdav.HTTPClient, user string) (string, error) {
|
||||
calendarURL, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar", d.calendarName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:A="http://apple.com/ns/ical/">
|
||||
<d:prop>
|
||||
<A:calendar-color/>
|
||||
</d:prop>
|
||||
</d:propfind>`
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", calendarURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "0")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusMultiStatus {
|
||||
return "", fmt.Errorf("PROPFIND calendar-color returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var ms multistatusResponse
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
|
||||
return "", fmt.Errorf("error parsing PROPFIND response: %w", err)
|
||||
}
|
||||
|
||||
for _, r := range ms.Responses {
|
||||
for _, ps := range r.PropStats {
|
||||
if ps.Prop.CalendarColor != "" {
|
||||
return normalizeColor(ps.Prop.CalendarColor), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// setCalendarColor setzt die calendar-color-Property (Apple-Namespace) des
|
||||
// Geburtstagskalenders per PROPPATCH. Die Farbe wird mit Alpha-Kanal (#RRGGBBFF)
|
||||
// übertragen, da SOGo dieses Format erwartet.
|
||||
func (d *Daemon) setCalendarColor(ctx context.Context, httpClient webdav.HTTPClient, user, color string) error {
|
||||
calendarURL, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar", d.calendarName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
colorValue := strings.ToUpper(color)
|
||||
if len(colorValue) == 7 {
|
||||
colorValue += "FF"
|
||||
}
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:A="http://apple.com/ns/ical/">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<A:calendar-color>%s</A:calendar-color>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</d:propertyupdate>`, colorValue)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPPATCH", calendarURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("PROPPATCH calendar-color returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureCalendarColor stellt sicher, dass der Geburtstagskalender die
|
||||
// konfigurierte Farbe hat, sofern der Benutzer sie nicht manuell geändert hat.
|
||||
// Die Erkennung manueller Änderungen basiert auf dem Vergleich zwischen der
|
||||
// aktuellen Kalenderfarbe und der zuletzt vom Daemon gesetzten Farbe (aus dem
|
||||
// State-File). Stimmen diese nicht überein, wurde die Farbe vom Benutzer
|
||||
// angepasst und wird nicht überschrieben.
|
||||
func (d *Daemon) ensureCalendarColor(ctx context.Context, httpClient webdav.HTTPClient, user string) error {
|
||||
if d.calendarColor == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
current, err := d.getCalendarColor(ctx, httpClient, user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading calendar color: %w", err)
|
||||
}
|
||||
|
||||
desired := normalizeColor(d.calendarColor)
|
||||
stored := normalizeColor(d.storedCalendarColor)
|
||||
|
||||
if current == desired {
|
||||
slog.DebugContext(ctx, "calendar color already correct", "user", user, "color", desired)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wenn der Benutzer die Farbe manuell geändert hat (current != stored && current != ""),
|
||||
// wird die Farbe nicht überschrieben.
|
||||
if current != "" && stored != "" && current != stored {
|
||||
slog.DebugContext(ctx, "calendar color was customized by user, skipping",
|
||||
"user", user, "current", current, "daemon", stored)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := d.setCalendarColor(ctx, httpClient, user, desired); err != nil {
|
||||
return fmt.Errorf("error setting calendar color: %w", err)
|
||||
}
|
||||
slog.InfoContext(ctx, "set calendar color", "user", user, "color", desired)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ type Daemon struct {
|
||||
stateUnsaved bool
|
||||
calendarName string
|
||||
oldCalendarName string
|
||||
calendarColor string
|
||||
storedCalendarColor string
|
||||
notificationEnabled bool
|
||||
notificationTrigger string
|
||||
eventYears int
|
||||
@@ -121,6 +123,10 @@ func run() error {
|
||||
if calendarName == "" {
|
||||
calendarName = "Birthdays"
|
||||
}
|
||||
calendarColor := os.Getenv("CALENDAR_COLOR")
|
||||
if calendarColor == "" {
|
||||
calendarColor = "#D01818"
|
||||
}
|
||||
notificationEnabled := strings.EqualFold(os.Getenv("NOTIFICATION_ENABLED"), "true")
|
||||
eventYears, err := parseEventYears()
|
||||
if err != nil {
|
||||
@@ -135,6 +141,7 @@ func run() error {
|
||||
slog.Debug("configuration summary",
|
||||
"MAILCOW_BASE", mailcowBase,
|
||||
"CALENDAR_NAME", calendarName,
|
||||
"CALENDAR_COLOR", calendarColor,
|
||||
"NOTIFICATION_ENABLED", notificationEnabled,
|
||||
"EVENT_YEARS", eventYears,
|
||||
"MAILCOW_RESOLVE_HOST", os.Getenv("MAILCOW_RESOLVE_HOST"),
|
||||
@@ -172,6 +179,7 @@ func run() error {
|
||||
stateFilepath: os.Getenv("STATEFILE"),
|
||||
httpClient: &http.Client{Transport: buildTransport()},
|
||||
calendarName: calendarName,
|
||||
calendarColor: calendarColor,
|
||||
notificationEnabled: notificationEnabled,
|
||||
notificationTrigger: notificationTrigger,
|
||||
eventYears: eventYears,
|
||||
@@ -307,6 +315,9 @@ func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error {
|
||||
if err := d.ensureBirthdayCal(ctx, davclient, m.Username); err != nil {
|
||||
return fmt.Errorf("error creating birthday calendar in caldav: %w", err)
|
||||
}
|
||||
if err := d.ensureCalendarColor(ctx, davclient, m.Username); err != nil {
|
||||
slog.WarnContext(ctx, "error setting calendar color", "user", m.Username, "err", err)
|
||||
}
|
||||
if err := d.syncBirthdaysToCal(ctx, davclient, m.Username, bb); err != nil {
|
||||
return fmt.Errorf("error syncing birthday events to caldav: %w", err)
|
||||
}
|
||||
|
||||
@@ -47,9 +47,10 @@ func (d *Daemon) loadState() error {
|
||||
case 2:
|
||||
slog.Debug("loading current state format", "stateVer", stateVer.Version)
|
||||
state := struct {
|
||||
Version int `json:"version"`
|
||||
UserTokens map[string]string `json:"userTokens"`
|
||||
CalendarName string `json:"calendarName"`
|
||||
Version int `json:"version"`
|
||||
UserTokens map[string]string `json:"userTokens"`
|
||||
CalendarName string `json:"calendarName"`
|
||||
CalendarColor string `json:"calendarColor,omitempty"`
|
||||
}{}
|
||||
if err := d.loadFromDisk(&state); err != nil {
|
||||
return fmt.Errorf("cant load state v%d: %w", stateVer.Version, err)
|
||||
@@ -62,6 +63,7 @@ func (d *Daemon) loadState() error {
|
||||
d.userTokens[k] = string(dec)
|
||||
}
|
||||
storedCalendarName = state.CalendarName
|
||||
d.storedCalendarColor = state.CalendarColor
|
||||
}
|
||||
slog.Info("state loaded", "users", len(d.userTokens))
|
||||
if storedCalendarName != "" && storedCalendarName != d.calendarName {
|
||||
@@ -70,6 +72,13 @@ func (d *Daemon) loadState() error {
|
||||
d.oldCalendarName = storedCalendarName
|
||||
d.stateUnsaved = true
|
||||
}
|
||||
if normalizeColor(d.storedCalendarColor) != normalizeColor(d.calendarColor) {
|
||||
if d.storedCalendarColor != "" {
|
||||
slog.Info("calendar color configuration changed",
|
||||
"old", d.storedCalendarColor, "new", d.calendarColor)
|
||||
}
|
||||
d.stateUnsaved = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -80,13 +89,15 @@ func (d *Daemon) saveState() error {
|
||||
encTokens[k] = base64.StdEncoding.EncodeToString([]byte(v))
|
||||
}
|
||||
state := struct {
|
||||
Version int `json:"version"`
|
||||
UserTokens map[string]string `json:"userTokens"`
|
||||
CalendarName string `json:"calendarName"`
|
||||
Version int `json:"version"`
|
||||
UserTokens map[string]string `json:"userTokens"`
|
||||
CalendarName string `json:"calendarName"`
|
||||
CalendarColor string `json:"calendarColor,omitempty"`
|
||||
}{
|
||||
Version: 2,
|
||||
UserTokens: encTokens,
|
||||
CalendarName: d.calendarName,
|
||||
Version: 2,
|
||||
UserTokens: encTokens,
|
||||
CalendarName: d.calendarName,
|
||||
CalendarColor: d.calendarColor,
|
||||
}
|
||||
if err := d.saveToDisk(state); err != nil {
|
||||
return err
|
||||
|
||||
@@ -33,6 +33,13 @@ Die Variable kann jederzeit – auch bei bestehenden Installationen – per Upda
|
||||
|
||||
> **Wichtig (betrifft nur Updates von vor v0.2.0):** Damit die Umbenennung korrekt erkannt wird, muss der Daemon **mindestens einmal** mit dem neuen Code (ab v0.2.0) und dem **alten** Kalendernamen gelaufen sein, damit der Name im State-File gespeichert wird. Erst danach `CALENDAR_NAME` ändern und erneut starten. Wird der Name geändert, bevor der State aktualisiert wurde, kann der alte Kalender nicht automatisch entfernt werden. In diesem Fall kann der integrierte Cleanup-Befehl verwendet werden (siehe [Troubleshooting](troubleshooting.md#doppelte-kalender-nach-umbenennung-von-calendar_name)).
|
||||
|
||||
## Kalenderfarbe
|
||||
|
||||
- Der Daemon setzt automatisch die CalDAV-Property `calendar-color` (Apple-Namespace) auf dem Geburtstagskalender. Dadurch hebt sich der Kalender im Client farblich sofort ab.
|
||||
- Die Farbe ist über `CALENDAR_COLOR` konfigurierbar (Standard: `#D01818`). Das Format ist `#RRGGBB`.
|
||||
- Hat ein Benutzer die Farbe seines Kalenders manuell geändert (z. B. in SOGo oder einem anderen Client), wird diese Anpassung respektiert und nicht überschrieben.
|
||||
- Bei einer Änderung von `CALENDAR_COLOR` werden alle Kalender aktualisiert, deren Farbe nicht manuell angepasst wurde.
|
||||
|
||||
## Benachrichtigungen
|
||||
|
||||
- Wenn `NOTIFICATION_ENABLED=true` gesetzt ist, erhält jedes Geburtstags-Event einen **VALARM** (iCal-Alarm). Kalender-Clients (SOGo, iOS, Android, Thunderbird) zeigen dann zur konfigurierten Uhrzeit eine Benachrichtigung an. Das Event bleibt weiterhin ein Ganztags-Event.
|
||||
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
- MAILCOW_BASE=https://mail.example.com
|
||||
- MAILCOW_APIKEY=DEIN-APIKEY-HIER
|
||||
- CALENDAR_NAME=Birthdays
|
||||
# - CALENDAR_COLOR=#D01818
|
||||
# - MAILCOW_RESOLVE_HOST=nginx-mailcow
|
||||
# - NOTIFICATION_ENABLED=true
|
||||
# - NOTIFICATION_TIME=08:00
|
||||
@@ -63,6 +64,7 @@ docker compose pull birthdaydaemon && docker compose up -d --no-deps birthdaydae
|
||||
| `MAILCOW_APIKEY` | **Ja** | – | API-Key mit Lese-/Schreibzugriff aus dem Mailcow-Admin-Panel |
|
||||
| `MAILCOW_RESOLVE_HOST` | Nein | – | Interner Hostname für TCP-Verbindungen (z. B. `nginx-mailcow`). Löst Hairpin-NAT-Probleme in Docker-Netzen. TLS nutzt weiterhin den Hostnamen aus `MAILCOW_BASE`. |
|
||||
| `CALENDAR_NAME` | Nein | `Birthdays` | Name des Geburtstagskalenders, der in jeder Mailbox erstellt wird |
|
||||
| `CALENDAR_COLOR` | Nein | `#D01818` | Farbe des Geburtstagskalenders im Hex-Format (z. B. `#FF6600`). Wird nur gesetzt, wenn der Benutzer die Farbe nicht manuell geändert hat. |
|
||||
| `NOTIFICATION_ENABLED` | Nein | `false` | Aktiviert Kalender-Benachrichtigungen (VALARM) für Geburtstags-Events (`true`/`false`) |
|
||||
| `NOTIFICATION_TIME` | Nein | `08:00` | Uhrzeit der Benachrichtigung im Format `HH:MM` (nur wirksam wenn `NOTIFICATION_ENABLED=true`) |
|
||||
| `STATEFILE` | Nein | `state.json` (im Container: `/data/state.json`) | Pfad zur Zustandsdatei, in der App-Passwörter und der aktuelle Kalendername gespeichert werden |
|
||||
|
||||
Reference in New Issue
Block a user