Browse Source

Add CalDAV server for iOS Calendar bidirectional sync (#256)

Add CalDAV server for iOS Calendar bidirectional sync (#256)
Alan Yeung 5 days ago
parent
commit
463c4429e3
7 changed files with 1334 additions and 1 deletions
  1. 86 0
      src/caldav_init.go
  2. 10 0
      src/main.router.go
  3. 579 0
      src/mod/caldav/caldav.go
  4. 295 0
      src/mod/caldav/caldav_test.go
  5. 346 0
      src/mod/caldav/ics.go
  6. 2 1
      src/startup.go
  7. 16 0
      src/web/Calendar/img/icon.svg

+ 86 - 0
src/caldav_init.go

@@ -0,0 +1,86 @@
+package main
+
+/*
+	caldav_init.go - CalDAV server initialisation
+
+	Registers the CalDAV handler at /caldav/ and a /.well-known/caldav
+	redirect so iOS can auto-discover the calendar service.
+
+	Also registers /api/caldav/credentials (authenticated) which returns
+	the CalDAV URL and auto-generates an auto-login token for the calling
+	user, giving them a ready-to-use password for iOS Calendar setup.
+*/
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"imuslab.com/arozos/mod/caldav"
+	"imuslab.com/arozos/mod/info/logger"
+	prout "imuslab.com/arozos/mod/prouter"
+	"imuslab.com/arozos/mod/utils"
+)
+
+// CalDAVHandler is the global CalDAV HTTP handler.
+var CalDAVHandler *caldav.Handler
+
+// CalDAVInit initialises the CalDAV server and registers API endpoints.
+// It must be called after AuthInit() and UserSystemInit().
+func CalDAVInit() {
+	CalDAVHandler = caldav.NewHandler(caldav.HandlerOptions{
+		AuthAgent:   authAgent,
+		UserHandler: userHandler,
+		Prefix:      "/caldav",
+	})
+
+	// Credentials helper – authenticated, non-admin endpoint so any logged-in
+	// user can retrieve their own CalDAV connection details.
+	router := prout.NewModuleRouter(prout.RouterOption{
+		ModuleName:  "Calendar",
+		AdminOnly:   false,
+		UserHandler: userHandler,
+		DeniedHandler: func(w http.ResponseWriter, r *http.Request) {
+			utils.SendErrorResponse(w, "Permission Denied")
+		},
+	})
+	router.HandleFunc("/api/caldav/credentials", handleCalDAVCredentials)
+
+	logger.PrintAndLog("CalDAV", "CalDAV service started at /caldav/", nil)
+}
+
+// handleCalDAVCredentials returns the CalDAV server URL, username and an
+// auto-login token the caller can use as a CalDAV password on iOS.
+func handleCalDAVCredentials(w http.ResponseWriter, r *http.Request) {
+	username, err := authAgent.GetUserName(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "Unable to get username")
+		return
+	}
+
+	// Re-use an existing token if available, otherwise mint a new one.
+	existing := authAgent.GetTokensFromUsername(username)
+	var token string
+	if len(existing) > 0 {
+		token = existing[0].Token
+	} else {
+		token = authAgent.NewAutologinToken(username)
+	}
+
+	scheme := "http"
+	if r.TLS != nil {
+		scheme = "https"
+	}
+	serverURL := scheme + "://" + r.Host + "/caldav/"
+
+	type credResponse struct {
+		ServerURL string `json:"serverURL"`
+		Username  string `json:"username"`
+		Token     string `json:"token"`
+	}
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(credResponse{
+		ServerURL: serverURL,
+		Username:  username,
+		Token:     token,
+	})
+}

+ 10 - 0
src/main.router.go

@@ -64,6 +64,16 @@ func mrouter(h http.Handler) http.Handler {
 			}
 			h.ServeHTTP(w, r)
 
