Files
Aether/backend/internal/calendar/color.go
T
marcandClaude Opus 5 0c9cc69493 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>
2026-09-04 01:16:06 +02:00

112 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package calendar
import (
"fmt"
"strconv"
"strings"
)
// Terminfarben und die Synchronisierung.
//
// RFC 7986 schreibt für COLOR einen **CSS3-Farbnamen** vor ("tomato"), keinen
// Hex-Wert. Genau daran hängt, ob die Farbe eines Termins auf dem Telefon
// ankommt: DAVx5 liest die Eigenschaft über eine Namenstabelle und wirft
// alles weg, was es dort nicht findet – ein "#ea4335" landet also nirgends,
// und im Samsung-Kalender bliebe der Termin farblos.
//
// Deshalb zwei Eigenschaften nebeneinander:
//
// COLOR:tomato – der Standard, damit fremde Clients es verstehen
// X-AETHER-COLOR:#ea4335 – der genaue Wert, damit *hier* die Palette stimmt
//
// Beim Lesen gewinnt X-AETHER-COLOR, wenn vorhanden; sonst wird der Name in
// Hex zurückübersetzt. So geht in beide Richtungen nichts verloren: Ein
// Termin, den das Telefon einfärbt, kommt hier mit seiner Farbe an, und
// unsere Palette bleibt auf dem Telefon erkennbar – wenn auch auf den
// nächstgelegenen benannten Ton gerundet.
const (
propColor = "COLOR"
propColorX = "X-AETHER-COLOR"
)
// css3Colors ist eine Auswahl aus der CSS3-Namensliste – über den Farbkreis
// verteilt, dazu die Töne, die Kalender-Clients üblicherweise selbst
// vergeben. Nicht die vollständige Liste (147 Namen): Für "nächstgelegener
// Ton" bringt jeder weitere Zwischenwert kaum etwas, und die Liste will
// gepflegt werden.
var css3Colors = map[string]string{
"black": "#000000", "dimgray": "#696969", "gray": "#808080",
"darkgray": "#a9a9a9", "silver": "#c0c0c0", "white": "#ffffff",
"maroon": "#800000", "brown": "#a52a2a", "firebrick": "#b22222",
"crimson": "#dc143c", "red": "#ff0000", "tomato": "#ff6347",
"coral": "#ff7f50", "orangered": "#ff4500", "darkorange": "#ff8c00",
"orange": "#ffa500", "goldenrod": "#daa520", "gold": "#ffd700",
"yellow": "#ffff00", "olive": "#808000", "yellowgreen": "#9acd32",
"darkolivegreen": "#556b2f", "lawngreen": "#7cfc00", "limegreen": "#32cd32",
"green": "#008000", "darkgreen": "#006400", "seagreen": "#2e8b57",
"mediumseagreen": "#3cb371", "teal": "#008080", "darkcyan": "#008b8b",
"lightseagreen": "#20b2aa", "turquoise": "#40e0d0", "cyan": "#00ffff",
"deepskyblue": "#00bfff", "steelblue": "#4682b4", "cornflowerblue": "#6495ed",
"royalblue": "#4169e1", "dodgerblue": "#1e90ff", "blue": "#0000ff",
"mediumblue": "#0000cd", "navy": "#000080", "slateblue": "#6a5acd",
"mediumpurple": "#9370db", "blueviolet": "#8a2be2", "darkviolet": "#9400d3",
"purple": "#800080", "orchid": "#da70d6", "magenta": "#ff00ff",
"mediumvioletred": "#c71585", "deeppink": "#ff1493", "hotpink": "#ff69b4",
"pink": "#ffc0cb", "slategray": "#708090", "lightslategray": "#778899",
"tan": "#d2b48c", "peru": "#cd853f", "sienna": "#a0522d",
}
func parseHex(s string) (r, g, b int, ok bool) {
s = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(s), "#"))
if len(s) == 3 { // Kurzform #abc
s = string([]byte{s[0], s[0], s[1], s[1], s[2], s[2]})
}
if len(s) == 8 { // mit Alpha – der Kanal interessiert hier nicht
s = s[:6]
}
if len(s) != 6 {
return 0, 0, 0, false
}
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, 0, 0, false
}
return int(v>>16) & 0xff, int(v>>8) & 0xff, int(v) & 0xff, true
}
// nearestCSS3 findet den nächstgelegenen benannten Ton. Der Abstand wird im
// RGB-Würfel gemessen – grob, aber für "welcher Name kommt dem am nächsten"
// vollkommen ausreichend, und ohne Farbmetrik-Bibliothek im Gepäck.
func nearestCSS3(hex string) string {
r, g, b, ok := parseHex(hex)
if !ok {
return ""
}
best, bestDist := "", 1<<30
for name, ref := range css3Colors {
rr, rg, rb, _ := parseHex(ref)
d := (r-rr)*(r-rr) + (g-rg)*(g-rg) + (b-rb)*(b-rb)
// Bei gleichem Abstand der alphabetisch erste Name – sonst wechselt
// die Ausgabe zwischen zwei Läufen, weil map-Reihenfolge zufällig ist.
if d < bestDist || (d == bestDist && name < best) {
best, bestDist = name, d
}
}
return best
}
// css3ToHex übersetzt zurück. Unbekannter Name: leer – der Termin erbt dann
// die Kalenderfarbe, was allemal besser ist als eine geratene.
func css3ToHex(name string) string {
return css3Colors[strings.ToLower(strings.TrimSpace(name))]
}
// normalizeHex bringt eine Farbe auf die Schreibweise "#rrggbb".
func normalizeHex(s string) string {
r, g, b, ok := parseHex(s)
if !ok {
return ""
}
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}