package handlers import ( "context" "encoding/base64" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ics "github.com/arran4/golang-ical" "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" "github.com/jackc/pgx/v5/pgtype" ) 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 } cal, err := h.queries.GetCalendarByID(r.Context(), utils.ToPgUUID(calID)) if err != nil { utils.WriteError(w, models.ErrInternal) return } h.writeICSFeed(w, r.Context(), cal.Name, calID) } func (h *ICSHandler) PublicFeed(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") if token == "" { utils.WriteError(w, models.ErrNotFound) return } cal, err := h.queries.GetCalendarByToken(r.Context(), pgtype.Text{String: token, Valid: true}) if err != nil { utils.WriteError(w, models.ErrNotFound) return } calID := utils.FromPgUUID(cal.ID) h.writeICSFeed(w, r.Context(), cal.Name, calID) } func (h *ICSHandler) writeICSFeed(w http.ResponseWriter, ctx context.Context, calName string, calID uuid.UUID) { now := time.Now().UTC() rangeStart := now.AddDate(-1, 0, 0) rangeEnd := now.AddDate(1, 0, 0) events, err := h.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{ CalendarID: utils.ToPgUUID(calID), EndTime: utils.ToPgTimestamptz(rangeStart), StartTime: utils.ToPgTimestamptz(rangeEnd), }) if err != nil { utils.WriteError(w, models.ErrInternal) return } eventIDs := make([]uuid.UUID, len(events)) for i, ev := range events { eventIDs[i] = utils.FromPgUUID(ev.ID) } reminderMap := h.loadReminders(ctx, eventIDs) attendeeMap := h.loadAttendees(ctx, eventIDs) calendar := ics.NewCalendar() calendar.SetProductId("-//CalendarAPI//EN") calendar.SetCalscale("GREGORIAN") calendar.SetXWRCalName(calName) for _, ev := range events { evID := utils.FromPgUUID(ev.ID) event := calendar.AddEvent(evID.String() + "@calendarapi") event.SetDtStampTime(time.Now().UTC()) event.SetCreatedTime(utils.FromPgTimestamptz(ev.CreatedAt)) event.SetModifiedAt(utils.FromPgTimestamptz(ev.UpdatedAt)) startTime := utils.FromPgTimestamptz(ev.StartTime) endTime := utils.FromPgTimestamptz(ev.EndTime) if ev.AllDay { event.SetAllDayStartAt(startTime) event.SetAllDayEndAt(endTime) } else { event.SetStartAt(startTime) event.SetEndAt(endTime) } event.SetSummary(ev.Title) if ev.Description.Valid { event.SetDescription(ev.Description.String) } if ev.Location.Valid { event.SetLocation(ev.Location.String) } if ev.RecurrenceRule.Valid { event.AddRrule(ev.RecurrenceRule.String) } event.SetStatus(ics.ObjectStatusConfirmed) for _, rem := range reminderMap[evID] { alarm := event.AddAlarm() alarm.SetAction(ics.ActionDisplay) alarm.SetTrigger(fmt.Sprintf("-PT%dM", rem.MinutesBefore)) alarm.SetProperty(ics.ComponentPropertyDescription, "Reminder") } for _, att := range attendeeMap[evID] { email := "" if att.Email.Valid { email = att.Email.String } if email == "" { continue } partStat := mapStatusToPartStat(att.Status) event.AddAttendee("mailto:"+email, partStat) } } w.Header().Set("Content-Type", "text/calendar; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.ics", calName)) w.WriteHeader(http.StatusOK) w.Write([]byte(calendar.Serialize())) } 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.importICSData(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) ImportURL(w http.ResponseWriter, r *http.Request) { userID, _ := middleware.GetUserID(r.Context()) var req struct { CalendarID string `json:"calendar_id"` URL string `json:"url"` } if err := utils.DecodeJSON(r, &req); err != nil { utils.WriteError(w, err) return } calID, err := utils.ValidateUUID(req.CalendarID) if err != nil { utils.WriteError(w, err) return } if req.URL == "" { utils.WriteError(w, models.NewValidationError("url is required")) return } if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "webcal://") { utils.WriteError(w, models.NewValidationError("url must be http, https, or webcal")) 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 } fetchURL := req.URL if strings.HasPrefix(fetchURL, "webcal://") { fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://") } if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok { fetchURL = normalized } body, fetchErr := fetchICSURL(fetchURL) if fetchErr != nil { utils.WriteError(w, models.NewValidationError(fetchErr.Error())) return } count := h.importICSData(r.Context(), string(body), calID, userID) h.queries.CreateCalendarSubscription(r.Context(), repository.CreateCalendarSubscriptionParams{ ID: utils.ToPgUUID(uuid.New()), CalendarID: utils.ToPgUUID(calID), SourceUrl: req.URL, SyncIntervalMinutes: pgtype.Int4{Valid: false}, }) utils.WriteJSON(w, http.StatusOK, map[string]interface{}{ "ok": true, "imported": map[string]int{"events": count}, "source": req.URL, }) } func (h *ICSHandler) AddFromURL(w http.ResponseWriter, r *http.Request) { userID, _ := middleware.GetUserID(r.Context()) var req struct { URL string `json:"url"` Name *string `json:"name"` Color *string `json:"color"` } if err := utils.DecodeJSON(r, &req); err != nil { utils.WriteError(w, err) return } req.URL = strings.TrimSpace(req.URL) if req.URL == "" { utils.WriteError(w, models.NewValidationError("url is required")) return } if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "webcal://") { utils.WriteError(w, models.NewValidationError("url must be http, https, or webcal")) return } fetchURL := req.URL if strings.HasPrefix(fetchURL, "webcal://") { fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://") } if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok { fetchURL = normalized } body, fetchErr := fetchICSURL(fetchURL) if fetchErr != nil { utils.WriteError(w, models.NewValidationError(fetchErr.Error())) return } name := "Imported Calendar" if req.Name != nil && *req.Name != "" { name = *req.Name } color := "#3B82F6" if req.Color != nil && *req.Color != "" { if err := utils.ValidateColor(*req.Color); err != nil { utils.WriteError(w, err) return } color = *req.Color } cal, err := h.calSvc.Create(r.Context(), userID, name, color) if err != nil { utils.WriteError(w, err) return } calID := cal.ID count := h.importICSData(r.Context(), string(body), calID, userID) h.queries.CreateCalendarSubscription(r.Context(), repository.CreateCalendarSubscriptionParams{ ID: utils.ToPgUUID(uuid.New()), CalendarID: utils.ToPgUUID(calID), SourceUrl: req.URL, SyncIntervalMinutes: pgtype.Int4{Valid: false}, }) utils.WriteJSON(w, http.StatusOK, map[string]interface{}{ "ok": true, "calendar": cal, "imported": map[string]int{"events": count}, "source": req.URL, }) } func (h *ICSHandler) ListSubscriptions(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 } subs, err := h.queries.ListSubscriptionsByCalendar(r.Context(), utils.ToPgUUID(calID)) if err != nil { utils.WriteError(w, models.ErrInternal) return } items := make([]map[string]interface{}, len(subs)) for i, s := range subs { syncMins := (*int32)(nil) if s.SyncIntervalMinutes.Valid { syncMins = &s.SyncIntervalMinutes.Int32 } lastSynced := "" if s.LastSyncedAt.Valid { lastSynced = s.LastSyncedAt.Time.Format(time.RFC3339) } items[i] = map[string]interface{}{ "id": utils.FromPgUUID(s.ID), "calendar_id": utils.FromPgUUID(s.CalendarID), "source_url": s.SourceUrl, "last_synced_at": lastSynced, "sync_interval_minutes": syncMins, "created_at": s.CreatedAt.Time.Format(time.RFC3339), } } utils.WriteJSON(w, http.StatusOK, map[string]interface{}{ "items": items, "page": map[string]interface{}{"limit": 100, "next_cursor": nil}, }) } func (h *ICSHandler) AddSubscription(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 } 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 } var req struct { URL string `json:"url"` SyncIntervalMinutes *int32 `json:"sync_interval_minutes"` } if err := utils.DecodeJSON(r, &req); err != nil { utils.WriteError(w, err) return } if req.URL == "" { utils.WriteError(w, models.NewValidationError("url is required")) return } if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "webcal://") { utils.WriteError(w, models.NewValidationError("url must be http, https, or webcal")) return } fetchURL := req.URL if strings.HasPrefix(fetchURL, "webcal://") { fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://") } if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok { fetchURL = normalized } body, fetchErr := fetchICSURL(fetchURL) if fetchErr != nil { utils.WriteError(w, models.NewValidationError(fetchErr.Error())) return } count := h.importICSData(r.Context(), string(body), calID, userID) syncMins := pgtype.Int4{Valid: false} if req.SyncIntervalMinutes != nil && *req.SyncIntervalMinutes > 0 { syncMins = pgtype.Int4{Int32: *req.SyncIntervalMinutes, Valid: true} } h.queries.CreateCalendarSubscription(r.Context(), repository.CreateCalendarSubscriptionParams{ ID: utils.ToPgUUID(uuid.New()), CalendarID: utils.ToPgUUID(calID), SourceUrl: req.URL, SyncIntervalMinutes: syncMins, }) utils.WriteJSON(w, http.StatusOK, map[string]interface{}{ "ok": true, "imported": map[string]int{"events": count}, "source": req.URL, }) } func (h *ICSHandler) DeleteSubscription(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 } subID, err := utils.ValidateUUID(chi.URLParam(r, "subId")) if err != nil { utils.WriteError(w, err) return } if _, err := h.calSvc.GetRole(r.Context(), calID, userID); err != nil { utils.WriteError(w, err) return } sub, err := h.queries.GetSubscriptionByID(r.Context(), utils.ToPgUUID(subID)) if err != nil { utils.WriteError(w, models.ErrNotFound) return } if utils.FromPgUUID(sub.CalendarID) != calID { utils.WriteError(w, models.ErrNotFound) return } if err := h.queries.DeleteSubscription(r.Context(), utils.ToPgUUID(subID)); err != nil { utils.WriteError(w, models.ErrInternal) return } utils.WriteOK(w) } func (h *ICSHandler) SyncSubscription(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 } subID, err := utils.ValidateUUID(chi.URLParam(r, "subId")) 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 } sub, err := h.queries.GetSubscriptionByID(r.Context(), utils.ToPgUUID(subID)) if err != nil { utils.WriteError(w, models.ErrNotFound) return } if utils.FromPgUUID(sub.CalendarID) != calID { utils.WriteError(w, models.ErrNotFound) return } fetchURL := sub.SourceUrl if strings.HasPrefix(fetchURL, "webcal://") { fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://") } if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok { fetchURL = normalized } body, fetchErr := fetchICSURL(fetchURL) if fetchErr != nil { utils.WriteError(w, models.NewValidationError(fetchErr.Error())) return } count := h.importICSData(r.Context(), string(body), calID, userID) if err := h.queries.UpdateSubscriptionLastSynced(r.Context(), utils.ToPgUUID(subID)); err != nil { utils.WriteError(w, models.ErrInternal) return } utils.WriteJSON(w, http.StatusOK, map[string]interface{}{ "ok": true, "imported": map[string]int{"events": count}, }) } // SyncSubscriptionBackground performs a subscription sync (used by background worker). func (h *ICSHandler) SyncSubscriptionBackground(ctx context.Context, subscriptionID string) error { subID, err := uuid.Parse(subscriptionID) if err != nil { return err } sub, err := h.queries.GetSubscriptionByID(ctx, utils.ToPgUUID(subID)) if err != nil { return err } calID := utils.FromPgUUID(sub.CalendarID) cal, err := h.queries.GetCalendarByID(ctx, sub.CalendarID) if err != nil { return err } ownerID := utils.FromPgUUID(cal.OwnerID) fetchURL := sub.SourceUrl if strings.HasPrefix(fetchURL, "webcal://") { fetchURL = "https://" + strings.TrimPrefix(fetchURL, "webcal://") } if normalized, ok := normalizeGoogleCalendarURL(fetchURL); ok { fetchURL = normalized } body, err := fetchICSURL(fetchURL) if err != nil { return err } _ = h.importICSData(ctx, string(body), calID, ownerID) return h.queries.UpdateSubscriptionLastSynced(ctx, utils.ToPgUUID(subID)) } // normalizeGoogleCalendarURL converts Google Calendar embed/share URLs to the iCal feed format. // Returns (fetchURL, true) if the URL was converted or is already a Google iCal URL. func normalizeGoogleCalendarURL(raw string) (string, bool) { raw = strings.TrimSpace(raw) if !strings.Contains(raw, "calendar.google.com") { return raw, false } // Already an iCal URL - use as-is (may be public or private) if strings.Contains(raw, "/calendar/ical/") && strings.HasSuffix(raw, ".ics") { return raw, true } // Embed URL: https://calendar.google.com/calendar/embed?src=CALENDAR_ID if strings.Contains(raw, "/calendar/embed") { if u, err := url.Parse(raw); err == nil { if src := u.Query().Get("src"); src != "" { decoded, _ := url.QueryUnescape(src) icalURL := "https://calendar.google.com/calendar/ical/" + url.PathEscape(decoded) + "/public/basic.ics" return icalURL, true } } } // Calendar link with cid: https://calendar.google.com/calendar?cid=BASE64_CALENDAR_ID if strings.Contains(raw, "cid=") { if u, err := url.Parse(raw); err == nil { if cid := u.Query().Get("cid"); cid != "" { // cid can be base64, base64url, or base64url without padding var decoded []byte for _, enc := range []*base64.Encoding{base64.URLEncoding, base64.RawURLEncoding, base64.StdEncoding} { d, decodeErr := enc.DecodeString(cid) if decodeErr == nil && len(d) > 0 { decoded = d break } } if len(decoded) > 0 { calID := string(decoded) icalURL := "https://calendar.google.com/calendar/ical/" + url.PathEscape(calID) + "/public/basic.ics" return icalURL, true } } } } return raw, true } // googleCalendarHelp is the error hint shown when Google Calendar requests fail. const googleCalendarHelp = "Use the iCal link from Google: Settings → your calendar → Integrate calendar → copy \"Secret address in iCal format\" (not the embed or share URL)" // fetchICSURL fetches an ICS file from a URL. Google Calendar blocks non-browser User-Agents, // so we use a full Chrome User-Agent. Other providers generally accept it. func fetchICSURL(fetchURL string) ([]byte, error) { req, err := http.NewRequest(http.MethodGet, fetchURL, nil) if err != nil { return nil, fmt.Errorf("invalid URL: %w", err) } // Google Calendar returns 403 for non-browser User-Agents; use a real Chrome UA req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/calendar,text/plain,*/*") if strings.Contains(fetchURL, "calendar.google.com") { req.Header.Set("Referer", "https://calendar.google.com/") } client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch URL: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { if strings.Contains(fetchURL, "calendar.google.com") { switch resp.StatusCode { case http.StatusNotFound: return nil, fmt.Errorf("Google Calendar returned 404. %s", googleCalendarHelp) case http.StatusForbidden: return nil, fmt.Errorf("Google Calendar returned 403 (access denied). %s", googleCalendarHelp) } } return nil, fmt.Errorf("URL returned HTTP %d", resp.StatusCode) } // Google may return 200 with an HTML login/error page instead of ICS bodyStr := string(body) if len(body) > 10 && (body[0] == '<' || strings.HasPrefix(bodyStr, " 0 { if _, err := time.LoadLocation(tzid[0]); err == nil { tz = tzid[0] } } description := "" if p := ev.GetProperty(ics.ComponentPropertyDescription); p != nil { description = p.Value } location := "" if p := ev.GetProperty(ics.ComponentPropertyLocation); p != nil { location = p.Value } rrule := "" if p := ev.GetProperty(ics.ComponentPropertyRrule); p != nil { rrule = p.Value } eventID := uuid.New() _, err := h.queries.CreateEvent(ctx, repository.CreateEventParams{ ID: utils.ToPgUUID(eventID), CalendarID: utils.ToPgUUID(calID), Title: title, Description: utils.ToPgText(description), Location: utils.ToPgText(location), StartTime: utils.ToPgTimestamptz(startTime.UTC()), EndTime: utils.ToPgTimestamptz(endTime.UTC()), Timezone: tz, AllDay: allDay, RecurrenceRule: utils.ToPgText(rrule), Tags: []string{}, CreatedBy: utils.ToPgUUID(userID), UpdatedBy: utils.ToPgUUID(userID), }) if err != nil { return false } for _, alarm := range ev.Alarms() { triggerProp := alarm.GetProperty(ics.ComponentPropertyTrigger) if triggerProp == nil { continue } minutes := parseTriggerMinutes(triggerProp.Value) if minutes > 0 && minutes <= 10080 { h.queries.CreateReminder(ctx, repository.CreateReminderParams{ ID: utils.ToPgUUID(uuid.New()), EventID: utils.ToPgUUID(eventID), MinutesBefore: minutes, }) } } for _, att := range ev.Attendees() { email := strings.TrimPrefix(att.Value, "mailto:") email = strings.TrimPrefix(email, "MAILTO:") if email == "" { continue } h.queries.CreateAttendee(ctx, repository.CreateAttendeeParams{ ID: utils.ToPgUUID(uuid.New()), EventID: utils.ToPgUUID(eventID), Email: pgtype.Text{String: email, Valid: true}, }) } return true } func (h *ICSHandler) loadReminders(ctx context.Context, eventIDs []uuid.UUID) map[uuid.UUID][]repository.EventReminder { m := make(map[uuid.UUID][]repository.EventReminder) for _, id := range eventIDs { rows, err := h.queries.ListRemindersByEvent(ctx, utils.ToPgUUID(id)) if err == nil && len(rows) > 0 { m[id] = rows } } return m } func (h *ICSHandler) loadAttendees(ctx context.Context, eventIDs []uuid.UUID) map[uuid.UUID][]repository.EventAttendee { m := make(map[uuid.UUID][]repository.EventAttendee) for _, id := range eventIDs { rows, err := h.queries.ListAttendeesByEvent(ctx, utils.ToPgUUID(id)) if err == nil && len(rows) > 0 { m[id] = rows } } return m } func mapStatusToPartStat(status string) ics.ParticipationStatus { switch status { case "accepted": return ics.ParticipationStatusAccepted case "declined": return ics.ParticipationStatusDeclined case "tentative": return ics.ParticipationStatusTentative default: return ics.ParticipationStatusNeedsAction } } // parseTriggerMinutes parses an iCal TRIGGER value like "-PT15M", "-PT1H", "-P1D" into minutes. func parseTriggerMinutes(trigger string) int32 { trigger = strings.TrimPrefix(trigger, "-") trigger = strings.TrimPrefix(trigger, "+") if strings.HasPrefix(trigger, "PT") { trigger = strings.TrimPrefix(trigger, "PT") return parseTimePart(trigger) } if strings.HasPrefix(trigger, "P") { trigger = strings.TrimPrefix(trigger, "P") total := int32(0) if idx := strings.Index(trigger, "W"); idx >= 0 { if w, err := strconv.Atoi(trigger[:idx]); err == nil { total += int32(w) * 7 * 24 * 60 } return total } if idx := strings.Index(trigger, "D"); idx >= 0 { if d, err := strconv.Atoi(trigger[:idx]); err == nil { total += int32(d) * 24 * 60 } trigger = trigger[idx+1:] } if strings.HasPrefix(trigger, "T") { total += parseTimePart(strings.TrimPrefix(trigger, "T")) } return total } return 0 } func parseTimePart(s string) int32 { total := int32(0) if idx := strings.Index(s, "H"); idx >= 0 { if h, err := strconv.Atoi(s[:idx]); err == nil { total += int32(h) * 60 } s = s[idx+1:] } if idx := strings.Index(s, "M"); idx >= 0 { if m, err := strconv.Atoi(s[:idx]); err == nil { total += int32(m) } s = s[idx+1:] } if idx := strings.Index(s, "S"); idx >= 0 { if sec, err := strconv.Atoi(s[:idx]); err == nil { total += int32(sec) / 60 } } return total }