// ical/fs serves a directory of .ics files as a 9p file system. // // It does not fetch anything. A subscribed calendar is a file somebody // else wrote -- pim/fetch(1), a svc entry, an editor -- which is why a // local calendar and a subscribed one are the same thing here. Writing // a file and poking ctl is the whole interface for keeping it current. // // It is one backend behind pim/lib/cal, which owns the tree. Files on // disk are read-only as far as scheduling goes: there is nowhere to PUT // and no METHOD:REQUEST to reply to, so this backend reports "read" and // nothing above it has to guess. package main import ( "flag" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" "pim/lib/cal" ) func fatal(format string, a ...interface{}) { cal.Warnf(format, a...) os.Exit(1) } // backend reads a directory of .ics files. type backend struct { c *Cal // nil when serving -d alone dir string poll time.Duration mu sync.Mutex evs []*cal.Event sig string // what the directory looked like when last loaded last time.Time err string } func (b *backend) Name() string { if b.c != nil { return b.c.Name } return "calendar" } // A published feed can only be read. See pim/doc/design.md. func (b *backend) Caps() string { return "read" } // Refresh is how often to re-stat the directory. Whoever writes the // files should poke ctl instead; this only catches a hand edit. func (b *backend) Refresh() time.Duration { return b.poll } func (b *backend) Status() (time.Time, string) { b.mu.Lock() defer b.mu.Unlock() return b.last, b.err } func (b *backend) Describe() string { s := fmt.Sprintf("dir %s\n", b.dir) if b.c != nil && len(b.c.Me) > 0 { s += "me " + joinComma(b.c.Me) + "\n" } return s } // Sync reloads if the directory has changed. Name, size and mtime are // enough to notice: a rewrite that keeps all three identical is a // rewrite of identical content. func (b *backend) Sync() (bool, error) { sig, err := dirsig(b.dir) b.note(time.Now(), err) if err != nil { return false, err } b.mu.Lock() same := sig == b.sig && b.evs != nil b.mu.Unlock() if same { return false, nil } evs, err := cal.LoadDir(b.dir) if err != nil { return false, err } b.mu.Lock() b.evs, b.sig = evs, sig b.mu.Unlock() return true, nil } // dirsig summarises every .ics in dir. func dirsig(dir string) (string, error) { names, err := filepath.Glob(filepath.Join(dir, "*.ics")) if err != nil { return "", err } sort.Strings(names) var b strings.Builder for _, n := range names { fi, err := os.Stat(n) if err != nil { continue } fmt.Fprintf(&b, "%s:%d:%d\n", n, fi.Size(), fi.ModTime().UnixNano()) } return b.String(), nil } func (b *backend) Events() ([]*cal.Event, error) { b.mu.Lock() evs := b.evs b.mu.Unlock() if evs != nil { return evs, nil } evs, err := cal.LoadDir(b.dir) if err != nil { return nil, err } b.mu.Lock() b.evs = evs b.mu.Unlock() return evs, nil } func (b *backend) note(t time.Time, err error) { b.mu.Lock() defer b.mu.Unlock() b.last = t if err != nil { b.err = err.Error() } else { b.err = "" } } func joinComma(a []string) string { s := "" for i, x := range a { if i > 0 { s += "," } s += x } return s } func main() { cal.Argv0 = "ical/fs" var ( dir = flag.String("d", ".", "directory of .ics files") name = flag.String("s", "", "service name to post in /srv (default ical.$user.$pid)") days = flag.Int("w", 400, "expansion window in days") dry = flag.Bool("n", false, "load and report, do not serve") conf = flag.String("c", "", "calendar config (default $home/lib/pim)") which = flag.String("N", "", "which calendar in the config to serve") poll = flag.Duration("r", 0, "re-stat the directory this often (0: only on ctl refresh)") ) flag.Usage = func() { fmt.Fprintf(os.Stderr, "usage: ical/fs [-n] [-c config] [-N name] [-d dir] [-s srv] [-w days] [-r poll]\n") os.Exit(2) } flag.Parse() user := os.Getenv("user") if user == "" { user = "glenda" } if *name == "" { // as rio(1) and plumb(1) name theirs, so several may run at once *name = fmt.Sprintf("ical.%s.%d", user, os.Getpid()) } path := *conf if path == "" { path = filepath.Join(os.Getenv("home"), "lib", "pim") if _, err := os.Stat(path); err != nil { path = "" // no config is fine: -d alone works } } // One server serves one calendar; several calendars means several // servers, each mounted at its own name under /mnt/pim/calendars. be := &backend{dir: *dir, poll: *poll} if *which != "" { // a name even without a config: -o still wants to be called // something other than "calendar" under calendars/ be.c = &Cal{Name: *which} } if path != "" { cals, err := readConfig(path, *dir) if err != nil { fatal("%v", err) } if be.c, err = pick(cals, *which); err != nil { fatal("%v", err) } } s := cal.New(be, cal.Config{ User: user, Srv: *name, Conf: path, Window: time.Duration(*days) * 24 * time.Hour, }) if *dry { if err := s.Report(); err != nil { fatal("%v", err) } return } if err := s.Serve(); err != nil { fatal("%v", err) } }