Add calendar color support (DAVx5 calendar-color)

Calendars can now have a color (hex, e.g. #3b82f6) that DAVx5 and other
CalDAV clients pick up via the Apple/dav4jvm calendar-color property.

- db: add calendars.color column with migration for existing DBs;
  CreateCalendarWithColor, SetCalendarColor, GetCalendarColor;
  ListCalendars now returns []Calendar{Name, Color} instead of []string
- caldav: since go-webdav's caldav.Backend interface has no extension
  point for vendor properties, wrap the handler with a response-rewriting
  middleware that injects <calendar-color xmlns="http://apple.com/ns/ical/">
  into PROPFIND responses for calendars that have a color set
- web: color picker on the "New calendar" form and an inline color swatch/
  picker on each calendar card (calendars only, not address books)
- nidusctl: `calendar create --color` flag and a new `calendar color`
  subcommand; `calendar list` now also prints the color if set

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 06:43:56 +02:00
co-authored by Copilot
parent 37399d1ceb
commit ebbc7a2a2b
15 changed files with 618 additions and 163 deletions
+142 -7
View File
@@ -1,11 +1,16 @@
package caldav
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path"
"regexp"
"strconv"
"strings"
"time"
@@ -40,9 +45,15 @@ func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.
}
// NewHandler returns an http.Handler for the /cal/ prefix.
//
// It wraps the go-webdav caldav.Handler with a small response-rewriting
// middleware that injects the non-standard (Apple/DAVx5) calendar-color
// property into PROPFIND responses for calendar collections, since
// go-webdav's caldav.Backend interface has no extension point for
// vendor-specific WebDAV properties.
func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler {
b := NewBackend(cfg, st, dbase, logger)
return &caldav.Handler{Backend: b}
return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}}
}
// -------- caldav.Backend interface --------
@@ -79,19 +90,19 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
}
var cals []caldav.Calendar
for _, name := range names {
if err := b.store.EnsureCollection(p.Username, "cal-"+name); err != nil {
b.logger.Warn("ensuring calendar directory", "calendar", name, "error", err)
for _, cal := range names {
if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil {
b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err)
continue
}
cals = append(cals, b.calendarMeta(p.Username, name, name))
cals = append(cals, b.calendarMeta(p.Username, cal.Name, cal.Name))
}
// Also include any extra calendars that exist on disk but aren't registered
disk, _ := b.store.ListCollections(p.Username)
configured := make(map[string]bool)
for _, n := range names {
configured["cal-"+n] = true
for _, cal := range names {
configured["cal-"+cal.Name] = true
}
for _, dir := range disk {
if strings.HasPrefix(dir, "cal-") && !configured[dir] {
@@ -386,3 +397,127 @@ func hashBytes(data []byte) uint64 {
}
return h
}
// -------- calendar-color PROPFIND injection --------
// calCollectionPathRe matches a calendar collection's own path (e.g.
// "/cal/home/work/"), as opposed to a calendar object inside it (e.g.
// "/cal/home/work/abc123.ics") or the home-set/principal path.
var calCollectionPathRe = regexp.MustCompile(`^/cal/home/[^/]+/$`)
// colorInjectingHandler wraps a caldav.Handler and post-processes PROPFIND
// responses to add the non-standard `calendar-color` property (in Apple's
// "http://apple.com/ns/ical/" namespace) that DAVx5 and other clients read
// to color-code synced calendars. go-webdav's caldav.Backend interface has
// no extension point for vendor properties like this, so the response XML
// is rewritten after the fact instead.
type colorInjectingHandler struct {
backend *Backend
next http.Handler
}
func (h *colorInjectingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != "PROPFIND" || h.backend.dbase == nil {
h.next.ServeHTTP(w, r)
return
}
// Only bother rewriting if the client asked for calendar-color
// specifically, or for all properties (propname/allprop requests, or
// no body at all, are treated as "all properties" by most clients).
var reqBody []byte
if r.Body != nil {
reqBody, _ = readAllAndReset(&r.Body)
}
wantsColor := len(reqBody) == 0 || bytes.Contains(reqBody, []byte("calendar-color")) || bytes.Contains(reqBody, []byte("allprop"))
if !wantsColor {
h.next.ServeHTTP(w, r)
return
}
rec := httptest.NewRecorder()
h.next.ServeHTTP(rec, r)
for k, vs := range rec.Header() {
for _, v := range vs {
w.Header().Add(k, v)
}
}
body := rec.Body.Bytes()
if rec.Code == http.StatusMultiStatus && strings.Contains(rec.Header().Get("Content-Type"), "xml") {
body = h.injectCalendarColors(r.Context(), body)
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
}
w.WriteHeader(rec.Code)
_, _ = w.Write(body)
}
// responseBlockRe matches a single <response>...</response> element
// (non-greedy) inside a multistatus document, capturing its href.
var responseBlockRe = regexp.MustCompile(`(?s)<response[^>]*>.*?<href[^>]*>([^<]*)</href>.*?</response>`)
// injectCalendarColors scans a multistatus PROPFIND response body and, for
// each <response> whose href is a calendar collection with a color set,
// inserts a <apple:calendar-color xmlns:apple="http://apple.com/ns/ical/">
// element into its first 200 OK <prop>.
func (h *colorInjectingHandler) injectCalendarColors(ctx context.Context, body []byte) []byte {
p := auth.FromContext(ctx)
if p == nil {
return body
}
return responseBlockRe.ReplaceAllFunc(body, func(block []byte) []byte {
m := responseBlockRe.FindSubmatch(block)
if m == nil {
return block
}
href := string(m[1])
if !calCollectionPathRe.MatchString(href) {
return block
}
localName := strings.TrimSuffix(strings.TrimPrefix(href, calHomePath()), "/")
owner, realName, _, err := h.backend.resolveCalendar(p.Username, localName, false)
if err != nil {
return block
}
color, err := h.backend.dbase.GetCalendarColor(owner, realName)
if err != nil || color == "" {
return block
}
propEl := []byte(`<prop xmlns="DAV:">`)
idx := bytes.Index(block, propEl)
if idx < 0 {
return block
}
insertAt := idx + len(propEl)
colorEl := []byte(fmt.Sprintf(`<calendar-color xmlns="http://apple.com/ns/ical/">%s</calendar-color>`, xmlEscapeColor(color)))
out := make([]byte, 0, len(block)+len(colorEl))
out = append(out, block[:insertAt]...)
out = append(out, colorEl...)
out = append(out, block[insertAt:]...)
return out
})
}
// xmlEscapeColor returns color formatted as an 8-digit ARGB/RGBA hex value
// (as Apple's calendar-color property expects), padding a plain 6-digit
// "#RRGGBB" (as produced by an HTML <input type="color">) with a fully
// opaque alpha channel.
func xmlEscapeColor(color string) string {
if len(color) == 7 && color[0] == '#' {
return color + "FF"
}
return color
}
// readAllAndReset reads body fully and replaces it with a fresh reader over
// the same bytes, so downstream handlers can still consume it.
func readAllAndReset(body *io.ReadCloser) ([]byte, error) {
data, err := io.ReadAll(*body)
if err != nil {
return nil, err
}
*body = io.NopCloser(bytes.NewReader(data))
return data, nil
}