package calendar import ( "context" "io" "io/fs" "net/http" "os" "slices" "time" ical "github.com/arran4/golang-ical" "gitlab.com/tozd/go/errors" "go.alanpearce.eu/x/log" "go.alanpearce.eu/homestead/internal/cache" "go.alanpearce.eu/homestead/internal/config" ) const Filename = "calendar.ics" const Refresh = 30 * time.Minute type Options struct { URL config.URL } type Calendar struct { opts *Options log *log.Logger client *http.Client *ical.Calendar } type Event struct { ical.VEvent StartTime Date EndTime Date } type Date struct { time.Time } type CalendarDate struct { Date Events []*Event } func New(opts *Options, logger *log.Logger) *Calendar { if opts.URL.Scheme == "webcal" { opts.URL.Scheme = "https" } return &Calendar{ opts: opts, log: logger, client: &http.Client{ Timeout: time.Second * 10, }, } } func (c *Calendar) FetchIfNeeded(ctx context.Context) error { stat, err := cache.Root.Stat(Filename) if err != nil && !errors.Is(err, fs.ErrNotExist) { return errors.WithMessage(err, "could not stat calendar file") } if stat == nil || time.Since(stat.ModTime()) > Refresh { err := c.fetch(ctx) if err != nil { return err } } f, err := c.open() if err != nil { return err } defer f.Close() c.Calendar, err = ical.ParseCalendar(f) if err != nil { c.log.Warn("error parsing calendar", "error", err) return errors.WithMessage(err, "could not parse calendar") } return err } func (c *Calendar) open() (*os.File, errors.E) { f, err := cache.Root.Open(Filename) if err != nil { return nil, errors.WithMessage(err, "could not open calendar file") } return f, nil } func (c *Calendar) fetch(ctx context.Context) errors.E { c.log.Debug("fetching calendar", "url", c.opts.URL.String()) f, err := cache.Root.OpenFile(Filename, os.O_RDWR|os.O_CREATE, 0o600) if err != nil { return errors.WithMessage(err, "could not create temp file") } defer f.Close() req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.opts.URL.String(), nil) if err != nil { return errors.WithMessage(err, "could not create request") } res, err := c.client.Do(req) if err != nil { return errors.WithMessage(err, "could not fetch calendar") } defer res.Body.Close() if res.StatusCode != http.StatusOK { return errors.New("unexpected status code") } if _, err := io.Copy(f, res.Body); err != nil { return errors.WithMessage(err, "could not write calendar to file") } err = f.Sync() if err != nil { return errors.WithMessage(err, "could not sync file") } return nil } func (c *Calendar) EventsBetween(from time.Time, to time.Time) ([]*Event, error) { if c.Calendar == nil { return nil, errors.New("calendar not initialised") } evs := c.Events() c.log.Debug("processing events", "count", len(evs)) events := make([]*Event, 0, len(evs)) for _, ev := range evs { st, err := ev.GetStartAt() if err != nil { c.log.Warn("could not get start time", "event", ev.Id(), "error", err) continue } et, err := ev.GetEndAt() if err != nil { c.log.Warn("could not get end time", "event", ev.Id(), "error", err) continue } if st.Before(from) && et.Before(from) || st.After(to) && et.After(to) { continue } events = append(events, &Event{ VEvent: *ev, StartTime: Date{Time: st}, EndTime: Date{Time: et}, }) } slices.SortFunc(events, func(a, b *Event) int { return a.StartTime.Compare(b.StartTime.Time) }) return events, nil } func (c *Calendar) Weeks(count int) []Date { now := time.Now() weekday := int(now.Weekday()-time.Monday) % 7 start := time.Date( now.Year(), now.Month(), now.Day()-(weekday), 0, 0, 0, 0, now.Location(), ) days := count*7 + weekday + 1 dates := make([]Date, 0, days) for i := start; i.Before(start.AddDate(0, 0, days)); i = i.AddDate(0, 0, 1) { dates = append(dates, Date{Time: i}) } return dates } func (c *Calendar) Availability(weeks int) ([]*CalendarDate, error) { cds := make([]*CalendarDate, 0, weeks*7) dates := c.Weeks(weeks) for _, date := range dates { c.log.Debug("processing date", "date", date.DateOnly()) cd := &CalendarDate{Date: date} evs, err := c.EventsBetween(date.Time, date.NextDay().Time) if err != nil { return nil, errors.WithMessage(err, "could not get events") } for _, ev := range evs { c.log.Debug("processing event", "id", ev.Id()) cd.Events = append(cd.Events, ev) } cds = append(cds, cd) } return cds, nil } func (d Date) Between(lower, upper Date) bool { return d.After(lower.Time) && d.Before(upper.Time) } func (d Date) Key() int { return d.Year()*10000 + int(d.Month())*100 + d.Day() } func (d Date) BeginningOfDay() Date { return Date{ Time: time.Date( d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location(), ), } } func (d Date) DateOnly() string { return d.Format(time.DateOnly) } func (d Date) NextDay() Date { return Date{ Time: d.AddDate(0, 0, 1), } } func (d Date) IsToday() bool { return d.Key() == Date{time.Now()}.Key() }