package main import ( "encoding/xml" "flag" "fmt" "io" "net/http" "net/url" "os" "strings" "time" ) // Build-time configuration (set via ldflags at compile time) var ( BuildServerURL string BuildUsername string BuildToken string ) // Config holds Nextcloud connection configuration type Config struct { URL string User string Token string CalDAV string } // Client is a Nextcloud CalDAV client type Client struct { Config Config HTTPClient *http.Client } // MultiStatus represents WebDAV multistatus response type MultiStatus struct { XMLName xml.Name `xml:"multistatus"` Responses []Response `xml:"response"` } // Response represents a single WebDAV response type Response struct { Href string `xml:"href"` PropStats []PropStat `xml:"propstat"` } // PropStat represents a WebDAV propstat element type PropStat struct { Prop Prop `xml:"prop"` Status string `xml:"status"` } // Prop represents WebDAV properties type Prop struct { DisplayName string `xml:"displayname"` GetETag string `xml:"getetag"` ResourceType ResourceType `xml:"resourcetype"` } // ResourceType represents resourcetype element type ResourceType struct { Collection string `xml:"collection"` } // Calendar represents a CalDAV calendar type Calendar struct { Href string DisplayName string ETag string } // Event represents a calendar event (iCalendar format) type Event struct { UID string Summary string Start time.Time End time.Time iCal string // Raw iCalendar content } func main() { // Parse flags (with build-time defaults) serverURL := flag.String("url", BuildServerURL, "Nextcloud server URL (e.g., https://cloud.example.com)") username := flag.String("user", BuildUsername, "Nextcloud username") token := flag.String("token", BuildToken, "Nextcloud app token") operation := flag.String("op", "list-calendars", "Operation: list-calendars, list-events, get-event, create-event, delete-event") calendarName := flag.String("calendar", "personal", "Calendar name (default: personal)") eventUID := flag.String("uid", "", "Event UID for get/delete-event operation") icalPath := flag.String("ical", "", "Path to iCalendar file for create-event operation") summary := flag.String("summary", "", "Event summary/title (for create-event)") startTime := flag.String("start", "", "Event start time (RFC3339 format, e.g., 2026-02-11T10:00:00Z)") endTime := flag.String("end", "", "Event end time (RFC3339 format, e.g., 2026-02-11T11:00:00Z)") flag.Parse() // Use environment variables as fallback if not set at build time or via flags if *serverURL == "" { *serverURL = os.Getenv("NEXTCLOUD_URL") } if *username == "" { *username = os.Getenv("NEXTCLOUD_USER") } if *token == "" { *token = os.Getenv("NEXTCLOUD_TOKEN") } if serverURL == nil || username == nil || token == nil || *serverURL == "" || *username == "" || *token == "" { fmt.Println("Error: URL, user, and token are required (set via ldflags, flags, or env vars)") os.Exit(1) } // Build config encodedUsername := url.PathEscape(*username) config := Config{ URL: *serverURL, User: *username, Token: *token, CalDAV: fmt.Sprintf("%s/remote.php/dav/calendars/%s", strings.TrimSuffix(*serverURL, "/"), encodedUsername), } // Create client client := &Client{ Config: config, HTTPClient: &http.Client{}, } var err error switch *operation { case "list-calendars": err = client.listCalendars() case "list-events": err = client.listEvents(*calendarName) case "get-event": if *eventUID == "" { fmt.Println("Error: --uid is required for get-event operation") os.Exit(1) } err = client.getEvent(*calendarName, *eventUID) case "create-event": event := &Event{ Summary: *summary, } if *icalPath != "" { icalContent, readErr := os.ReadFile(*icalPath) if readErr != nil { fmt.Printf("Error reading iCalendar file: %v\n", readErr) os.Exit(1) } event.iCal = string(icalContent) } else { // Parse times if provided if *startTime != "" { startTimeParsed, parseErr := time.Parse(time.RFC3339, *startTime) if parseErr != nil { fmt.Printf("Error parsing start time: %v\n", parseErr) os.Exit(1) } event.Start = startTimeParsed } if *endTime != "" { endTimeParsed, parseErr := time.Parse(time.RFC3339, *endTime) if parseErr != nil { fmt.Printf("Error parsing end time: %v\n", parseErr) os.Exit(1) } event.End = endTimeParsed } } err = client.createEvent(*calendarName, event) case "delete-event": if *eventUID == "" { fmt.Println("Error: --uid is required for delete-event operation") os.Exit(1) } err = client.deleteEvent(*calendarName, *eventUID) default: fmt.Printf("Unknown operation: %s\n", *operation) os.Exit(1) } if err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } } func (c *Client) listCalendars() error { // PROPFIND body body := fmt.Sprintf(` `) req, err := http.NewRequest("PROPFIND", c.Config.CalDAV+"/", strings.NewReader(body)) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("Content-Type", "application/xml; charset=utf-8") req.Header.Set("Depth", "1") resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 207 { return fmt.Errorf("unexpected status: %d (expected 207 Multi-Status)", resp.StatusCode) } var multiStatus MultiStatus decoder := xml.NewDecoder(resp.Body) if err := decoder.Decode(&multiStatus); err != nil { return fmt.Errorf("xml decode failed: %v", err) } // Print calendars fmt.Println("Calendars:") fmt.Println("----------") for _, response := range multiStatus.Responses { relPath := strings.TrimPrefix(response.Href, c.Config.CalDAV+"/") if relPath == "" || !strings.HasSuffix(relPath, "/") { continue } // Skip system folders (inbox, outbox, trashbin) if relPath == "inbox/" || relPath == "outbox/" || relPath == "trashbin/" { continue } var mergedProp Prop for _, propStat := range response.PropStats { if strings.Contains(propStat.Status, "200") { if propStat.Prop.DisplayName != "" { mergedProp.DisplayName = propStat.Prop.DisplayName } if propStat.Prop.GetETag != "" { mergedProp.GetETag = propStat.Prop.GetETag } } } calendarName := strings.TrimSuffix(relPath, "/") fmt.Printf("Name: %s\n", mergedProp.DisplayName) fmt.Printf("Path: %s\n", calendarName) fmt.Printf("ETag: %s\n", mergedProp.GetETag) fmt.Println() } return nil } func (c *Client) listEvents(calendarName string) error { calendarPath := fmt.Sprintf("%s/%s/", c.Config.CalDAV, calendarName) // PROPFIND body body := fmt.Sprintf(` `) req, err := http.NewRequest("PROPFIND", calendarPath, strings.NewReader(body)) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("Content-Type", "application/xml; charset=utf-8") req.Header.Set("Depth", "1") resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 207 { return fmt.Errorf("unexpected status: %d (expected 207 Multi-Status)", resp.StatusCode) } var multiStatus MultiStatus decoder := xml.NewDecoder(resp.Body) if err := decoder.Decode(&multiStatus); err != nil { return fmt.Errorf("xml decode failed: %v", err) } // Print events fmt.Printf("Events in '%s' calendar:\n", calendarName) fmt.Println("-----------------") eventCount := 0 for _, response := range multiStatus.Responses { relPath := strings.TrimPrefix(response.Href, calendarPath) if relPath == "" || strings.HasSuffix(relPath, "/") { continue } eventCount++ var etag string for _, propStat := range response.PropStats { if strings.Contains(propStat.Status, "200") { etag = propStat.Prop.GetETag } } // Extract UID from .ics filename filename := relPath if idx := strings.LastIndex(relPath, "/"); idx != -1 { filename = relPath[idx+1:] } eventUID := strings.TrimSuffix(filename, ".ics") fmt.Printf("UID: %s\n", eventUID) fmt.Printf("ETag: %s\n", etag) fmt.Println() } if eventCount == 0 { fmt.Println("(No events found)") } return nil } func (c *Client) getEvent(calendarName, eventUID string) error { eventPath := fmt.Sprintf("%s/%s/%s.ics", c.Config.CalDAV, calendarName, eventUID) req, err := http.NewRequest("GET", eventPath, nil) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("Accept", "text/calendar; charset=utf-8") resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("get event failed with status: %d", resp.StatusCode) } content, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %v", err) } fmt.Printf("Event: %s\n", eventUID) fmt.Println("--------") fmt.Println(string(content)) return nil } func (c *Client) createEvent(calendarName string, event *Event) error { calendarPath := fmt.Sprintf("%s/%s/", c.Config.CalDAV, calendarName) // Generate iCalendar if not provided icalContent := event.iCal if icalContent == "" { if event.Summary == "" { return fmt.Errorf("event summary is required") } // Generate a simple VEVENT uid := fmt.Sprintf("%d", time.Now().UnixNano()) now := time.Now().UTC().Format("20060102T150405Z") var startTime, endTime string if !event.Start.IsZero() { startTime = event.Start.UTC().Format("20060102T150405Z") } else { // Default to now + 1 hour startTime = time.Now().UTC().Add(1 * time.Hour).Format("20060102T150405Z") } if !event.End.IsZero() { endTime = event.End.UTC().Format("20060102T150405Z") } else { // Default to start + 1 hour endTime = startTime } icalContent = fmt.Sprintf(`BEGIN:VCALENDAR VERSION:2.0 PRODID:-//WLTBAgent//NextCalendar//EN BEGIN:VEVENT UID:%s DTSTAMP:%s DTSTART:%s DTEND:%s SUMMARY:%s END:VEVENT END:VCALENDAR`, uid, now, startTime, endTime, event.Summary) } eventPath := fmt.Sprintf("%s%s.ics", calendarPath, fmt.Sprintf("%d", time.Now().UnixNano())) req, err := http.NewRequest("PUT", eventPath, strings.NewReader(icalContent)) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("Content-Type", "text/calendar; charset=utf-8") resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 201 && resp.StatusCode != 204 { return fmt.Errorf("create event failed with status: %d", resp.StatusCode) } fmt.Printf("Event created successfully\n") return nil } func (c *Client) deleteEvent(calendarName, eventUID string) error { eventPath := fmt.Sprintf("%s/%s/%s.ics", c.Config.CalDAV, calendarName, eventUID) req, err := http.NewRequest("DELETE", eventPath, nil) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 204 { return fmt.Errorf("delete event failed with status: %d", resp.StatusCode) } fmt.Printf("Event deleted: %s\n", eventUID) return nil }