package main import ( "bufio" "bytes" "fmt" "io" "log" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/robfig/cron/v3" ) type InputLine struct { RawSchedule, RawTask string } type TaskAddingJob struct { Schedule cron.Schedule Task cron.Job } type Task struct { Name string } func (t Task) Run() { createTask(t.Name) } var lastSeenInput = 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() for { input, err := readInput(p) if err != nil { log.Printf("Error reading input: %s", err) continue } if inputChanged(lastSeenInput, input) { log.Print("Updating task list") lastSeenInput = input c.Stop() c := cron.New() addJobs(c, input) c.Start() log.Print("Done updating task list") } time.Sleep(time.Minute) // re-read the input file every 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) (map[InputLine]TaskAddingJob, error) { input := map[InputLine]TaskAddingJob{} f, err := os.Open(p) if err != nil { return nil, fmt.Errorf("opening '%s': %w", p, err) } defer f.Close() 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], } 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 } job := Task{Name: inputLine.RawTask} taskAddingJob := TaskAddingJob{ Schedule: schedule, Task: job, } input[inputLine] = taskAddingJob } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("scanning: %w", err) } return input, nil } func inputChanged(old, new map[InputLine]TaskAddingJob) bool { if len(new) != len(old) { return true } for k := range old { if _, ok := new[k]; !ok { return true } } return false } func createTask(task string) { 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 { log.Printf("Failed to create request: %s", err) return } req.Header.Add("Authorization", "Bearer "+todoistToken) req.Header.Set("Content-Type", "application/json") resp, err := httpClient.Do(req) if err != nil { log.Printf("Failed to Do request: %s", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { log.Printf("Got status code %d, expected 200 when creating task", resp.StatusCode) bodyBytes, err := io.ReadAll(resp.Body) if err != nil { log.Printf("Failed to read body: %s", err) return } log.Printf("Got body: %s", string(bodyBytes)) } else { log.Printf("Created task '%s'", task) } } func addJobs(c *cron.Cron, tasks map[InputLine]TaskAddingJob) { for _, task := range tasks { _ = c.Schedule(task.Schedule, task.Task) } log.Printf("Added %d jobs", len(tasks)) }