package main import ( "bufio" "bytes" "context" "fmt" "io" "log" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/cenkalti/backoff/v5" "github.com/robfig/cron/v3" ) type InputLine struct { RawSchedule, RawTask string } type TaskAddingJob struct { Schedule cron.Schedule Task cron.Job EntryID cron.EntryID // this doesn't fit super well, but we'll figure that out soon I guess Delete bool } type Task struct { Name string } func (t Task) Run() { _, err := backoff.Retry(context.Background(), func() (bool, error) { return true, createTask(t.Name) }, backoff.WithBackOff(backoff.NewExponentialBackOff())) if err != nil { log.Printf("Failed to create task '%s': %s", t.Name, err) } } var jobsFromInput = map[InputLine]TaskAddingJob{} var todoistToken string var httpClient = http.Client{ Timeout: 10 * time.Second, } func main() { if err := setToken(); err != nil { log.Fatalf("setting token: %s", err) } p, err := inputFilePath() if err != nil { log.Fatalf("getting input file: %s", err) } log.Printf("Started watching '%s'", p) c := cron.New() c.Start() for { err = readInput(p) if err != nil { log.Printf("Error reading input: %s", err) goto SLEEP } for inputLine, taskAddingJob := range jobsFromInput { if jobsFromInput[inputLine].EntryID == 0 { taskAddingJob.EntryID = c.Schedule( jobsFromInput[inputLine].Schedule, jobsFromInput[inputLine].Task, ) jobsFromInput[inputLine] = taskAddingJob log.Printf( "Added '%s' with recurrence '%s'", inputLine.RawTask, inputLine.RawSchedule, ) } else if taskAddingJob.Delete { c.Remove(taskAddingJob.EntryID) delete(jobsFromInput, inputLine) log.Printf("Removed '%s'", inputLine.RawTask) } } SLEEP: time.Sleep(time.Minute) } } func setToken() error { token := os.Getenv("TODOIST_TOKEN") if token == "" { return fmt.Errorf("TODOIST_TOKEN env var must be present and non-empty") } todoistToken = token return nil } func inputFilePath() (string, error) { args := os.Args if len(args) != 2 { return "", fmt.Errorf("expected one argument (input file path), got %d", len(args)-1) } p := filepath.Clean(args[1]) // While we're here, check that the input path actually exists and // is stat-able. if _, err := os.Stat(p); err != nil { return "", fmt.Errorf("cannot stat input file: %w", err) } return p, nil } var inputFileRe = regexp.MustCompile(`([^\s]+\s+[^\s]+\s+[^\s]+\s+[^\s]+\s+[^\s]+)\s+(.*)`) func readInput(p string) error { f, err := os.Open(p) if err != nil { return fmt.Errorf("opening '%s': %w", p, err) } defer f.Close() previousInput := map[InputLine]struct{}{} for k := range jobsFromInput { previousInput[k] = struct{}{} } currentInput := map[InputLine]struct{}{} scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } matches := inputFileRe.FindStringSubmatch(line) if matches == nil || len(matches) != 1+2 { log.Printf("failed to parse input line: '%s'", line) continue } inputLine := InputLine{ RawSchedule: matches[1], RawTask: matches[2], } currentInput[inputLine] = struct{}{} if _, ok := previousInput[inputLine]; ok { continue } schedule, err := cron.ParseStandard(inputLine.RawSchedule) if err != nil { log.Printf( "Failed to add '%s' with recurrence '%s': %s", inputLine.RawTask, inputLine.RawSchedule, err, ) continue } jobsFromInput[inputLine] = TaskAddingJob{ Schedule: schedule, Task: Task{Name: inputLine.RawTask}, } } if err := scanner.Err(); err != nil { return fmt.Errorf("scanning: %w", err) } // Mark stuff that we previously saw, but is now missing, for pruning. for k := range previousInput { if _, ok := currentInput[k]; !ok { v := jobsFromInput[k] v.Delete = true jobsFromInput[k] = v } } return nil } func createTask(task string) error { jsonBody := []byte(fmt.Sprintf(`{"content": %s}`, strconv.Quote(task))) req, err := http.NewRequest( http.MethodPost, "https://api.todoist.com/api/v1/tasks", bytes.NewBuffer(jsonBody), ) if err != nil { return fmt.Errorf("creating request: %w", err) } req.Header.Add("Authorization", "Bearer "+todoistToken) req.Header.Set("Content-Type", "application/json") resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("doing request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("reading body when preparing error message (HTTP status was %d): %w", resp.StatusCode, err) } return fmt.Errorf("expected 200, got status code %d: body was %s", resp.StatusCode, strconv.Quote(string(bodyBytes))) } log.Printf("Created task '%s'", task) return nil }