package calendar import ( "context" "fmt" "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 Timezone config.Timezone Cache bool } type Calendar struct { opts *Options log *log.Logger client *http.Client *ical.Calendar } type Busy struct { ical.VBusy StartTime Date EndTime Date } type Date struct { time.Time } type CalendarDate struct { Date BusyPeriods []*Busy } 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) ValidCache() (bool, error) { if !c.opts.Cache { return false, nil } stat, err := cache.Root.Stat(Filename) if err != nil && !errors.Is(err, fs.ErrNotExist) { return false, errors.WithMessage(err, "could not stat calendar file") } return stat != nil && time.Since(stat.ModTime()) < Refresh && stat.Size() > 0, nil } func (c *Calendar) FetchIfNeeded(ctx context.Context) error { var err error if valid, err := c.ValidCache(); err != nil { return err } else if !valid { 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(fmt.Sprintf("unexpected status code %d", res.StatusCode)) } 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) ([]*Busy, error) { if c.Calendar == nil { return nil, errors.New("calendar not initialised") } in := c.Busys() bs := make([]*Busy, 0, len(in)) for _, b := range in { st, err := b.GetStartAt() if err != nil { c.log.Warn("could not get start time", "event", b.Id(), "error", err) continue } et, err := b.GetEndAt() if err != nil { c.log.Warn("could not get end time", "event", b.Id(), "error", err) continue } if st.Before(from) && et.Before(from) || st.After(to) && et.After(to) { continue } bs = append(bs, &Busy{ VBusy: *b, StartTime: Date{Time: st}, EndTime: Date{Time: et}, }) } slices.SortFunc(bs, func(a, b *Busy) int { return a.StartTime.Compare(b.StartTime.Time) }) return bs, 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, c.opts.Timezone.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) { if !c.opts.Cache { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) err := c.FetchIfNeeded(ctx) cancel() if err != nil { return nil, err } } 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.Add(1*time.Second), date.EndOfDay().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.BusyPeriods = append(cd.BusyPeriods, ev) } cds = append(cds, cd) } return cds, nil } func (d Date) Between(lower, upper Date) bool { return d.After(lower) && d.Before(upper) } 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) EndOfDay() Date { return Date{ Time: time.Date( d.Year(), d.Month(), d.Day(), 23, 59, 59, 999999999, 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() } func (d Date) Before(other Date) bool { return d.Time.Before(other.Time) } func (d Date) After(other Date) bool { return d.Time.After(other.Time) }