Add public/private calendars, full iCal support, and iCal URL import
- Public/private: toggle is_public via PUT /calendars/{id}; generate/clear
public_token and return ical_url when public
- Public feed: GET /cal/{token}/feed.ics (no auth) for subscription in
Google/Apple/Outlook calendars
- Full iCal export: use golang-ical; VALARM, ATTENDEE, all-day (VALUE=DATE),
RRULE, DTSTAMP, CREATED, LAST-MODIFIED
- Full iCal import: parse TZID, VALUE=DATE, VALARM, ATTENDEE, RRULE
- Import from URL: POST /calendars/import-url with calendar_id + url
- Migration: unique index on public_token, calendar_subscriptions table
- Config: BASE_URL for ical_url; Calendar model + API: ical_url field
- Docs: OpenAPI, llms.txt, README, SKILL.md, about/overview
Made-with: Cursor
This commit is contained in:
@@ -5,9 +5,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ics "github.com/arran4/golang-ical"
|
||||
"github.com/calendarapi/internal/middleware"
|
||||
"github.com/calendarapi/internal/models"
|
||||
"github.com/calendarapi/internal/repository"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"github.com/calendarapi/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type ICSHandler struct {
|
||||
@@ -40,11 +43,38 @@ func (h *ICSHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
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.GetCalendarByPublicToken(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(r.Context(), repository.ListEventsByCalendarInRangeParams{
|
||||
events, err := h.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{
|
||||
CalendarID: utils.ToPgUUID(calID),
|
||||
EndTime: utils.ToPgTimestamptz(rangeStart),
|
||||
StartTime: utils.ToPgTimestamptz(rangeEnd),
|
||||
@@ -54,33 +84,76 @@ func (h *ICSHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
eventIDs := make([]uuid.UUID, len(events))
|
||||
for i, ev := range events {
|
||||
eventIDs[i] = utils.FromPgUUID(ev.ID)
|
||||
}
|
||||
|
||||
b.WriteString("END:VCALENDAR\r\n")
|
||||
reminderMap := h.loadReminders(ctx, eventIDs)
|
||||
attendeeMap := h.loadAttendees(ctx, eventIDs)
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=calendar.ics")
|
||||
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(b.String()))
|
||||
w.Write([]byte(calendar.Serialize()))
|
||||
}
|
||||
|
||||
func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -121,7 +194,7 @@ func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
count := h.parseAndImportICS(r.Context(), string(data), calID, userID)
|
||||
count := h.importICSData(r.Context(), string(data), calID, userID)
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
@@ -129,61 +202,292 @@ func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
func (h *ICSHandler) ImportURL(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := middleware.GetUserID(r.Context())
|
||||
|
||||
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:"))
|
||||
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://")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(fetchURL)
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.NewValidationError("failed to fetch URL: "+err.Error()))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
utils.WriteError(w, models.NewValidationError(fmt.Sprintf("URL returned status %d", resp.StatusCode)))
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
utils.WriteError(w, models.ErrInternal)
|
||||
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,
|
||||
})
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"imported": map[string]int{"events": count},
|
||||
"source": req.URL,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ICSHandler) importICSData(ctx context.Context, data string, calID, userID uuid.UUID) int {
|
||||
cal, err := ics.ParseCalendar(strings.NewReader(data))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, ev := range cal.Events() {
|
||||
imported := h.importEvent(ctx, ev, calID, userID)
|
||||
if imported {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (h *ICSHandler) importEvent(ctx context.Context, ev *ics.VEvent, calID, userID uuid.UUID) bool {
|
||||
summaryProp := ev.GetProperty(ics.ComponentPropertySummary)
|
||||
if summaryProp == nil {
|
||||
return false
|
||||
}
|
||||
title := summaryProp.Value
|
||||
|
||||
allDay := false
|
||||
var startTime, endTime time.Time
|
||||
|
||||
dtStartProp := ev.GetProperty(ics.ComponentPropertyDtStart)
|
||||
if dtStartProp == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if dtStartProp.GetValueType() == ics.ValueDataTypeDate {
|
||||
allDay = true
|
||||
var err error
|
||||
startTime, err = ev.GetAllDayStartAt()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
endTime, _ = ev.GetAllDayEndAt()
|
||||
if endTime.IsZero() {
|
||||
endTime = startTime.AddDate(0, 0, 1)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
startTime, err = ev.GetStartAt()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
endTime, _ = ev.GetEndAt()
|
||||
if endTime.IsZero() {
|
||||
endTime = startTime.Add(time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
tz := "UTC"
|
||||
if tzid, ok := dtStartProp.ICalParameters["TZID"]; ok && len(tzid) > 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user