package handlers import ( "context" "fmt" "io" "net/http" "strings" "time" "github.com/calendarapi/internal/middleware" "github.com/calendarapi/internal/models" "github.com/calendarapi/internal/repository" "github.com/calendarapi/internal/service" "github.com/calendarapi/internal/utils" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) type ICSHandler struct { calSvc *service.CalendarService eventSvc *service.EventService queries *repository.Queries } func NewICSHandler(calSvc *service.CalendarService, eventSvc *service.EventService, queries *repository.Queries) *ICSHandler { return &ICSHandler{calSvc: calSvc, eventSvc: eventSvc, queries: queries} } func (h *ICSHandler) Export(w http.ResponseWriter, r *http.Request) { userID, _ := middleware.GetUserID(r.Context()) calID, err := utils.ValidateUUID(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, err) return } if _, err := h.calSvc.GetRole(r.Context(), calID, userID); err != nil { utils.WriteError(w, err) return } now := time.Now().UTC() rangeStart := now.AddDate(-1, 0, 0) rangeEnd := now.AddDate(1, 0, 0) events, err := h.queries.ListEventsByCalendarInRange(r.Context(), repository.ListEventsByCalendarInRangeParams{ CalendarID: utils.ToPgUUID(calID), EndTime: utils.ToPgTimestamptz(rangeStart), StartTime: utils.ToPgTimestamptz(rangeEnd), }) if err != nil { utils.WriteError(w, models.ErrInternal) return } var b strings.Builder b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//CalendarAPI//EN\r\n") for _, ev := range events { b.WriteString("BEGIN:VEVENT\r\n") b.WriteString(fmt.Sprintf("UID:%s\r\n", utils.FromPgUUID(ev.ID).String())) b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", utils.FromPgTimestamptz(ev.StartTime).Format("20060102T150405Z"))) b.WriteString(fmt.Sprintf("DTEND:%s\r\n", utils.FromPgTimestamptz(ev.EndTime).Format("20060102T150405Z"))) b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", ev.Title)) if ev.Description.Valid { b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", ev.Description.String)) } if ev.Location.Valid { b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", ev.Location.String)) } if ev.RecurrenceRule.Valid { b.WriteString(fmt.Sprintf("RRULE:%s\r\n", ev.RecurrenceRule.String)) } b.WriteString("END:VEVENT\r\n") } b.WriteString("END:VCALENDAR\r\n") w.Header().Set("Content-Type", "text/calendar") w.Header().Set("Content-Disposition", "attachment; filename=calendar.ics") w.WriteHeader(http.StatusOK) w.Write([]byte(b.String())) } func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) { userID, _ := middleware.GetUserID(r.Context()) if err := r.ParseMultipartForm(10 << 20); err != nil { utils.WriteError(w, models.NewValidationError("invalid multipart form")) return } calIDStr := r.FormValue("calendar_id") calID, err := utils.ValidateUUID(calIDStr) if err != nil { utils.WriteError(w, err) return } role, err := h.calSvc.GetRole(r.Context(), calID, userID) if err != nil { utils.WriteError(w, err) return } if role != "owner" && role != "editor" { utils.WriteError(w, models.ErrForbidden) return } file, _, err := r.FormFile("file") if err != nil { utils.WriteError(w, models.NewValidationError("file required")) return } defer file.Close() data, err := io.ReadAll(file) if err != nil { utils.WriteError(w, models.ErrInternal) return } count := h.parseAndImportICS(r.Context(), string(data), calID, userID) utils.WriteJSON(w, http.StatusOK, map[string]interface{}{ "ok": true, "imported": map[string]int{"events": count}, }) } func (h *ICSHandler) parseAndImportICS(ctx context.Context, data string, calID, userID uuid.UUID) int { count := 0 lines := strings.Split(data, "\n") var inEvent bool var title, description, location, rruleStr string var dtstart, dtend time.Time for _, line := range lines { line = strings.TrimSpace(line) if line == "BEGIN:VEVENT" { inEvent = true title, description, location, rruleStr = "", "", "", "" dtstart, dtend = time.Time{}, time.Time{} continue } if line == "END:VEVENT" && inEvent { inEvent = false if title != "" && !dtstart.IsZero() && !dtend.IsZero() { _, err := h.queries.CreateEvent(ctx, repository.CreateEventParams{ ID: utils.ToPgUUID(uuid.New()), CalendarID: utils.ToPgUUID(calID), Title: title, Description: utils.ToPgText(description), Location: utils.ToPgText(location), StartTime: utils.ToPgTimestamptz(dtstart), EndTime: utils.ToPgTimestamptz(dtend), Timezone: "UTC", RecurrenceRule: utils.ToPgText(rruleStr), Tags: []string{}, CreatedBy: utils.ToPgUUID(userID), UpdatedBy: utils.ToPgUUID(userID), }) if err == nil { count++ } } continue } if !inEvent { continue } switch { case strings.HasPrefix(line, "SUMMARY:"): title = strings.TrimPrefix(line, "SUMMARY:") case strings.HasPrefix(line, "DESCRIPTION:"): description = strings.TrimPrefix(line, "DESCRIPTION:") case strings.HasPrefix(line, "LOCATION:"): location = strings.TrimPrefix(line, "LOCATION:") case strings.HasPrefix(line, "RRULE:"): rruleStr = strings.TrimPrefix(line, "RRULE:") case strings.HasPrefix(line, "DTSTART:"): dtstart, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTSTART:")) case strings.HasPrefix(line, "DTEND:"): dtend, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTEND:")) } } return count }