Add Nextcloud integration tools
- CLI tools: nextcloud-client, nextcloud-contacts, nextcloud-calendar, nextcloud-mail - Build script with compile-time credentials - Skills for all four tools - Email tool supports IMAP/SMTP with attachment download
This commit is contained in:
3
tools/go/nextcloud-calendar/go.mod
Normal file
3
tools/go/nextcloud-calendar/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/wltbagent/nextcloud-calendar
|
||||
|
||||
go 1.24.4
|
||||
454
tools/go/nextcloud-calendar/main.go
Normal file
454
tools/go/nextcloud-calendar/main.go
Normal file
@@ -0,0 +1,454 @@
|
||||
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname />
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>`)
|
||||
|
||||
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>`)
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user