+		} else if len(r.URL.Path) >= len("/caldav") && r.URL.Path[:7] == "/caldav" {
+			//CalDAV sub-router (bidirectional calendar sync for iOS)
+			if CalDAVHandler == nil {
+				errorHandleInternalServerError(w, r)
+				return
+			}
+			CalDAVHandler.ServeHTTP(w, r)
+		} else if r.URL.Path == "/.well-known/caldav" {
+			//CalDAV service discovery redirect (RFC 6764)
+			http.Redirect(w, r, "/caldav/", http.StatusMovedPermanently)
 		} else if len(r.URL.Path) >= len("/webdav") && r.URL.Path[:7] == "/webdav" {
 			//WebDAV sub-router
 			if WebDAVManager == nil {

+ 579 - 0
src/mod/caldav/caldav.go

@@ -0,0 +1,579 @@
+package caldav
+
+/*
+	caldav.go - CalDAV server for ArozOS Calendar
+
+	Implements a subset of RFC 4791 (CalDAV) sufficient for bidirectional
+	sync with iOS Calendar.  Events are stored in the same JSON file used
+	by the Calendar web-app (user:/Document/Calendar/events.json) so both
+	interfaces share the same data without any migration.
+
+	Authentication: HTTP Basic Auth where
+	  username = ArozOS username
+	  password = an ArozOS auto-login token for that user
+
+	URL layout:
+	  /caldav/                                  service root (principal discovery)
+	  /caldav/{username}/                       user principal
+	  /caldav/{username}/calendar/              calendar collection
+	  /caldav/{username}/calendar/{id}.ics      individual event resource
+*/
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+
+	"imuslab.com/arozos/mod/auth"
+	"imuslab.com/arozos/mod/info/logger"
+	"imuslab.com/arozos/mod/user"
+)
+
+// CalendarEvent mirrors the JSON schema used by the Calendar web-app.
+type CalendarEvent struct {
+	ID       string         `json:"id"`
+	Title    string         `json:"title"`
+	AllDay   bool           `json:"allDay"`
+	Start    int64          `json:"start"` // Unix milliseconds
+	End      int64          `json:"end"`   // Unix milliseconds
+	Address  string         `json:"address,omitempty"`
+	Notes    string         `json:"notes,omitempty"`
+	Reminder *EventReminder `json:"reminder,omitempty"`
+	Color    string         `json:"color,omitempty"`
+}
+
+// EventReminder matches the reminder sub-object in events.json.
+type EventReminder struct {
+	Value int    `json:"value"`
+	Unit  string `json:"unit"` // "mins" | "hours" | "days"
+}
+
+// Handler is the CalDAV HTTP handler.
+type Handler struct {
+	authAgent   *auth.AuthAgent
+	userHandler *user.UserHandler
+	prefix      string
+	mu          sync.Mutex // guards concurrent writes to events.json
+}
+
+// HandlerOptions holds the dependencies required to create a Handler.
+type HandlerOptions struct {
+	AuthAgent   *auth.AuthAgent
+	UserHandler *user.UserHandler
+	// Prefix is the HTTP path prefix, e.g. "/caldav".  Defaults to "/caldav".
+	Prefix string
+}
+
+// NewHandler constructs a CalDAV Handler.
+func NewHandler(opts HandlerOptions) *Handler {
+	prefix := strings.TrimRight(opts.Prefix, "/")
+	if prefix == "" {
+		prefix = "/caldav"
+	}
+	return &Handler{
+		authAgent:   opts.AuthAgent,
+		userHandler: opts.UserHandler,
+		prefix:      prefix,
+	}
+}
+
+// ServeHTTP implements http.Handler.
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	username, ok := h.authenticate(r)
+	if !ok {
+		w.Header().Set("WWW-Authenticate", `Basic realm="ArozOS CalDAV"`)
+		http.Error(w, "Unauthorized", http.StatusUnauthorized)
+		return
+	}
+
+	path := strings.TrimPrefix(r.URL.Path, h.prefix)
+	if path == "" {
+		path = "/"
+	}
+
+	switch r.Method {
+	case http.MethodOptions:
+		h.handleOptions(w)
+	case "PROPFIND":
+		h.handlePropfind(w, r, path, username)
+	case "REPORT":
+		h.handleReport(w, r, path, username)
+	case http.MethodGet, http.MethodHead:
+		h.handleGet(w, r, path, username)
+	case http.MethodPut:
+		h.handlePut(w, r, path, username)
+	case http.MethodDelete:
+		h.handleDelete(w, r, path, username)
+	default:
+		w.Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, GET, HEAD, PUT, DELETE")
+		http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
+	}
+}
+
+// authenticate validates HTTP Basic Auth credentials: username must match the
+// owner of the supplied auto-login token.
+func (h *Handler) authenticate(r *http.Request) (string, bool) {
+	username, password, ok := r.BasicAuth()
+	if !ok || password == "" {
+		return "", false
+	}
+	valid, tokenOwner := h.authAgent.ValidateAutoLoginToken(password)
+	if !valid || tokenOwner != username {
+		return "", false
+	}
+	return username, true
+}
+
+// ── OPTIONS ──────────────────────────────────────────────────────────────────
+
+func (h *Handler) handleOptions(w http.ResponseWriter) {
+	w.Header().Set("DAV", "1, 2, calendar-access")
+	w.Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, GET, HEAD, PUT, DELETE")
+	w.WriteHeader(http.StatusOK)
+}
+
+// ── PROPFIND ─────────────────────────────────────────────────────────────────
+
+func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request, path string, username string) {
+	depth := r.Header.Get("Depth")
+	if depth == "" {
+		depth = "0"
+	}
+	parts := splitURLPath(path)
+
+	// Consume the body (required even if unused, so the connection stays clean).
+	io.ReadAll(r.Body) //nolint
+
+	switch {
+	case len(parts) == 0:
+		h.propfindRoot(w, username)
+	case len(parts) == 1:
+		if parts[0] != username {
+			http.Error(w, "Not Found", http.StatusNotFound)
+			return
+		}
+		h.propfindPrincipal(w, username)
+	case len(parts) == 2 && parts[1] == "calendar":
+		h.propfindCalendar(w, username, depth)
+	default:
+		http.Error(w, "Not Found", http.StatusNotFound)
+	}
+}
+
+func (h *Handler) propfindRoot(w http.ResponseWriter, username string) {
+	principalHref := h.prefix + "/" + username + "/"
+	body := xmlHeader() +
+		`<D:multistatus xmlns:D="DAV:">` + "\n" +
+		`  <D:response>` + "\n" +
+		`    <D:href>` + h.prefix + `/</D:href>` + "\n" +
+		`    <D:propstat>` + "\n" +
+		`      <D:prop>` + "\n" +
+		`        <D:current-user-principal><D:href>` + principalHref + `</D:href></D:current-user-principal>` + "\n" +
+		`      </D:prop>` + "\n" +
+		`      <D:status>HTTP/1.1 200 OK</D:status>` + "\n" +
+		`    </D:propstat>` + "\n" +
+		`  </D:response>` + "\n" +
+		`</D:multistatus>`
+	writeXML(w, body)
+}
+
+func (h *Handler) propfindPrincipal(w http.ResponseWriter, username string) {
+	principalHref := h.prefix + "/" + username + "/"
+	calHomeHref := h.prefix + "/" + username + "/calendar/"
+	body := xmlHeader() +
+		`<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">` + "\n" +
+		`  <D:response>` + "\n" +
+		`    <D:href>` + principalHref + `</D:href>` + "\n" +
+		`    <D:propstat>` + "\n" +
+		`      <D:prop>` + "\n" +
+		`        <D:displayname>` + xmlEsc(username) + `</D:displayname>` + "\n" +
+		`        <D:principal-URL><D:href>` + principalHref + `</D:href></D:principal-URL>` + "\n" +
+		`        <C:calendar-home-set><D:href>` + calHomeHref + `</D:href></C:calendar-home-set>` + "\n" +
+		`      </D:prop>` + "\n" +
+		`      <D:status>HTTP/1.1 200 OK</D:status>` + "\n" +
+		`    </D:propstat>` + "\n" +
+		`  </D:response>` + "\n" +
+		`</D:multistatus>`
+	writeXML(w, body)
+}
+
+func (h *Handler) propfindCalendar(w http.ResponseWriter, username string, depth string) {
+	events, err := h.loadEvents(username)
+	if err != nil {
+		logger.PrintAndLog("CalDAV", "load events for "+username, err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	calHref := h.prefix + "/" + username + "/calendar/"
+	ctag := collectionCTag(events)
+
+	var sb strings.Builder
+	sb.WriteString(xmlHeader())
+	sb.WriteString(`<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">` + "\n")
+	sb.WriteString(`  <D:response>` + "\n")
+	sb.WriteString(`    <D:href>` + calHref + `</D:href>` + "\n")
+	sb.WriteString(`    <D:propstat>` + "\n")
+	sb.WriteString(`      <D:prop>` + "\n")
+	sb.WriteString(`        <D:resourcetype><D:collection/><C:calendar/></D:resourcetype>` + "\n")
+	sb.WriteString(`        <D:displayname>ArozOS Calendar</D:displayname>` + "\n")
+	sb.WriteString(`        <C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>` + "\n")
+	sb.WriteString(`        <CS:getctag>` + ctag + `</CS:getctag>` + "\n")
+	sb.WriteString(`      </D:prop>` + "\n")
+	sb.WriteString(`      <D:status>HTTP/1.1 200 OK</D:status>` + "\n")
+	sb.WriteString(`    </D:propstat>` + "\n")
+	sb.WriteString(`  </D:response>` + "\n")
+
+	if depth != "0" {
+		for _, ev := range events {
+			sb.WriteString(eventPropfindResponse(calHref+ev.ID+".ics", eventETag(ev)))
+		}
+	}
+
+	sb.WriteString(`</D:multistatus>`)
+	writeXML(w, sb.String())
+}
+
+func eventPropfindResponse(href, etag string) string {
+	return `  <D:response>` + "\n" +
+		`    <D:href>` + href + `</D:href>` + "\n" +
+		`    <D:propstat>` + "\n" +
+		`      <D:prop>` + "\n" +
+		`        <D:getetag>` + etag + `</D:getetag>` + "\n" +
+		`        <D:resourcetype/>` + "\n" +
+		`        <D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>` + "\n" +
+		`      </D:prop>` + "\n" +
+		`      <D:status>HTTP/1.1 200 OK</D:status>` + "\n" +
+		`    </D:propstat>` + "\n" +
+		`  </D:response>` + "\n"
+}
+
+// ── REPORT ───────────────────────────────────────────────────────────────────
+
+func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request, path string, username string) {
+	parts := splitURLPath(path)
+	if len(parts) < 2 || parts[1] != "calendar" {
+		http.Error(w, "Not Found", http.StatusNotFound)
+		return
+	}
+
+	body, err := io.ReadAll(r.Body)
+	if err != nil {
+		http.Error(w, "Bad Request", http.StatusBadRequest)
+		return
+	}
+
+	events, err := h.loadEvents(username)
+	if err != nil {
+		logger.PrintAndLog("CalDAV", "load events for "+username, err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	calHref := h.prefix + "/" + username + "/calendar/"
+	bodyStr := string(body)
+
+	// For calendar-multiget, only return the requested hrefs.
+	var filterIDs map[string]bool
+	if strings.Contains(bodyStr, "calendar-multiget") {
+		filterIDs = hrefsToIDSet(bodyStr, calHref)
+	}
+
+	var sb strings.Builder
+	sb.WriteString(xmlHeader())
+	sb.WriteString(`<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">` + "\n")
+
+	for _, ev := range events {
+		if filterIDs != nil && !filterIDs[ev.ID] {
+			continue
+		}
+		icsData := eventToICS(ev)
+		sb.WriteString(`  <D:response>` + "\n")
+		sb.WriteString(`    <D:href>` + calHref + ev.ID + `.ics</D:href>` + "\n")
+		sb.WriteString(`    <D:propstat>` + "\n")
+		sb.WriteString(`      <D:prop>` + "\n")
+		sb.WriteString(`        <D:getetag>` + eventETag(ev) + `</D:getetag>` + "\n")
+		sb.WriteString(`        <C:calendar-data>` + xmlEsc(icsData) + `</C:calendar-data>` + "\n")
+		sb.WriteString(`      </D:prop>` + "\n")
+		sb.WriteString(`      <D:status>HTTP/1.1 200 OK</D:status>` + "\n")
+		sb.WriteString(`    </D:propstat>` + "\n")
+		sb.WriteString(`  </D:response>` + "\n")
+	}
+
+	sb.WriteString(`</D:multistatus>`)
+	writeXML(w, sb.String())
+}
+
+// ── GET / HEAD ────────────────────────────────────────────────────────────────
+
+func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request, path string, username string) {
+	eventID := extractEventID(path)
+	if eventID == "" {
+		http.Error(w, "Not Found", http.StatusNotFound)
+		return
+	}
+
+	events, err := h.loadEvents(username)
+	if err != nil {
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	for _, ev := range events {
+		if ev.ID == eventID {
+			icsData := eventToICS(ev)
+			w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
+			w.Header().Set("ETag", eventETag(ev))
+			if r.Method == http.MethodHead {
+				w.WriteHeader(http.StatusOK)
+				return
+			}
+			w.WriteHeader(http.StatusOK)
+			fmt.Fprint(w, icsData)
+			return
+		}
+	}
+	http.Error(w, "Not Found", http.StatusNotFound)
+}
+
+// ── PUT ───────────────────────────────────────────────────────────────────────
+
+func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request, path string, username string) {
+	eventID := extractEventID(path)
+	if eventID == "" {
+		http.Error(w, "Bad Request", http.StatusBadRequest)
+		return
+	}
+
+	body, err := io.ReadAll(r.Body)
+	if err != nil {
+		http.Error(w, "Bad Request", http.StatusBadRequest)
+		return
+	}
+
+	newEv, err := icsToEvent(string(body), eventID)
+	if err != nil || newEv.Title == "" {
+		logger.PrintAndLog("CalDAV", "PUT: ICS parse failed for "+eventID, err)
+		http.Error(w, "Bad Request: cannot parse ICS", http.StatusBadRequest)
+		return
+	}
+	// Always use the URL path segment as the canonical ID.
+	newEv.ID = eventID
+
+	h.mu.Lock()
+	defer h.mu.Unlock()
+
+	events, err := h.loadEvents(username)
+	if err != nil {
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	isUpdate := false
+	for i, ev := range events {
+		if ev.ID == eventID {
+			events[i] = newEv
+			isUpdate = true
+			break
+		}
+	}
+	if !isUpdate {
+		events = append(events, newEv)
+	}
+
+	if err := h.saveEvents(username, events); err != nil {
+		logger.PrintAndLog("CalDAV", "save events for "+username, err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("ETag", eventETag(newEv))
+	if isUpdate {
+		w.WriteHeader(http.StatusNoContent)
+	} else {
+		w.WriteHeader(http.StatusCreated)
+	}
+}
+
+// ── DELETE ────────────────────────────────────────────────────────────────────
+
+func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request, path string, username string) {
+	eventID := extractEventID(path)
+	if eventID == "" {
+		http.Error(w, "Bad Request", http.StatusBadRequest)
+		return
+	}
+
+	h.mu.Lock()
+	defer h.mu.Unlock()
+
+	events, err := h.loadEvents(username)
+	if err != nil {
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	var kept []CalendarEvent
+	found := false
+	for _, ev := range events {
+		if ev.ID == eventID {
+			found = true
+		} else {
+			kept = append(kept, ev)
+		}
+	}
+	if !found {
+		http.Error(w, "Not Found", http.StatusNotFound)
+		return
+	}
+
+	if err := h.saveEvents(username, kept); err != nil {
+		logger.PrintAndLog("CalDAV", "save events for "+username, err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+// ── Storage helpers ───────────────────────────────────────────────────────────
+
+func (h *Handler) eventsFilePath(username string) (string, error) {
+	userObj, err := h.userHandler.GetUserInfoFromUsername(username)
+	if err != nil {
+		return "", err
+	}
+	fsh, err := userObj.GetHomeFileSystemHandler()
+	if err != nil {
+		return "", err
+	}
+	return fsh.FileSystemAbstraction.VirtualPathToRealPath("/Document/Calendar/events.json", username)
+}
+
+func (h *Handler) loadEvents(username string) ([]CalendarEvent, error) {
+	p, err := h.eventsFilePath(username)
+	if err != nil {
+		return nil, err
+	}
+	data, err := os.ReadFile(p)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return []CalendarEvent{}, nil
+		}
+		return nil, err
+	}
+	var events []CalendarEvent
+	if err := json.Unmarshal(data, &events); err != nil {
+		return nil, err
+	}
+	return events, nil
+}
+
+func (h *Handler) saveEvents(username string, events []CalendarEvent) error {
+	p, err := h.eventsFilePath(username)
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
+		return err
+	}
+	if events == nil {
+		events = []CalendarEvent{}
+	}
+	data, err := json.MarshalIndent(events, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(p, data, 0644)
+}
+
+// ── XML / path utilities ──────────────────────────────────────────────────────
+
+func writeXML(w http.ResponseWriter, body string) {
+	w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+	w.WriteHeader(207)
+	fmt.Fprint(w, body)
+}
+
+func xmlHeader() string {
+	return `<?xml version="1.0" encoding="UTF-8"?>` + "\n"
+}
+
+// xmlEsc escapes the five XML predefined entities.
+func xmlEsc(s string) string {
+	s = strings.ReplaceAll(s, "&", "&amp;")
+	s = strings.ReplaceAll(s, "<", "&lt;")
+	s = strings.ReplaceAll(s, ">", "&gt;")
+	s = strings.ReplaceAll(s, `"`, "&quot;")
+	s = strings.ReplaceAll(s, "'", "&apos;")
+	return s
+}
+
+// splitURLPath trims slashes and splits a URL path into segments,
+// returning an empty slice for the root.
+func splitURLPath(path string) []string {
+	path = strings.Trim(path, "/")
+	if path == "" {
+		return []string{}
+	}
+	return strings.Split(path, "/")
+}
+
+// extractEventID returns the event ID encoded in a path like
+// /{username}/calendar/{id}.ics, or "" if the path is not an event URL.
+func extractEventID(path string) string {
+	parts := splitURLPath(path)
+	if len(parts) != 3 || parts[1] != "calendar" {
+		return ""
+	}
+	return strings.TrimSuffix(parts[2], ".ics")
+}
+
+// hrefsToIDSet parses href elements from a calendar-multiget body and
+// returns the set of event IDs (filename without .ics).
+// iOS URL-encodes special characters (e.g. %40 for @) and may include
+// namespace attributes on the element, so we match by tag name suffix
+// and URL-decode before comparing against calHref.
+func hrefsToIDSet(body string, calHref string) map[string]bool {
+	result := make(map[string]bool)
+	for _, chunk := range strings.Split(body, "<") {
+		// Skip closing tags
+		if strings.HasPrefix(chunk, "/") {
+			continue
+		}
+		// Determine the tag name (everything before the first space or ">")
+		tagEnd := strings.IndexAny(chunk, " >")
+		if tagEnd < 0 {
+			continue
+		}
+		tagName := strings.ToLower(chunk[:tagEnd])
+		// Match any *:href or bare "href" element
+		if tagName != "href" && !strings.HasSuffix(tagName, ":href") {
+			continue
+		}
+		// Content starts after the closing ">" of the opening tag
+		gtIdx := strings.Index(chunk, ">")
+		if gtIdx < 0 {
+			continue
+		}
+		raw := strings.TrimSpace(strings.SplitN(chunk[gtIdx+1:], "<", 2)[0])
+		// Decode percent-encoding (iOS sends %40 for @, etc.)
+		decoded, err := url.PathUnescape(raw)
+		if err != nil {
+			decoded = raw
+		}
+		if !strings.HasSuffix(decoded, ".ics") {
+			continue
+		}
+		id := strings.TrimSuffix(strings.TrimPrefix(decoded, calHref), ".ics")
+		if id != "" && !strings.Contains(id, "/") {
+			result[id] = true
+		}
+	}
+	return result
+}

+ 295 - 0
src/mod/caldav/caldav_test.go

@@ -0,0 +1,295 @@
+package caldav
+
+import (
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestEventToICS_BasicFields(t *testing.T) {
+	ev := CalendarEvent{
+		ID:      "ev_test1",
+		Title:   "Team Meeting",
+		AllDay:  false,
+		Start:   time.Date(2024, 3, 15, 10, 0, 0, 0, time.UTC).UnixMilli(),
+		End:     time.Date(2024, 3, 15, 11, 0, 0, 0, time.UTC).UnixMilli(),
+		Address: "Conference Room",
+		Notes:   "Weekly sync",
+		Color:   "blue",
+	}
+	ics := eventToICS(ev)
+
+	checks := []string{
+		"BEGIN:VCALENDAR",
+		"BEGIN:VEVENT",
+		"UID:ev_test1@arozos",
+		"SUMMARY:Team Meeting",
+		"DTSTART:20240315T100000Z",
+		"DTEND:20240315T110000Z",
+		"LOCATION:Conference Room",
+		"DESCRIPTION:Weekly sync",
+		"X-APPLE-CALENDAR-COLOR:#4A90D9",
+		"END:VEVENT",
+		"END:VCALENDAR",
+	}
+	for _, want := range checks {
+		if !strings.Contains(ics, want) {
+			t.Errorf("eventToICS: missing %q in output:\n%s", want, ics)
+		}
+	}
+}
+
+func TestEventToICS_AllDay(t *testing.T) {
+	ev := CalendarEvent{
+		ID:     "ev_allday",
+		Title:  "Holiday",
+		AllDay: true,
+		Start:  time.Date(2024, 12, 25, 0, 0, 0, 0, time.UTC).UnixMilli(),
+		End:    time.Date(2024, 12, 26, 0, 0, 0, 0, time.UTC).UnixMilli(),
+	}
+	ics := eventToICS(ev)
+	if !strings.Contains(ics, "DTSTART;VALUE=DATE:20241225") {
+		t.Errorf("expected DATE-only DTSTART for all-day event, got:\n%s", ics)
+	}
+	if strings.Contains(ics, "T") && strings.Contains(ics, "DTSTART") {
+		// Only the date-only form should appear for DTSTART
+		if strings.Contains(ics, "DTSTART:") {
+			t.Errorf("all-day event should not have time-based DTSTART")
+		}
+	}
+}
+
+func TestEventToICS_Reminder(t *testing.T) {
+	cases := []struct {
+		reminder *EventReminder
+		want     string
+	}{
+		{&EventReminder{Value: 15, Unit: "mins"}, "TRIGGER:-PT15M"},
+		{&EventReminder{Value: 2, Unit: "hours"}, "TRIGGER:-PT2H"},
+		{&EventReminder{Value: 1, Unit: "days"}, "TRIGGER:-P1D"},
+	}
+	for _, tc := range cases {
+		ev := CalendarEvent{ID: "ev1", Title: "X", Reminder: tc.reminder,
+			Start: time.Now().UnixMilli(), End: time.Now().UnixMilli()}
+		ics := eventToICS(ev)
+		if !strings.Contains(ics, tc.want) {
+			t.Errorf("expected %q in ICS for reminder %+v, got:\n%s", tc.want, tc.reminder, ics)
+		}
+	}
+}
+
+func TestICSToEvent_RoundTrip(t *testing.T) {
+	original := CalendarEvent{
+		ID:       "ev_rt1",
+		Title:    "Round Trip",
+		AllDay:   false,
+		Start:    time.Date(2024, 6, 1, 9, 30, 0, 0, time.UTC).UnixMilli(),
+		End:      time.Date(2024, 6, 1, 10, 30, 0, 0, time.UTC).UnixMilli(),
+		Address:  "Main Hall",
+		Notes:    "Test notes",
+		Color:    "green",
+		Reminder: &EventReminder{Value: 30, Unit: "mins"},
+	}
+
+	ics := eventToICS(original)
+	parsed, err := icsToEvent(ics, "ev_rt1")
+	if err != nil {
+		t.Fatalf("icsToEvent returned error: %v", err)
+	}
+
+	if parsed.Title != original.Title {
+		t.Errorf("Title: got %q want %q", parsed.Title, original.Title)
+	}
+	if parsed.Start != original.Start {
+		t.Errorf("Start: got %d want %d", parsed.Start, original.Start)
+	}
+	if parsed.End != original.End {
+		t.Errorf("End: got %d want %d", parsed.End, original.End)
+	}
+	if parsed.Address != original.Address {
+		t.Errorf("Address: got %q want %q", parsed.Address, original.Address)
+	}
+	if parsed.Notes != original.Notes {
+		t.Errorf("Notes: got %q want %q", parsed.Notes, original.Notes)
+	}
+	if parsed.Color != original.Color {
+		t.Errorf("Color: got %q want %q", parsed.Color, original.Color)
+	}
+	if parsed.Reminder == nil {
+		t.Fatal("Reminder: got nil, want non-nil")
+	}
+	if parsed.Reminder.Value != original.Reminder.Value || parsed.Reminder.Unit != original.Reminder.Unit {
+		t.Errorf("Reminder: got %+v want %+v", parsed.Reminder, original.Reminder)
+	}
+}
+
+func TestICSToEvent_IDFromURL(t *testing.T) {
+	ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" +
+		"UID:some-ios-uid@icloud.com\r\n" +
+		"SUMMARY:iOS Event\r\n" +
+		"DTSTART:20240101T120000Z\r\n" +
+		"DTEND:20240101T130000Z\r\n" +
+		"END:VEVENT\r\nEND:VCALENDAR\r\n"
+
+	// idHint from URL should take precedence as canonical ID
+	parsed, err := icsToEvent(ics, "url-derived-id")
+	if err != nil {
+		t.Fatalf("icsToEvent error: %v", err)
+	}
+	// icsToEvent uses the UID field; caller (handlePut) overwrites with URL id
+	if parsed.Title != "iOS Event" {
+		t.Errorf("Title: got %q want %q", parsed.Title, "iOS Event")
+	}
+}
+
+func TestTriggerToReminder(t *testing.T) {
+	cases := []struct {
+		trigger string
+		want    *EventReminder
+	}{
+		{"-PT15M", &EventReminder{Value: 15, Unit: "mins"}},
+		{"-PT2H", &EventReminder{Value: 2, Unit: "hours"}},
+		{"-P1D", &EventReminder{Value: 1, Unit: "days"}},
+		{"PT15M", nil}, // positive trigger – ignore
+		{"invalid", nil},
+	}
+	for _, tc := range cases {
+		got := triggerToReminder(tc.trigger)
+		if tc.want == nil {
+			if got != nil {
+				t.Errorf("triggerToReminder(%q): want nil, got %+v", tc.trigger, got)
+			}
+			continue
+		}
+		if got == nil {
+			t.Errorf("triggerToReminder(%q): want %+v, got nil", tc.trigger, tc.want)
+			continue
+		}
+		if got.Value != tc.want.Value || got.Unit != tc.want.Unit {
+			t.Errorf("triggerToReminder(%q): got %+v want %+v", tc.trigger, got, tc.want)
+		}
+	}
+}
+
+func TestUnfoldICSLines(t *testing.T) {
+	input := "BEGIN:VCALENDAR\r\nSUMMARY:Long \r\n Title\r\nEND:VCALENDAR\r\n"
+	lines := unfoldICSLines(input)
+	found := false
+	for _, l := range lines {
+		if l == "SUMMARY:Long Title" {
+			found = true
+		}
+	}
+	if !found {
+		t.Errorf("unfoldICSLines: continuation line not joined; got %v", lines)
+	}
+}
+
+func TestCollectionCTag_ChangesWithEvents(t *testing.T) {
+	ev1 := []CalendarEvent{{ID: "a", Title: "A"}}
+	ev2 := []CalendarEvent{{ID: "a", Title: "A"}, {ID: "b", Title: "B"}}
+	if collectionCTag(ev1) == collectionCTag(ev2) {
+		t.Error("collectionCTag should differ when events differ")
+	}
+}
+
+func TestSplitURLPath(t *testing.T) {
+	cases := []struct {
+		in   string
+		want []string
+	}{
+		{"/", []string{}},
+		{"", []string{}},
+		{"/alice/calendar/", []string{"alice", "calendar"}},
+		{"/alice/calendar/ev1.ics", []string{"alice", "calendar", "ev1.ics"}},
+	}
+	for _, tc := range cases {
+		got := splitURLPath(tc.in)
+		if len(got) != len(tc.want) {
+			t.Errorf("splitURLPath(%q): got %v want %v", tc.in, got, tc.want)
+			continue
+		}
+		for i := range got {
+			if got[i] != tc.want[i] {
+				t.Errorf("splitURLPath(%q)[%d]: got %q want %q", tc.in, i, got[i], tc.want[i])
+			}
+		}
+	}
+}
+
+func TestExtractEventID(t *testing.T) {
+	cases := []struct {
+		path string
+		want string
+	}{
+		{"/alice/calendar/ev_abc.ics", "ev_abc"},
+		{"/alice/calendar/", ""},
+		{"/alice/", ""},
+	}
+	for _, tc := range cases {
+		got := extractEventID(tc.path)
+		if got != tc.want {
+			t.Errorf("extractEventID(%q): got %q want %q", tc.path, got, tc.want)
+		}
+	}
+}
+
+func TestHrefsToIDSet(t *testing.T) {
+	body := `<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
+  <D:href>/caldav/alice/calendar/ev1.ics</D:href>
+  <D:href>/caldav/alice/calendar/ev2.ics</D:href>
+</C:calendar-multiget>`
+	calHref := "/caldav/alice/calendar/"
+	ids := hrefsToIDSet(body, calHref)
+	if !ids["ev1"] {
+		t.Error("hrefsToIDSet: missing ev1")
+	}
+	if !ids["ev2"] {
+		t.Error("hrefsToIDSet: missing ev2")
+	}
+	if len(ids) != 2 {
+		t.Errorf("hrefsToIDSet: expected 2 IDs, got %d", len(ids))
+	}
+}
+
+func TestHrefsToIDSet_URLEncoded(t *testing.T) {
+	// iOS sends percent-encoded @ (%40) in hrefs for usernames like admin@example.com
+	body := `<B:calendar-multiget xmlns:B="urn:ietf:params:xml:ns:caldav">
+  <A:href xmlns:A="DAV:">/caldav/admin%40example.com/calendar/ev_abc.ics</A:href>
+</B:calendar-multiget>`
+	calHref := "/caldav/admin@example.com/calendar/"
+	ids := hrefsToIDSet(body, calHref)
+	if !ids["ev_abc"] {
+		t.Errorf("hrefsToIDSet: URL-encoded href not matched; got %v", ids)
+	}
+}
+
+func TestParseICSDateTime_TZID(t *testing.T) {
+	// iOS sends DTSTART;TZID=Asia/Tokyo:20260616T110000
+	// This should resolve to 02:00 UTC (JST = UTC+9, so 11:00 JST = 02:00 UTC)
+	key := "DTSTART;TZID=Asia/Tokyo"
+	val := "20260616T110000"
+	got, allDay := parseICSDateTime(key, val)
+	if allDay {
+		t.Error("TZID datetime should not be all-day")
+	}
+	wantHour := 2 // 11:00 JST = 02:00 UTC
+	if got.UTC().Hour() != wantHour {
+		t.Errorf("TZID parse: got UTC hour %d, want %d (full time: %s)", got.UTC().Hour(), wantHour, got.UTC())
+	}
+}
+
+func TestExtractTZID(t *testing.T) {
+	cases := []struct{ key, want string }{
+		{"DTSTART;TZID=Asia/Tokyo", "Asia/Tokyo"},
+		{"DTEND;TZID=America/New_York", "America/New_York"},
+		{"DTSTART", ""},
+		{"DTSTART;VALUE=DATE", ""},
+	}
+	for _, tc := range cases {
+		got := extractTZID(tc.key)
+		if got != tc.want {
+			t.Errorf("extractTZID(%q): got %q want %q", tc.key, got, tc.want)
+		}
+	}
+}

+ 346 - 0
src/mod/caldav/ics.go

@@ -0,0 +1,346 @@
+package caldav
+
+/*
+	ics.go - ICS / iCalendar conversion helpers
+
+	Converts between ArozOS CalendarEvent (JSON) and the iCalendar format
+	(RFC 5545) used by CalDAV.  Only the subset needed for iOS Calendar
+	bidirectional sync is handled.
+*/
+
+import (
+	"crypto/md5"
+	"encoding/json"
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// eventToICS serialises a CalendarEvent as a VCALENDAR / VEVENT string.
+func eventToICS(ev CalendarEvent) string {
+	var sb strings.Builder
+	sb.WriteString("BEGIN:VCALENDAR\r\n")
+	sb.WriteString("VERSION:2.0\r\n")
+	sb.WriteString("PRODID:-//ArozOS//CalDAV//EN\r\n")
+	sb.WriteString("BEGIN:VEVENT\r\n")
+	sb.WriteString("UID:" + ev.ID + "@arozos\r\n")
+	sb.WriteString("SUMMARY:" + escapeICSText(ev.Title) + "\r\n")
+
+	if ev.AllDay {
+		startT := time.UnixMilli(ev.Start).UTC()
+		endT := time.UnixMilli(ev.End).UTC()
+		sb.WriteString("DTSTART;VALUE=DATE:" + startT.Format("20060102") + "\r\n")
+		sb.WriteString("DTEND;VALUE=DATE:" + endT.Format("20060102") + "\r\n")
+	} else {
+		startT := time.UnixMilli(ev.Start).UTC()
+		endT := time.UnixMilli(ev.End).UTC()
+		sb.WriteString("DTSTART:" + startT.Format("20060102T150405Z") + "\r\n")
+		sb.WriteString("DTEND:" + endT.Format("20060102T150405Z") + "\r\n")
+	}
+
+	if ev.Address != "" {
+		sb.WriteString("LOCATION:" + escapeICSText(ev.Address) + "\r\n")
+	}
+	if ev.Notes != "" {
+		sb.WriteString("DESCRIPTION:" + escapeICSText(ev.Notes) + "\r\n")
+	}
+	if ev.Color != "" {
+		if hex := arozColorToHex(ev.Color); hex != "" {
+			sb.WriteString("X-APPLE-CALENDAR-COLOR:" + hex + "\r\n")
+		}
+	}
+	if ev.Reminder != nil {
+		trigger := reminderToTrigger(ev.Reminder)
+		sb.WriteString("BEGIN:VALARM\r\n")
+		sb.WriteString("TRIGGER:" + trigger + "\r\n")
+		sb.WriteString("ACTION:DISPLAY\r\n")
+		sb.WriteString("DESCRIPTION:Reminder\r\n")
+		sb.WriteString("END:VALARM\r\n")
+	}
+
+	sb.WriteString("END:VEVENT\r\n")
+	sb.WriteString("END:VCALENDAR\r\n")
+	return sb.String()
+}
+
+// icsToEvent parses a VCALENDAR string and returns a CalendarEvent.
+// idHint is used as the event ID when the UID is absent or needs normalising.
+func icsToEvent(icsData string, idHint string) (CalendarEvent, error) {
+	lines := unfoldICSLines(icsData)
+
+	ev := CalendarEvent{
+		ID:    idHint,
+		Color: "blue",
+	}
+
+	inVEvent := false
+	inVAlarm := false
+	var alarmTrigger string
+
+	for _, line := range lines {
+		switch line {
+		case "BEGIN:VEVENT":
+			inVEvent = true
+			continue
+		case "END:VEVENT":
+			inVEvent = false
+			continue
+		case "BEGIN:VALARM":
+			inVAlarm = true
+			continue
+		case "END:VALARM":
+			inVAlarm = false
+			continue
+		}
+
+		if !inVEvent {
+			continue
+		}
+
+		if inVAlarm {
+			if strings.HasPrefix(strings.ToUpper(line), "TRIGGER") {
+				_, val := splitICSLine(line)
+				alarmTrigger = strings.TrimSpace(val)
+			}
+			continue
+		}
+
+		key, val := splitICSLine(line)
+		baseKey := strings.ToUpper(strings.Split(key, ";")[0])
+
+		switch baseKey {
+		case "UID":
+			uid := unescapeICSText(strings.TrimSpace(val))
+			uid = strings.TrimSuffix(uid, "@arozos")
+			if uid != "" {
+				ev.ID = uid
+			}
+		case "SUMMARY":
+			ev.Title = unescapeICSText(val)
+		case "LOCATION":
+			ev.Address = unescapeICSText(val)
+		case "DESCRIPTION":
+			ev.Notes = unescapeICSText(val)
+		case "X-APPLE-CALENDAR-COLOR":
+			ev.Color = hexToArozColor(strings.TrimSpace(val))
+		case "DTSTART":
+			t, allDay := parseICSDateTime(key, val)
+			ev.Start = t.UnixMilli()
+			ev.AllDay = allDay
+		case "DTEND":
+			t, _ := parseICSDateTime(key, val)
+			ev.End = t.UnixMilli()
+		}
+	}
+
+	if alarmTrigger != "" {
+		ev.Reminder = triggerToReminder(alarmTrigger)
+	}
+
+	return ev, nil
+}
+
+// unfoldICSLines normalises line endings and joins continuation lines.
+func unfoldICSLines(data string) []string {
+	data = strings.ReplaceAll(data, "\r\n", "\n")
+	data = strings.ReplaceAll(data, "\r", "\n")
+	raw := strings.Split(data, "\n")
+
+	var lines []string
+	for _, line := range raw {
+		if len(line) == 0 {
+			continue
+		}
+		if (line[0] == ' ' || line[0] == '\t') && len(lines) > 0 {
+			lines[len(lines)-1] += line[1:]
+		} else {
+			lines = append(lines, line)
+		}
+	}
+	return lines
+}
+
+// splitICSLine splits "KEY;PARAMS:VALUE" at the first colon.
+func splitICSLine(line string) (string, string) {
+	idx := strings.Index(line, ":")
+	if idx < 0 {
+		return line, ""
+	}
+	return line[:idx], line[idx+1:]
+}
+
+func parseICSDateTime(key, val string) (time.Time, bool) {
+	val = strings.TrimSpace(val)
+	keyUpper := strings.ToUpper(key)
+
+	// All-day: VALUE=DATE
+	if strings.Contains(keyUpper, "VALUE=DATE") {
+		t, err := time.Parse("20060102", val)
+		if err != nil {
+			return time.Now().UTC(), true
+		}
+		return t.UTC(), true
+	}
+
+	// UTC: trailing Z
+	if strings.HasSuffix(val, "Z") {
+		t, err := time.Parse("20060102T150405Z", val)
+		if err != nil {
+			return time.Now().UTC(), false
+		}
+		return t.UTC(), false
+	}
+
+	// TZID-parameterized local time, e.g. DTSTART;TZID=Asia/Tokyo:20260616T110000
+	if tzid := extractTZID(key); tzid != "" {
+		loc, err := time.LoadLocation(tzid)
+		if err == nil {
+			t, err := time.ParseInLocation("20060102T150405", val, loc)
+			if err == nil {
+				return t.UTC(), false
+			}
+		}
+	}
+
+	// Floating local time – treat as UTC
+	t, err := time.Parse("20060102T150405", val)
+	if err != nil {
+		return time.Now().UTC(), false
+	}
+	return t.UTC(), false
+}
+
+// extractTZID pulls the TZID value out of an ICS property key such as
+// "DTSTART;TZID=Asia/Tokyo" or "DTEND;VALUE=DATE;TZID=America/New_York".
+func extractTZID(key string) string {
+	for _, param := range strings.Split(key, ";") {
+		if strings.HasPrefix(strings.ToUpper(param), "TZID=") {
+			return param[5:]
+		}
+	}
+	return ""
+}
+
+// escapeICSText escapes special characters per RFC 5545 §3.3.11.
+func escapeICSText(s string) string {
+	s = strings.ReplaceAll(s, "\\", "\\\\")
+	s = strings.ReplaceAll(s, ";", "\\;")
+	s = strings.ReplaceAll(s, ",", "\\,")
+	s = strings.ReplaceAll(s, "\n", "\\n")
+	s = strings.ReplaceAll(s, "\r", "")
+	return s
+}
+
+func unescapeICSText(s string) string {
+	s = strings.ReplaceAll(s, "\\n", "\n")
+	s = strings.ReplaceAll(s, "\\N", "\n")
+	s = strings.ReplaceAll(s, "\\;", ";")
+	s = strings.ReplaceAll(s, "\\,", ",")
+	s = strings.ReplaceAll(s, "\\\\", "\\")
+	return s
+}
+
+// arozColorToHex maps the ArozOS colour names to Apple iCal hex codes.
+func arozColorToHex(color string) string {
+	switch color {
+	case "blue":
+		return "#4A90D9"
+	case "red":
+		return "#D94040"
+	case "orange":
+		return "#E87C25"
+	case "green":
+		return "#56B969"
+	case "purple":
+		return "#8E6BAD"
+	case "teal":
+		return "#1BAFD6"
+	default:
+		return ""
+	}
+}
+
+// hexToArozColor maps a hex colour string back to an ArozOS colour name.
+func hexToArozColor(hex string) string {
+	switch strings.ToUpper(hex) {
+	case "#4A90D9", "#007AFF", "#0000FF":
+		return "blue"
+	case "#D94040", "#FF0000", "#FF3B30":
+		return "red"
+	case "#E87C25", "#FF8C00", "#FF9500":
+		return "orange"
+	case "#56B969", "#00FF00", "#34C759", "#008000":
+		return "green"
+	case "#8E6BAD", "#800080", "#AF52DE":
+		return "purple"
+	case "#1BAFD6", "#008080", "#5AC8FA":
+		return "teal"
+	default:
+		return "blue"
+	}
+}
+
+// reminderToTrigger converts an EventReminder to a VALARM TRIGGER value.
+func reminderToTrigger(r *EventReminder) string {
+	if r == nil {
+		return "-PT15M"
+	}
+	switch r.Unit {
+	case "hours":
+		return fmt.Sprintf("-PT%dH", r.Value)
+	case "days":
+		return fmt.Sprintf("-P%dD", r.Value)
+	default: // "mins"
+		return fmt.Sprintf("-PT%dM", r.Value)
+	}
+}
+
+// triggerToReminder parses a VALARM TRIGGER string into an EventReminder.
+// Only the common negative-duration forms used by iOS are handled.
+func triggerToReminder(trigger string) *EventReminder {
+	trigger = strings.TrimSpace(trigger)
+	if !strings.HasPrefix(trigger, "-P") {
+		return nil
+	}
+	s := trigger[2:] // strip "-P"
+	if strings.HasPrefix(s, "T") {
+		s = s[1:] // strip "T" time-designator
+	}
+	if strings.HasSuffix(s, "M") {
+		v, err := strconv.Atoi(s[:len(s)-1])
+		if err != nil {
+			return nil
+		}
+		return &EventReminder{Value: v, Unit: "mins"}
+	}
+	if strings.HasSuffix(s, "H") {
+		v, err := strconv.Atoi(s[:len(s)-1])
+		if err != nil {
+			return nil
+		}
+		return &EventReminder{Value: v, Unit: "hours"}
+	}
+	if strings.HasSuffix(s, "D") {
+		v, err := strconv.Atoi(s[:len(s)-1])
+		if err != nil {
+			return nil
+		}
+		return &EventReminder{Value: v, Unit: "days"}
+	}
+	return nil
+}
+
+// eventETag returns a quoted MD5 ETag for the given event.
+func eventETag(ev CalendarEvent) string {
+	data, _ := json.Marshal(ev)
+	h := md5.Sum(data)
+	return fmt.Sprintf(`"%x"`, h)
+}
+
+// collectionCTag returns an unquoted MD5 sync token for the whole collection.
+func collectionCTag(events []CalendarEvent) string {
+	data, _ := json.Marshal(events)
+	h := md5.Sum(data)
+	return fmt.Sprintf("%x", h)
+}

+ 2 - 1
src/startup.go

@@ -142,12 +142,13 @@ func RunStartup() {
 	AuthSettingsInit()        //Authentication Settings Handler, must be start after user Handler
 	AdvanceSettingInit()      //System Advance Settings
 	AIModelSettingInit()      //AI Model (OpenAI / Anthropic) config, pricing, quota & usage metrics
-	AGIRuntimeManagerInit()  //AGI VM lifecycle monitor (Developer Options tab)
+	AGIRuntimeManagerInit()   //AGI VM lifecycle monitor (Developer Options tab)
 	StartupFlagsInit()        //System BootFlag settibg
 	HardwarePowerInit()       //Start host power manager
 	RegisterStorageSettings() //Storage Settings
 
 	//10. Startup network services and schedule services
+	CalDAVInit()         //CalDAV calendar sync server (iOS bidirectional sync)
 	NetworkServiceInit() //Initalize network serves (ssdp / mdns etc)
 	WiFiInit()           //Inialize WiFi management module
 

+ 16 - 0
src/web/Calendar/img/icon.svg

@@ -0,0 +1,16 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+  <rect width="100" height="100" rx="20" fill="#007AFF"/>
+  <rect x="12" y="24" width="76" height="62" rx="8" fill="white"/>
+  <rect x="12" y="24" width="76" height="22" rx="8" fill="#005ED8"/>
+  <rect x="12" y="38" width="76" height="8" fill="#005ED8"/>
+  <rect x="28" y="16" width="8" height="18" rx="4" fill="white"/>
+  <rect x="64" y="16" width="8" height="18" rx="4" fill="white"/>
+  <rect x="22" y="57" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <rect x="38" y="57" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <circle cx="64" cy="61" r="8" fill="#007AFF"/>
+  <rect x="76" y="57" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <rect x="22" y="73" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <rect x="38" y="73" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <rect x="54" y="73" width="10" height="8" rx="2" fill="#D1E8FF"/>
+  <rect x="70" y="73" width="10" height="8" rx="2" fill="#D1E8FF"/>
+</svg>