package main import ( "pim/lib/cal" "fmt" "os" "path/filepath" "strings" "sync" "time" ) // A Cal is one calendar the server keeps current. // // The config is per-user, in ndb's attribute-pair syntax, one calendar // per line: // // cal=work me=you@work.example // cal=home me=you@home.example,alias@home.example // // In iTIP your identity is the mailto: in your own ATTENDEE line -- there // is no separate field for it -- so a calendar has to be told whose it // is before anything can reply on its behalf. Aliases are listed because // you may be invited at one address and send from another. // // Fetching is not this server's business. A subscribed calendar is a // file somebody else wrote; see pim/fetch(1). // // It lives in $home/lib/pim by default. The urls of private calendars // are secrets, which is the other reason they belong in a file rather // than in argv where ps(1) would show them. type Cal struct { Name string Refresh time.Duration // how often to re-stat, if the config says File string Me []string // the addresses that count as you on this calendar mu sync.Mutex last time.Time err string } func (c *Cal) status() (time.Time, string) { c.mu.Lock() defer c.mu.Unlock() return c.last, c.err } func (c *Cal) note(t time.Time, err error) { c.mu.Lock() defer c.mu.Unlock() c.last = t if err != nil { c.err = err.Error() } else { c.err = "" } } // readConfig parses the calendar list. Blank lines and lines beginning // with # are ignored; everything else is a tuple of attr=value pairs. func readConfig(path, dir string) ([]*Cal, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } var cals []*Cal for n, line := range strings.Split(string(b), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } c := &Cal{Refresh: 15 * time.Minute} for _, f := range strings.Fields(line) { k, v, ok := strings.Cut(f, "=") if !ok { return nil, fmt.Errorf("%s:%d: %q is not attr=value", path, n+1, f) } v = strings.Trim(v, `"'`) switch k { case "cal": c.Name = v case "url": // fetching moved out; the url belongs to the script // that writes the file cal.Warnf("%s:%d: url= is ignored, see pim/fetch", path, n+1) case "me": for _, a := range strings.Split(v, ",") { if a = strings.TrimSpace(a); a != "" { c.Me = append(c.Me, a) } } case "refresh": d, err := time.ParseDuration(v) if err != nil || d <= 0 { return nil, fmt.Errorf("%s:%d: bad refresh %q", path, n+1, v) } c.Refresh = d default: return nil, fmt.Errorf("%s:%d: unknown attribute %q", path, n+1, k) } } if c.Name == "" { return nil, fmt.Errorf("%s:%d: no cal= name", path, n+1) } c.File = filepath.Join(dir, c.Name+".ics") cals = append(cals, c) } if len(cals) == 0 { return nil, fmt.Errorf("%s: no calendars", path) } return cals, nil } // pick selects one calendar by name. A server serves exactly one; the // config lists them all so that whatever starts them has a single place // to read. func pick(cals []*Cal, name string) (*Cal, error) { if name == "" { if len(cals) == 1 { return cals[0], nil } var names []string for _, c := range cals { names = append(names, c.Name) } return nil, fmt.Errorf("which calendar? -N one of: %s", strings.Join(names, " ")) } for _, c := range cals { if c.Name == name { return c, nil } } return nil, fmt.Errorf("no calendar named %q", name) }