Magnifique
This commit is contained in:
parent
0466a1c169
commit
1fda9e7bac
@ -357,6 +357,20 @@ server {
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
# Réglages du compte Ulti
|
||||
location ^~ /compte {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
||||
proxy_pass http://$mail_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
# Console d'administration suite
|
||||
location ^~ /admin {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@ -12,6 +13,7 @@ import (
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||
@ -37,6 +39,9 @@ func (h *Handler) Routes() chi.Router {
|
||||
r.With(read).Get("/", h.ListCalendars)
|
||||
r.With(read).Get("/{calID}/events", h.ListEvents)
|
||||
r.With(read).Post("/freebusy", h.FreeBusy)
|
||||
r.With(write).Post("/", h.CreateCalendar)
|
||||
r.With(write).Patch("/{calID}", h.UpdateCalendar)
|
||||
r.With(write).Delete("/{calID}", h.DeleteCalendar)
|
||||
r.With(write).Post("/{calID}/events", h.CreateEvent)
|
||||
r.With(write).Put("/events/*", h.UpdateEvent)
|
||||
r.With(write).Post("/events/response/*", h.RespondToInvitation)
|
||||
@ -45,9 +50,46 @@ func (h *Handler) Routes() chi.Router {
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *Handler) nextcloudUser(w http.ResponseWriter, r *http.Request, claims *auth.Claims) (string, bool) {
|
||||
userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
|
||||
if err != nil {
|
||||
h.logger.Error("ensure nextcloud user", "error", err, "sub", claims.Sub, "email", claims.Email)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return "", false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func (h *Handler) retryOnDAVMissing(
|
||||
ctx context.Context,
|
||||
claims *auth.Claims,
|
||||
ncUser string,
|
||||
op func(userID string) error,
|
||||
) error {
|
||||
err := op(ncUser)
|
||||
if !errors.Is(err, nextcloud.ErrDAVCredentialsMissing) {
|
||||
return err
|
||||
}
|
||||
refreshed, reproErr := h.svc.ReprovisionPrincipal(ctx, claims)
|
||||
if reproErr != nil {
|
||||
h.logger.Error("reprovision nextcloud principal", "error", reproErr, "user", ncUser)
|
||||
return err
|
||||
}
|
||||
return op(refreshed)
|
||||
}
|
||||
|
||||
func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
cals, err := h.svc.ListCalendars(r.Context(), claims.Sub)
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var cals []nextcloud.Calendar
|
||||
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
var listErr error
|
||||
cals, listErr = h.svc.ListCalendars(r.Context(), userID)
|
||||
return listErr
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("list calendars", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
@ -56,15 +98,95 @@ func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) {
|
||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"calendars": cals})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req calendarPropsRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
normalized, verr := validateCreateCalendar(&req)
|
||||
if verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
return h.svc.CreateCalendar(r.Context(), userID, normalized.ID, normalized.DisplayName, normalized.Color)
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("create calendar", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusCreated, map[string]any{"id": normalized.ID})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
calID := chi.URLParam(r, "calID")
|
||||
var req calendarPropsRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateUpdateCalendar(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
return h.svc.UpdateCalendar(r.Context(), userID, calID, strings.TrimSpace(req.DisplayName), strings.TrimSpace(req.Color))
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("update calendar", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
calID := chi.URLParam(r, "calID")
|
||||
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
return h.svc.DeleteCalendar(r.Context(), userID, calID)
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("delete calendar", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListEvents(r.Context(), claims.Sub, chi.URLParam(r, "calID"), params)
|
||||
calID := chi.URLParam(r, "calID")
|
||||
var result EventsList
|
||||
err = h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
var listErr error
|
||||
result, listErr = h.svc.ListEvents(r.Context(), userID, calID, params)
|
||||
return listErr
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("list events", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
@ -75,6 +197,10 @@ func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) FreeBusy(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req freeBusyRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
|
||||
@ -86,7 +212,12 @@ func (h *Handler) FreeBusy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.FreeBusy(r.Context(), claims.Sub, ncReq)
|
||||
var result *nextcloud.FreeBusyResponse
|
||||
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
var fbErr error
|
||||
result, fbErr = h.svc.FreeBusy(r.Context(), userID, ncReq)
|
||||
return fbErr
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("free/busy", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
@ -97,6 +228,10 @@ func (h *Handler) FreeBusy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var event nextcloud.Event
|
||||
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &event); err != nil {
|
||||
@ -107,7 +242,14 @@ func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.CreateEvent(r.Context(), claims.Sub, chi.URLParam(r, "calID"), &event); err != nil {
|
||||
calID := chi.URLParam(r, "calID")
|
||||
if strings.TrimSpace(event.Organizer) == "" {
|
||||
event.Organizer = claims.Email
|
||||
}
|
||||
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
return h.svc.CreateEvent(r.Context(), userID, calID, &event)
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("create event", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
@ -117,6 +259,10 @@ func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/")
|
||||
if verr := validateDeletePath(eventPath); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
@ -139,7 +285,15 @@ func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
etag, err := h.svc.UpdateEvent(r.Context(), claims.Sub, eventPath, ifMatch, &event)
|
||||
if strings.TrimSpace(event.Organizer) == "" {
|
||||
event.Organizer = claims.Email
|
||||
}
|
||||
var etag string
|
||||
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
var updErr error
|
||||
etag, updErr = h.svc.UpdateEvent(r.Context(), userID, eventPath, ifMatch, &event)
|
||||
return updErr
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, nextcloud.ErrETagMismatch) {
|
||||
apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil)
|
||||
@ -154,6 +308,10 @@ func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) RespondToInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/")
|
||||
if verr := validateDeletePath(eventPath); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
@ -171,7 +329,12 @@ func (h *Handler) RespondToInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
etag, err := h.svc.RespondToInvitation(r.Context(), claims.Sub, eventPath, normalized.Email, normalized.Response, normalized.IfMatch)
|
||||
var etag string
|
||||
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
var respErr error
|
||||
etag, respErr = h.svc.RespondToInvitation(r.Context(), userID, eventPath, normalized.Email, normalized.Response, normalized.IfMatch)
|
||||
return respErr
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, nextcloud.ErrAttendeeNotFound):
|
||||
@ -189,6 +352,10 @@ func (h *Handler) RespondToInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) CreateMeetLink(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
eventPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/")
|
||||
if verr := validateDeletePath(eventPath); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
@ -204,7 +371,12 @@ func (h *Handler) CreateMeetLink(w http.ResponseWriter, r *http.Request) {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
meetURL, etag, err := h.svc.CreateMeetLink(r.Context(), claims.Sub, claims.Name, claims.Email, eventPath, req.IfMatch)
|
||||
var meetURL, etag string
|
||||
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
var linkErr error
|
||||
meetURL, etag, linkErr = h.svc.CreateMeetLink(r.Context(), userID, claims.Name, claims.Email, eventPath, req.IfMatch)
|
||||
return linkErr
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrMeetDisabled) {
|
||||
apiresponse.WriteError(w, r, http.StatusConflict, "meet_disabled", "meet integration is disabled", nil)
|
||||
@ -226,12 +398,19 @@ func (h *Handler) CreateMeetLink(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
eventPath := chi.URLParam(r, "*")
|
||||
if verr := validateDeletePath(eventPath); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteEvent(r.Context(), claims.Sub, eventPath); err != nil {
|
||||
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
return h.svc.DeleteEvent(r.Context(), userID, eventPath)
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("delete event", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
@ -24,6 +25,25 @@ func NewService(nc *nextcloud.Client, meetCfg *meetpkg.Config) *Service {
|
||||
return &Service{nc: nc, meetCfg: meetCfg}
|
||||
}
|
||||
|
||||
func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {
|
||||
if s.nc == nil {
|
||||
return "", fmt.Errorf("nextcloud unavailable")
|
||||
}
|
||||
return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name)
|
||||
}
|
||||
|
||||
// ReprovisionPrincipal drops cached CalDAV credentials and provisions a fresh app password.
|
||||
func (s *Service) ReprovisionPrincipal(ctx context.Context, claims *auth.Claims) (string, error) {
|
||||
if s.nc == nil {
|
||||
return "", fmt.Errorf("nextcloud unavailable")
|
||||
}
|
||||
userID := nextcloud.UserIDFromClaims(claims.Email, claims.Sub)
|
||||
if err := s.nc.InvalidatePrincipalCredentials(ctx, userID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name)
|
||||
}
|
||||
|
||||
func calendarPath(userID, calID string) string {
|
||||
return "/remote.php/dav/calendars/" + userID + "/" + calID + "/"
|
||||
}
|
||||
@ -32,6 +52,18 @@ func (s *Service) ListCalendars(ctx context.Context, userID string) ([]nextcloud
|
||||
return s.nc.ListCalendars(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) CreateCalendar(ctx context.Context, userID, calID, displayName, color string) error {
|
||||
return s.nc.CreateCalendar(ctx, userID, calID, displayName, color)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateCalendar(ctx context.Context, userID, calID, displayName, color string) error {
|
||||
return s.nc.UpdateCalendar(ctx, userID, calID, displayName, color)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteCalendar(ctx context.Context, userID, calID string) error {
|
||||
return s.nc.DeleteCalendar(ctx, userID, calID)
|
||||
}
|
||||
|
||||
type EventsList struct {
|
||||
Events []nextcloud.Event `json:"events"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -34,6 +35,115 @@ type createMeetLinkRequest struct {
|
||||
IfMatch string `json:"if_match,omitempty"`
|
||||
}
|
||||
|
||||
type calendarPropsRequest struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
type normalizedCalendarProps struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
Color string
|
||||
}
|
||||
|
||||
func isHexColor(value string) bool {
|
||||
if len(value) != 7 && len(value) != 9 {
|
||||
return false
|
||||
}
|
||||
if value[0] != '#' {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[1:] {
|
||||
switch {
|
||||
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// slugifyCalendarID derives a DAV-safe collection ID from a display name.
|
||||
func slugifyCalendarID(name string) string {
|
||||
var b strings.Builder
|
||||
lastDash := true
|
||||
for _, r := range strings.ToLower(name) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
default:
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
func isValidCalendarID(id string) bool {
|
||||
if id == "" || len(id) > 120 {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateCreateCalendar(req *calendarPropsRequest) (*normalizedCalendarProps, *apivalidate.ValidationError) {
|
||||
var details []apivalidate.FieldDetail
|
||||
name := strings.TrimSpace(req.DisplayName)
|
||||
if name == "" {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "display_name", Message: "required"})
|
||||
} else if len(name) > 200 {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "display_name", Message: "too long"})
|
||||
}
|
||||
color := strings.TrimSpace(req.Color)
|
||||
if color != "" && !isHexColor(color) {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "color", Message: "must be a #RRGGBB hex color"})
|
||||
}
|
||||
id := strings.TrimSpace(req.ID)
|
||||
if id == "" {
|
||||
id = slugifyCalendarID(name)
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("cal-%d", time.Now().UnixNano())
|
||||
}
|
||||
}
|
||||
if !isValidCalendarID(id) {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "id", Message: "must contain only a-z, 0-9, - and _"})
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return nil, apivalidate.NewValidationError(details...)
|
||||
}
|
||||
return &normalizedCalendarProps{ID: id, DisplayName: name, Color: color}, nil
|
||||
}
|
||||
|
||||
func validateUpdateCalendar(req *calendarPropsRequest) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
name := strings.TrimSpace(req.DisplayName)
|
||||
color := strings.TrimSpace(req.Color)
|
||||
if name == "" && color == "" {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "display_name", Message: "display_name or color required"})
|
||||
}
|
||||
if len(name) > 200 {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "display_name", Message: "too long"})
|
||||
}
|
||||
if color != "" && !isHexColor(color) {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "color", Message: "must be a #RRGGBB hex color"})
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFreeBusy(req *freeBusyRequest) (*nextcloud.FreeBusyRequest, *apivalidate.ValidationError) {
|
||||
var details []apivalidate.FieldDetail
|
||||
|
||||
|
||||
@ -31,6 +31,9 @@ type Event struct {
|
||||
Organizer string `json:"organizer,omitempty"`
|
||||
Attendees []EventAttendee `json:"attendees,omitempty"`
|
||||
MeetURL string `json:"meet_url,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
RRule string `json:"rrule,omitempty"`
|
||||
ExDates []string `json:"exdates,omitempty"`
|
||||
RawICS string `json:"raw_ics,omitempty"`
|
||||
}
|
||||
|
||||
@ -90,6 +93,77 @@ func (c *Client) ListCalendars(ctx context.Context, userID string) ([]Calendar,
|
||||
return parseCalendarList(resp.Body, path)
|
||||
}
|
||||
|
||||
// CreateCalendar provisions a new calendar collection via MKCALENDAR.
|
||||
func (c *Client) CreateCalendar(ctx context.Context, userID, calID, displayName, color string) error {
|
||||
path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID)
|
||||
var props strings.Builder
|
||||
props.WriteString(fmt.Sprintf("<d:displayname>%s</d:displayname>", xmlEscape(displayName)))
|
||||
if strings.TrimSpace(color) != "" {
|
||||
props.WriteString(fmt.Sprintf("<a:calendar-color>%s</a:calendar-color>", xmlEscape(color)))
|
||||
}
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/">
|
||||
<d:set><d:prop>%s</d:prop></d:set>
|
||||
</c:mkcalendar>`, props.String())
|
||||
|
||||
resp, err := c.DoAsUser(ctx, "MKCALENDAR", path, strings.NewReader(body), userID, map[string]string{
|
||||
"Content-Type": "application/xml",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return fmt.Errorf("create calendar failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCalendar patches display name and/or color via PROPPATCH.
|
||||
func (c *Client) UpdateCalendar(ctx context.Context, userID, calID, displayName, color string) error {
|
||||
path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID)
|
||||
var props strings.Builder
|
||||
if strings.TrimSpace(displayName) != "" {
|
||||
props.WriteString(fmt.Sprintf("<d:displayname>%s</d:displayname>", xmlEscape(displayName)))
|
||||
}
|
||||
if strings.TrimSpace(color) != "" {
|
||||
props.WriteString(fmt.Sprintf("<a:calendar-color>%s</a:calendar-color>", xmlEscape(color)))
|
||||
}
|
||||
if props.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:a="http://apple.com/ns/ical/">
|
||||
<d:set><d:prop>%s</d:prop></d:set>
|
||||
</d:propertyupdate>`, props.String())
|
||||
|
||||
resp, err := c.DoAsUser(ctx, "PROPPATCH", path, strings.NewReader(body), userID, map[string]string{
|
||||
"Content-Type": "application/xml",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("update calendar failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCalendar removes a calendar collection and all of its events.
|
||||
func (c *Client) DeleteCalendar(ctx context.Context, userID, calID string) error {
|
||||
path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID)
|
||||
resp, err := c.DoAsUser(ctx, "DELETE", path, nil, userID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("delete calendar failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ListEvents(ctx context.Context, userID, calendarPath string, from, to time.Time) ([]Event, error) {
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
@ -317,6 +391,59 @@ func extractMailto(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// escapeICSText escapes TEXT property values per RFC 5545 §3.3.11.
|
||||
func escapeICSText(value string) string {
|
||||
value = strings.ReplaceAll(value, "\\", "\\\\")
|
||||
value = strings.ReplaceAll(value, "\r\n", "\n")
|
||||
value = strings.ReplaceAll(value, "\r", "\n")
|
||||
value = strings.ReplaceAll(value, "\n", "\\n")
|
||||
value = strings.ReplaceAll(value, ";", "\\;")
|
||||
value = strings.ReplaceAll(value, ",", "\\,")
|
||||
return value
|
||||
}
|
||||
|
||||
func unescapeICSText(value string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] == '\\' && i+1 < len(value) {
|
||||
switch value[i+1] {
|
||||
case 'n', 'N':
|
||||
b.WriteByte('\n')
|
||||
i++
|
||||
continue
|
||||
case '\\', ';', ',':
|
||||
b.WriteByte(value[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(value[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isDateOnlyValue(value string) bool {
|
||||
if len(value) != 8 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeDateProp(b *strings.Builder, name, value string, allDay bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if allDay || isDateOnlyValue(value) {
|
||||
fmt.Fprintf(b, "%s;VALUE=DATE:%s\r\n", name, value)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "%s:%s\r\n", name, value)
|
||||
}
|
||||
|
||||
func buildICS(event *Event) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("BEGIN:VCALENDAR\r\n")
|
||||
@ -326,12 +453,12 @@ func buildICS(event *Event) string {
|
||||
if event.UID != "" {
|
||||
b.WriteString(fmt.Sprintf("UID:%s\r\n", event.UID))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", event.Summary))
|
||||
b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", escapeICSText(event.Summary)))
|
||||
if event.Description != "" {
|
||||
b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", event.Description))
|
||||
b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", escapeICSText(event.Description)))
|
||||
}
|
||||
if event.Location != "" {
|
||||
b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", event.Location))
|
||||
b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", escapeICSText(event.Location)))
|
||||
}
|
||||
if event.Organizer != "" {
|
||||
b.WriteString(fmt.Sprintf("ORGANIZER:mailto:%s\r\n", event.Organizer))
|
||||
@ -357,8 +484,25 @@ func buildICS(event *Event) string {
|
||||
b.WriteString(fmt.Sprintf("URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
|
||||
b.WriteString(fmt.Sprintf("X-ULTI-MEET-URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", event.Start))
|
||||
b.WriteString(fmt.Sprintf("DTEND:%s\r\n", event.End))
|
||||
if strings.TrimSpace(event.Color) != "" {
|
||||
b.WriteString(fmt.Sprintf("COLOR:%s\r\n", strings.TrimSpace(event.Color)))
|
||||
}
|
||||
if strings.TrimSpace(event.RRule) != "" {
|
||||
b.WriteString(fmt.Sprintf("RRULE:%s\r\n", strings.TrimSpace(event.RRule)))
|
||||
}
|
||||
for _, exDate := range event.ExDates {
|
||||
exDate = strings.TrimSpace(exDate)
|
||||
if exDate == "" {
|
||||
continue
|
||||
}
|
||||
if isDateOnlyValue(exDate) {
|
||||
b.WriteString(fmt.Sprintf("EXDATE;VALUE=DATE:%s\r\n", exDate))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("EXDATE:%s\r\n", exDate))
|
||||
}
|
||||
}
|
||||
writeDateProp(&b, "DTSTART", event.Start, event.AllDay)
|
||||
writeDateProp(&b, "DTEND", event.End, event.AllDay)
|
||||
b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
|
||||
b.WriteString("END:VEVENT\r\n")
|
||||
b.WriteString("END:VCALENDAR\r\n")
|
||||
@ -408,31 +552,144 @@ func parseEventList(body io.Reader) ([]Event, error) {
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// unfoldICSLines splits raw ICS into logical lines, joining folded
|
||||
// continuation lines (RFC 5545 §3.1: lines starting with space or tab).
|
||||
func unfoldICSLines(ics string) []string {
|
||||
raw := strings.Split(strings.ReplaceAll(ics, "\r\n", "\n"), "\n")
|
||||
lines := make([]string, 0, len(raw))
|
||||
for _, l := range raw {
|
||||
if (strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t")) && len(lines) > 0 {
|
||||
lines[len(lines)-1] += strings.TrimLeft(l, " \t")
|
||||
continue
|
||||
}
|
||||
l = strings.TrimRight(l, "\r")
|
||||
if l != "" {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// parsePropLine splits "NAME;PARAM=V;PARAM2=V2:value" into name, params, value.
|
||||
func parsePropLine(line string) (name string, params map[string]string, value string) {
|
||||
head, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
return strings.ToUpper(line), nil, ""
|
||||
}
|
||||
parts := strings.Split(head, ";")
|
||||
name = strings.ToUpper(strings.TrimSpace(parts[0]))
|
||||
if len(parts) > 1 {
|
||||
params = make(map[string]string, len(parts)-1)
|
||||
for _, p := range parts[1:] {
|
||||
k, v, found := strings.Cut(p, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
params[strings.ToUpper(strings.TrimSpace(k))] = strings.Trim(strings.TrimSpace(v), "\"")
|
||||
}
|
||||
}
|
||||
return name, params, val
|
||||
}
|
||||
|
||||
// normalizeICSDate converts a DTSTART/DTEND value to either a date-only value
|
||||
// (YYYYMMDD) or a UTC datetime (YYYYMMDDTHHMMSSZ) when a TZID is provided.
|
||||
// Floating times without TZID are returned unchanged.
|
||||
func normalizeICSDate(value string, params map[string]string) (normalized string, allDay bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if params["VALUE"] == "DATE" || isDateOnlyValue(value) {
|
||||
return value, true
|
||||
}
|
||||
if strings.HasSuffix(value, "Z") {
|
||||
return value, false
|
||||
}
|
||||
if tzid := params["TZID"]; tzid != "" {
|
||||
if loc, err := time.LoadLocation(tzid); err == nil {
|
||||
if t, err := time.ParseInLocation("20060102T150405", value, loc); err == nil {
|
||||
return t.UTC().Format("20060102T150405Z"), false
|
||||
}
|
||||
}
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
// extractVEventBlock returns the logical lines of the primary VEVENT in an ICS
|
||||
// payload: the master component (no RECURRENCE-ID) when present, else the first.
|
||||
func extractVEventBlock(lines []string) []string {
|
||||
var blocks [][]string
|
||||
var current []string
|
||||
depth := 0
|
||||
for _, line := range lines {
|
||||
upper := strings.ToUpper(line)
|
||||
switch {
|
||||
case upper == "BEGIN:VEVENT":
|
||||
depth++
|
||||
current = nil
|
||||
case upper == "END:VEVENT":
|
||||
if depth > 0 {
|
||||
blocks = append(blocks, current)
|
||||
depth = 0
|
||||
}
|
||||
case depth > 0:
|
||||
current = append(current, line)
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, b := range blocks {
|
||||
master := true
|
||||
for _, line := range b {
|
||||
if strings.HasPrefix(strings.ToUpper(line), "RECURRENCE-ID") {
|
||||
master = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if master {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return blocks[0]
|
||||
}
|
||||
|
||||
func parseICS(ics string) Event {
|
||||
var e Event
|
||||
for _, line := range strings.Split(ics, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "UID:"):
|
||||
e.UID = strings.TrimPrefix(line, "UID:")
|
||||
case strings.HasPrefix(line, "SUMMARY:"):
|
||||
e.Summary = strings.TrimPrefix(line, "SUMMARY:")
|
||||
case strings.HasPrefix(line, "DESCRIPTION:"):
|
||||
e.Description = strings.TrimPrefix(line, "DESCRIPTION:")
|
||||
case strings.HasPrefix(line, "LOCATION:"):
|
||||
e.Location = strings.TrimPrefix(line, "LOCATION:")
|
||||
case strings.HasPrefix(line, "ORGANIZER"):
|
||||
e.Organizer = extractMailto(extractValue(line))
|
||||
case strings.HasPrefix(line, "ATTENDEE"):
|
||||
block := extractVEventBlock(unfoldICSLines(ics))
|
||||
for _, line := range block {
|
||||
name, params, value := parsePropLine(line)
|
||||
switch name {
|
||||
case "UID":
|
||||
e.UID = value
|
||||
case "SUMMARY":
|
||||
e.Summary = unescapeICSText(value)
|
||||
case "DESCRIPTION":
|
||||
e.Description = unescapeICSText(value)
|
||||
case "LOCATION":
|
||||
e.Location = unescapeICSText(value)
|
||||
case "ORGANIZER":
|
||||
e.Organizer = extractMailto(value)
|
||||
case "ATTENDEE":
|
||||
e.Attendees = append(e.Attendees, parseAttendeeLine(line))
|
||||
case strings.HasPrefix(line, "X-ULTI-MEET-URL:"):
|
||||
e.MeetURL = strings.TrimPrefix(line, "X-ULTI-MEET-URL:")
|
||||
case strings.HasPrefix(line, "URL:") && e.MeetURL == "":
|
||||
e.MeetURL = strings.TrimPrefix(line, "URL:")
|
||||
case strings.HasPrefix(line, "DTSTART"):
|
||||
e.Start = extractValue(line)
|
||||
case strings.HasPrefix(line, "DTEND"):
|
||||
e.End = extractValue(line)
|
||||
case "X-ULTI-MEET-URL":
|
||||
e.MeetURL = value
|
||||
case "URL":
|
||||
if e.MeetURL == "" {
|
||||
e.MeetURL = value
|
||||
}
|
||||
case "COLOR":
|
||||
e.Color = value
|
||||
case "RRULE":
|
||||
e.RRule = value
|
||||
case "EXDATE":
|
||||
for _, ex := range strings.Split(value, ",") {
|
||||
normalized, _ := normalizeICSDate(ex, params)
|
||||
if normalized != "" {
|
||||
e.ExDates = append(e.ExDates, normalized)
|
||||
}
|
||||
}
|
||||
case "DTSTART":
|
||||
e.Start, e.AllDay = normalizeICSDate(value, params)
|
||||
case "DTEND":
|
||||
e.End, _ = normalizeICSDate(value, params)
|
||||
}
|
||||
}
|
||||
return e
|
||||
|
||||
157
internal/nextcloud/calendar_test.go
Normal file
157
internal/nextcloud/calendar_test.go
Normal file
@ -0,0 +1,157 @@
|
||||
package nextcloud
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseICSBasics(t *testing.T) {
|
||||
ics := strings.Join([]string{
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"BEGIN:VTIMEZONE",
|
||||
"TZID:Europe/Paris",
|
||||
"BEGIN:STANDARD",
|
||||
"DTSTART:19961027T030000",
|
||||
"END:STANDARD",
|
||||
"END:VTIMEZONE",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:abc-123",
|
||||
"SUMMARY:Réunion\\, équipe",
|
||||
"DESCRIPTION:Ligne 1\\nLigne 2",
|
||||
"DTSTART;TZID=Europe/Paris:20260611T100000",
|
||||
"DTEND;TZID=Europe/Paris:20260611T110000",
|
||||
"RRULE:FREQ=WEEKLY;BYDAY=TH",
|
||||
"EXDATE;TZID=Europe/Paris:20260618T100000",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
}, "\r\n")
|
||||
|
||||
e := parseICS(ics)
|
||||
if e.UID != "abc-123" {
|
||||
t.Fatalf("UID = %q", e.UID)
|
||||
}
|
||||
if e.Summary != "Réunion, équipe" {
|
||||
t.Fatalf("Summary = %q", e.Summary)
|
||||
}
|
||||
if e.Description != "Ligne 1\nLigne 2" {
|
||||
t.Fatalf("Description = %q", e.Description)
|
||||
}
|
||||
// VTIMEZONE DTSTART must not leak into the event.
|
||||
if e.Start != "20260611T080000Z" {
|
||||
t.Fatalf("Start = %q, want UTC-converted 20260611T080000Z", e.Start)
|
||||
}
|
||||
if e.End != "20260611T090000Z" {
|
||||
t.Fatalf("End = %q", e.End)
|
||||
}
|
||||
if e.AllDay {
|
||||
t.Fatal("AllDay should be false")
|
||||
}
|
||||
if e.RRule != "FREQ=WEEKLY;BYDAY=TH" {
|
||||
t.Fatalf("RRule = %q", e.RRule)
|
||||
}
|
||||
if len(e.ExDates) != 1 || e.ExDates[0] != "20260618T080000Z" {
|
||||
t.Fatalf("ExDates = %v", e.ExDates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSAllDayAndFolding(t *testing.T) {
|
||||
ics := strings.Join([]string{
|
||||
"BEGIN:VCALENDAR",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:x",
|
||||
"SUMMARY:Long title that",
|
||||
" continues folded",
|
||||
"DTSTART;VALUE=DATE:20260611",
|
||||
"DTEND;VALUE=DATE:20260612",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
}, "\r\n")
|
||||
|
||||
e := parseICS(ics)
|
||||
if !e.AllDay {
|
||||
t.Fatal("AllDay should be true")
|
||||
}
|
||||
if e.Start != "20260611" || e.End != "20260612" {
|
||||
t.Fatalf("Start/End = %q/%q", e.Start, e.End)
|
||||
}
|
||||
if e.Summary != "Long title thatcontinues folded" {
|
||||
t.Fatalf("Summary = %q", e.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSPrefersMasterVEvent(t *testing.T) {
|
||||
ics := strings.Join([]string{
|
||||
"BEGIN:VCALENDAR",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:rec",
|
||||
"RECURRENCE-ID:20260618T100000Z",
|
||||
"SUMMARY:Exception",
|
||||
"DTSTART:20260618T120000Z",
|
||||
"DTEND:20260618T130000Z",
|
||||
"END:VEVENT",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:rec",
|
||||
"SUMMARY:Master",
|
||||
"DTSTART:20260611T100000Z",
|
||||
"DTEND:20260611T110000Z",
|
||||
"RRULE:FREQ=DAILY",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
}, "\r\n")
|
||||
|
||||
e := parseICS(ics)
|
||||
if e.Summary != "Master" {
|
||||
t.Fatalf("Summary = %q, want master VEVENT", e.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildICSRoundTrip(t *testing.T) {
|
||||
event := &Event{
|
||||
UID: "round-trip",
|
||||
Summary: "Point; hebdo, équipe",
|
||||
Description: "Ordre du jour:\npoint 1",
|
||||
Location: "Salle A",
|
||||
Start: "20260611T100000Z",
|
||||
End: "20260611T110000Z",
|
||||
RRule: "FREQ=WEEKLY;BYDAY=TH",
|
||||
ExDates: []string{"20260618T100000Z"},
|
||||
Color: "#1A73E8",
|
||||
Attendees: []EventAttendee{
|
||||
{Email: "invite@example.com", Name: "Invité", Status: "ACCEPTED"},
|
||||
},
|
||||
}
|
||||
parsed := parseICS(buildICS(event))
|
||||
if parsed.Summary != event.Summary {
|
||||
t.Fatalf("Summary = %q", parsed.Summary)
|
||||
}
|
||||
if parsed.Description != event.Description {
|
||||
t.Fatalf("Description = %q", parsed.Description)
|
||||
}
|
||||
if parsed.RRule != event.RRule {
|
||||
t.Fatalf("RRule = %q", parsed.RRule)
|
||||
}
|
||||
if parsed.Color != event.Color {
|
||||
t.Fatalf("Color = %q", parsed.Color)
|
||||
}
|
||||
if len(parsed.ExDates) != 1 || parsed.ExDates[0] != "20260618T100000Z" {
|
||||
t.Fatalf("ExDates = %v", parsed.ExDates)
|
||||
}
|
||||
if len(parsed.Attendees) != 1 || parsed.Attendees[0].Email != "invite@example.com" || parsed.Attendees[0].Status != "ACCEPTED" {
|
||||
t.Fatalf("Attendees = %+v", parsed.Attendees)
|
||||
}
|
||||
if parsed.Start != event.Start || parsed.End != event.End {
|
||||
t.Fatalf("Start/End = %q/%q", parsed.Start, parsed.End)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildICSAllDay(t *testing.T) {
|
||||
ics := buildICS(&Event{UID: "ad", Summary: "Férié", Start: "20260714", End: "20260715", AllDay: true})
|
||||
if !strings.Contains(ics, "DTSTART;VALUE=DATE:20260714") {
|
||||
t.Fatalf("missing all-day DTSTART:\n%s", ics)
|
||||
}
|
||||
parsed := parseICS(ics)
|
||||
if !parsed.AllDay {
|
||||
t.Fatal("AllDay should round-trip")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user