Files
scriptos 93371e3afa
Build Test Docker Image / docker-test (pull_request) Successful in 1m32s
Run Tests / test (pull_request) Successful in 4m53s
feat: Kalenderfarbe (calendar-color) automatisch setzen
2026-03-29 23:06:09 +02:00

428 lines
13 KiB
Go

package main
import (
"context"
"encoding/xml"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/google/uuid"
)
const (
ConstProductID = "-//scriptos//MailcowBirthdayDaemon//EN"
)
// cleanupOldCalendar prüft, ob ein alter Daemon-Kalender existiert, und löscht
// ihn nur, wenn alle enthaltenen Events die Daemon-PRODID tragen. Enthält der
// Kalender fremde Events, wird er nicht gelöscht und eine Warnung geloggt.
func (d *Daemon) cleanupOldCalendar(ctx context.Context, httpClient webdav.HTTPClient, user, oldName string) error {
slog.DebugContext(ctx, "checking for old calendar to clean up", "user", user, "oldName", oldName)
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/")
if err != nil {
return err
}
cl, err := caldav.NewClient(httpClient, endpoint)
if err != nil {
return err
}
cc, err := cl.FindCalendars(ctx, "")
if err != nil {
return err
}
slog.DebugContext(ctx, "found calendars", "user", user, "count", len(cc))
found := false
for _, c := range cc {
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", oldName)) {
found = true
break
}
}
if !found {
slog.DebugContext(ctx, "old calendar not found, nothing to clean up", "user", user, "oldName", oldName)
return nil
}
slog.DebugContext(ctx, "old calendar found, checking events", "user", user, "oldName", oldName)
calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, oldName)
events, err := cl.QueryCalendar(ctx, calendarPath, &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
},
CompFilter: caldav.CompFilter{
Name: "VCALENDAR",
Comps: []caldav.CompFilter{{
Name: "VEVENT",
}},
},
})
if err != nil {
return err
}
for _, ev := range events {
prodID := ev.Data.Props.Get(ical.PropProductID)
if prodID == nil || prodID.Value != ConstProductID {
slog.WarnContext(ctx, "old calendar contains foreign events, skipping cleanup",
"user", user, "calendar", oldName)
return nil
}
}
if err := cl.RemoveAll(ctx, calendarPath); err != nil {
return fmt.Errorf("error removing old calendar: %w", err)
}
slog.InfoContext(ctx, "removed old birthday calendar", "user", user, "calendar", oldName)
return nil
}
func (d *Daemon) ensureBirthdayCal(ctx context.Context, httpClient webdav.HTTPClient, user string) error {
slog.DebugContext(ctx, "ensuring birthday calendar exists", "user", user, "calendar", d.calendarName)
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/")
if err != nil {
return err
}
cl, err := caldav.NewClient(httpClient, endpoint)
if err != nil {
return err
}
cc, err := cl.FindCalendars(ctx, "")
if err != nil {
return err
}
for _, c := range cc {
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", d.calendarName)) {
slog.DebugContext(ctx, "birthday calendar already exists", "user", user)
return nil
}
}
if err := cl.Mkdir(ctx, d.calendarName); err != nil {
return err
}
slog.InfoContext(ctx, "created birthday calendar", "user", user, "calendar", d.calendarName)
return nil
}
func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPClient, user string, birthdays []BirthdayContact) error {
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/")
if err != nil {
return err
}
cl, err := caldav.NewClient(httpClient, endpoint)
if err != nil {
return err
}
calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, d.calendarName)
events, err := cl.QueryCalendar(ctx, calendarPath, &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
},
CompFilter: caldav.CompFilter{
Name: "VCALENDAR",
Comps: []caldav.CompFilter{{
Name: "VEVENT",
}},
Start: time.Now().Add(time.Hour * 24 * 365 * -1).UTC(),
End: time.Now().Add(time.Hour * 24 * 365 * 100).UTC(),
},
})
if err != nil {
return err
}
slog.DebugContext(ctx, "queried existing calendar events", "user", user, "count", len(events))
bevs := generateBirthdayEvents(birthdays, d.eventYears)
slog.DebugContext(ctx, "generated birthday events", "user", user, "count", len(bevs))
bevsInSync := make([]int, 0)
driftedEvents := make([]string, 0)
for _, ev := range events {
matchedBev := false
for _, v := range ev.Data.Children {
for i, bev := range bevs {
if icalMatchesBev(v, bev, d.notificationEnabled) {
bevsInSync = append(bevsInSync, i)
matchedBev = true
}
}
}
if !matchedBev {
driftedEvents = append(driftedEvents, ev.Path)
}
}
counterDelete, counterAdded := 0, 0
for _, v := range driftedEvents {
slog.DebugContext(ctx, "removing drifted calendar event", "user", user, "path", v)
if err := cl.RemoveAll(ctx, v); err != nil {
return err
}
counterDelete++
}
for i, v := range bevs {
if slices.Contains(bevsInSync, i) {
continue
}
slog.DebugContext(ctx, "adding calendar event", "user", user, "summary", v.Summary, "start", v.DateTimeStart)
p, ic := v.generateICAL(calendarPath, d.notificationEnabled, d.notificationTrigger)
_, err := cl.PutCalendarObject(ctx, p, ic)
if err != nil {
return err
}
counterAdded++
}
if (counterAdded + counterDelete) > 0 {
slog.InfoContext(ctx, "synchronized birthday events", "user", user, "added", counterAdded, "removed", counterDelete)
} else {
slog.DebugContext(ctx, "birthday events already in sync", "user", user, "total", len(bevs))
}
return nil
}
type birthdayEvent struct {
Summary string
DateTimeStart string
DateTimeEnd string
}
func generateBirthdayEvents(birthdays []BirthdayContact, eventYears int) []birthdayEvent {
cyear := time.Now().Year()
bb := make([]birthdayEvent, 0)
for _, v := range birthdays {
for year := cyear; year <= eventYears+cyear; year++ {
yearshift := year - v.Date.Year()
prefix := "\U0001F382 " // 🎂
if v.Type == ContactTypeAnniversary {
prefix = "\U0001F48D " // 💍
}
ev := birthdayEvent{
Summary: fmt.Sprintf("%s%s %s", prefix, v.GivenName, v.FamilyName),
DateTimeStart: v.Date.AddDate(yearshift, 0, 0).Format("20060102"),
DateTimeEnd: v.Date.AddDate(yearshift, 0, 1).Format("20060102"),
}
if v.YearKnown {
ev.Summary = fmt.Sprintf("%s (%d)", ev.Summary, yearshift)
}
bb = append(bb, ev)
}
}
return bb
}
func icalMatchesBev(ic *ical.Component, bev birthdayEvent, notificationEnabled bool) bool {
if ic.Props.Get(ical.PropSummary) == nil || ic.Props.Get(ical.PropSummary).Value != bev.Summary {
return false
}
dtStart := ic.Props.Get(ical.PropDateTimeStart)
if dtStart == nil || dtStart.Value != bev.DateTimeStart {
return false
}
if dtStart.Params.Get(ical.ParamValue) != string(ical.ValueDate) {
return false
}
dtEnd := ic.Props.Get(ical.PropDateTimeEnd)
if dtEnd == nil || dtEnd.Value != bev.DateTimeEnd {
return false
}
if dtEnd.Params.Get(ical.ParamValue) != string(ical.ValueDate) {
return false
}
hasAlarm := false
for _, child := range ic.Children {
if child.Name == ical.CompAlarm {
hasAlarm = true
break
}
}
if notificationEnabled != hasAlarm {
return false
}
return true
}
func (bev birthdayEvent) generateICAL(calendar string, notificationEnabled bool, notificationTrigger string) (string, *ical.Calendar) {
id := uuid.New().String()
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropProductID, ConstProductID)
cal.Props.SetText(ical.PropVersion, "2.0")
event := ical.NewComponent(ical.CompEvent)
event.Props.SetText(ical.PropUID, id)
event.Props.SetText(ical.PropSummary, bev.Summary)
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now())
start := ical.NewProp(ical.PropDateTimeStart)
start.SetValueType(ical.ValueDate)
start.Value = bev.DateTimeStart
end := ical.NewProp(ical.PropDateTimeEnd)
end.SetValueType(ical.ValueDate)
end.Value = bev.DateTimeEnd
event.Props.Set(start)
event.Props.Set(end)
if notificationEnabled {
alarm := ical.NewComponent(ical.CompAlarm)
alarm.Props.SetText(ical.PropAction, "DISPLAY")
alarm.Props.SetText(ical.PropDescription, bev.Summary)
trigger := ical.NewProp(ical.PropTrigger)
trigger.Params.Set(ical.ParamValue, string(ical.ValueDuration))
trigger.Value = notificationTrigger
alarm.Props.Set(trigger)
event.Children = append(event.Children, alarm)
}
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
}