// # Documentation // we try to read a file pointed to by WAYBAR_TODO_FILE // or `nb show --path todo`. // While no particular format is assumed, we recommend // Markdown. // We extract the todos from the top-level list items // (listings start with a `*` character). // // # Usage: // go build -o waybar-todo todo.go package main import ( "bytes" "encoding/json" "fmt" "io/ioutil" "log" "os" "os/exec" "os/signal" "regexp" "strings" "text/scanner" "time" "github.com/fsnotify/fsnotify" "golang.org/x/sys/unix" ) const ( TODO_LINE_CHAR rune = '*' TODO_INACTIVE_STRING string = "" TODO_LABEL_REGEX string = `\[\[!(.*)\]\]` ) var ( active = true todofile = os.Getenv("WAYBAR_TODO_FILE") cookiefile = os.ExpandEnv("${XDG_RUNTIME_DIR}/waybar-todo.cookie") labelFilter = os.Getenv("WAYBAR_TODO_FILTER") ) func printJson(text, tooltip string, class []string, alt string, percentage int) error { return json.NewEncoder(os.Stdout).Encode(map[string]interface{}{ "text": text, "tooltip": tooltip, "class": class, "alt": alt, "percentage": percentage, }) } type todoItem struct { text string label string } func readTodofile(path string) ([]todoItem, error) { content, err := ioutil.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading file: %v", err) } var ( sc scanner.Scanner validLine bool todos []todoItem ) sc.Init(bytes.NewReader(content)) sc.Filename = path sc.Whitespace ^= 1<<'\n' | 1<<' ' // don't skip newlines or spaces sc.Mode ^= scanner.GoTokens for tok := sc.Scan(); tok != scanner.EOF; tok = sc.Scan() { switch { // a valid line stats with TODO_LINE_CHAR // followed by a space // we strip them before appending the line array case tok == TODO_LINE_CHAR && sc.Peek() == ' ': validLine = true _ = sc.Scan() // swallow the space todos = append(todos, todoItem{}) // grow the array case tok == '\n': validLine = false if len(todos) > 0 { idx := len(todos) - 1 matches := labelre.FindAllStringSubmatch(todos[idx].text, -1) if matches != nil && len(matches) > 0 { todos[idx].label = matches[0][1] todos[idx].text = strings.TrimSpace( strings.Replace(todos[idx].text, matches[0][0], "", 1)) } } case validLine: todos[len(todos)-1].text = todos[len(todos)-1].text + sc.TokenText() } } return todos, nil } func setCookie(active bool) error { if !active { _, err := os.Create(cookiefile) return err } return os.Remove(cookiefile) } func update() error { if _, err := os.Stat(cookiefile); err == nil { log.Printf("cookie set, we're inactive") active = false } if !active { return printJson(TODO_INACTIVE_STRING, "* inactive *", []string{"off"}, "todo-off", 0) } todos, err := readTodofile(todofile) if err != nil { return err } var ( text string tooltip string extraClass string ) for _, item := range todos { if labelFilter != "" && strings.Contains(labelFilter, item.label) { continue } if text == "" { text = item.text extraClass = item.label continue } // render out the rest of the todos into the tooltip tooltip = strings.Join([]string{tooltip, item.text}, "\n") } return printJson(text, tooltip, []string{"on", extraClass}, "todo-on", 1) } var ( labelre *regexp.Regexp ) func init() { labelre = regexp.MustCompile(TODO_LABEL_REGEX) } func main() { var ( updateC = make(chan os.Signal, 1) cancelC = make(chan os.Signal, 1) ) signal.Notify(updateC, unix.SIGUSR1, unix.SIGUSR2, unix.SIGHUP) signal.Notify(cancelC, unix.SIGTERM, unix.SIGINT) var err error // we will receive a SIGTERM if our parent dies err = unix.Prctl(unix.PR_SET_PDEATHSIG, uintptr(unix.SIGTERM), 0, 0, 0) if err != nil { log.Fatalf("error: setting prctl: %v", err) } if todofile == "" { // get from nb(1) nb := exec.Command("sh", "-c", "nb show --no-color --path todo") nb_output, err := nb.Output() if err != nil { e := err.(*exec.ExitError) log.Fatalf("error: finding todofile via nb: %s", e.Stderr) } todofile = strings.TrimSpace(string(nb_output)) } log.Printf("watching %q", todofile) w, err := fsnotify.NewWatcher() if err != nil { log.Fatalf("error: creating watcher: %v", err) } defer w.Close() if err := w.Add(todofile); err != nil { log.Fatalf("error: adding watch: %v", err) } if err := update(); err != nil { // fail here because it's the first time we // try to read the todofile. log.Fatalf("error: %v", err) } for { select { case evt, ok := <-w.Events: if !ok { return } // see https://github.com/fsnotify/fsnotify/issues/94 // https://github.com/gohugoio/hugo/pull/4720 if evt.Op&fsnotify.Remove == fsnotify.Remove { counter := 0 for w.Add(todofile) != nil { counter++ if counter >= 1000 { log.Panic("error: unable to add file watcher") } time.Sleep(100 * time.Millisecond) } } goto force_update case err, ok := <-w.Errors: if !ok { return } log.Printf("error: from watcher: %v", err) case sig := <-updateC: switch sig { case unix.SIGUSR1: // on active = !active if err := setCookie(active); err != nil { log.Printf("err: %v", err) } goto force_update case unix.SIGUSR2: // if strings.Contains(labelFilter, "work") { labelFilter = strings.Trim(strings.ReplaceAll(labelFilter, "work", ""), ",") } else { labelFilter = strings.Join([]string{labelFilter, "work"}, ",") } continue case unix.SIGHUP: // force update log.Println("force update") goto force_update } case <-cancelC: log.Printf("%s exiting", os.Args[0]) os.Exit(0) } force_update: _ = update() } }