feat: Optionale Kalender-Benachrichtigungen für Geburtstage

This commit is contained in:
2026-03-28 22:06:49 +01:00
parent eb72cae10c
commit 772eaba37e
5 changed files with 100 additions and 19 deletions

View File

@@ -133,7 +133,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
matchedBev := false
for _, v := range ev.Data.Children {
for i, bev := range bevs {
if icalMatchesBev(v, bev) {
if icalMatchesBev(v, bev, d.notificationEnabled) {
bevsInSync = append(bevsInSync, i)
matchedBev = true
}
@@ -154,7 +154,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
if slices.Contains(bevsInSync, i) {
continue
}
p, ic := v.generateICAL(calendarPath)
p, ic := v.generateICAL(calendarPath, d.notificationEnabled, d.notificationTrigger)
_, err := cl.PutCalendarObject(ctx, p, ic)
if err != nil {
return err
@@ -193,7 +193,7 @@ func generateBirthdayEvents(birthdays []BirthdayContact) []birthdayEvent {
return bb
}
func icalMatchesBev(ic *ical.Component, bev birthdayEvent) bool {
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
}
@@ -211,10 +211,20 @@ func icalMatchesBev(ic *ical.Component, bev birthdayEvent) bool {
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) (string, *ical.Calendar) {
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)
@@ -231,6 +241,16 @@ func (bev birthdayEvent) generateICAL(calendar string) (string, *ical.Calendar)
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
}

View File

@@ -26,15 +26,17 @@ var (
)
type Daemon struct {
httpClient *http.Client
baseURL string
mailcowClient mailcow.Client
userTokens map[string]string
userTokensLock *sync.RWMutex
stateFilepath string
stateUnsaved bool
calendarName string
oldCalendarName string
httpClient *http.Client
baseURL string
mailcowClient mailcow.Client
userTokens map[string]string
userTokensLock *sync.RWMutex
stateFilepath string
stateUnsaved bool
calendarName string
oldCalendarName string
notificationEnabled bool
notificationTrigger string
}
func main() {
@@ -65,13 +67,29 @@ func run() error {
if calendarName == "" {
calendarName = "Birthdays"
}
notificationEnabled := strings.EqualFold(os.Getenv("NOTIFICATION_ENABLED"), "true")
notificationTrigger := "PT8H"
if notificationEnabled {
notificationTime := os.Getenv("NOTIFICATION_TIME")
if notificationTime == "" {
notificationTime = "08:00"
}
trigger, err := parseNotificationTrigger(notificationTime)
if err != nil {
return fmt.Errorf("invalid NOTIFICATION_TIME: %w", err)
}
notificationTrigger = trigger
slog.Info("birthday notifications enabled", "time", notificationTime, "trigger", notificationTrigger)
}
d := &Daemon{
userTokens: make(map[string]string),
userTokensLock: &sync.RWMutex{},
baseURL: mailcowBase,
stateFilepath: os.Getenv("STATEFILE"),
httpClient: &http.Client{Transport: buildTransport()},
calendarName: calendarName,
userTokens: make(map[string]string),
userTokensLock: &sync.RWMutex{},
baseURL: mailcowBase,
stateFilepath: os.Getenv("STATEFILE"),
httpClient: &http.Client{Transport: buildTransport()},
calendarName: calendarName,
notificationEnabled: notificationEnabled,
notificationTrigger: notificationTrigger,
}
if len(d.stateFilepath) == 0 {
d.stateFilepath = "state.json"

View File

@@ -65,3 +65,32 @@ func sanitizeBirthday(input string) (uint16, uint16, uint16, error) {
}
return 0, 0, 0, fmt.Errorf("birthday prop format unknown: %s", input)
}
// parseNotificationTrigger konvertiert eine Uhrzeit im Format "HH:MM" in eine
// iCal-Duration (z.B. "PT8H", "PT9H30M"), die als VALARM-Trigger relativ zum
// Start eines Ganztags-Events (Mitternacht) verwendet wird.
func parseNotificationTrigger(timeStr string) (string, error) {
parts := strings.Split(timeStr, ":")
if len(parts) != 2 {
return "", fmt.Errorf("invalid time format: %s (expected HH:MM)", timeStr)
}
hours, err := strconv.Atoi(parts[0])
if err != nil || hours < 0 || hours > 23 {
return "", fmt.Errorf("invalid hours in time: %s", timeStr)
}
minutes, err := strconv.Atoi(parts[1])
if err != nil || minutes < 0 || minutes > 59 {
return "", fmt.Errorf("invalid minutes in time: %s", timeStr)
}
if hours == 0 && minutes == 0 {
return "PT0S", nil
}
trigger := "PT"
if hours > 0 {
trigger += fmt.Sprintf("%dH", hours)
}
if minutes > 0 {
trigger += fmt.Sprintf("%dM", minutes)
}
return trigger, nil
}