package calendar import ( "context" "errors" "fmt" "io" "io/fs" "net/http" "os" "slices" "time" "github.com/Southclaws/fault" "github.com/Southclaws/fault/fmsg" "go.alanpearce.eu/x/log" ical "vimagination.zapto.org/ics" "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.FreeBusy StartTime Date EndTime Date Type ical.ParamFreeBusyType } 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, fault.Wrap(err, fmsg.With("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.Decode(f) if err != nil { c.log.Warn("error parsing calendar", "error", err) return fault.Wrap(err, fmsg.With("could not parse calendar")) } return err } func (c *Calendar) open() (*os.File, error) { f, err := cache.Root.Open(Filename) if err != nil { return nil, fault.Wrap(err, fmsg.With("could not open calendar file")) } return f, nil } func (c *Calendar) fetch(ctx context.Context) error { 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 fault.Wrap(err, fmsg.With("could not create temp file")) } defer f.Close() req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.opts.URL.String(), nil) if err != nil { return fault.Wrap(err, fmsg.With("could not create request")) } res, err := c.client.Do(req) if err != nil { return fault.Wrap(err, fmsg.With("could not fetch calendar")) } defer res.Body.Close() if res.StatusCode != http.StatusOK { return fault.New(fmt.Sprintf("unexpected status code %d", res.StatusCode)) } if _, err := io.Copy(f, res.Body); err != nil { return fault.Wrap(err, fmsg.With("could not write calendar to file")) } err = f.Sync() if err != nil { return fault.Wrap(err, fmsg.With("could not sync file")) } return nil } func (c *Calendar) EventsBetween(from time.Time, to time.Time) ([]*Busy, error) { if c.Calendar == nil { return nil, fault.New("calendar not initialised") } bs := make([]*Busy, 0, len(c.FreeBusy)) for _, fb := range c.FreeBusy { for _, b := range fb.FreeBusy { st := b.Start et := b.End if st.Before(from) && et.Before(from) || st.After(to) && et.After(to) { continue } bs = append(bs, &Busy{ FreeBusy: fb, Type: *b.FreeBusyType, StartTime: Date{Time: st.In(c.opts.Timezone.Location)}, EndTime: Date{Time: et.In(c.opts.Timezone.Location)}, }) } } 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, fault.Wrap(err, fmsg.With("could not get events")) } for _, ev := range evs { c.log.Debug("processing event", "id", ev.UID) 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) }