main.go (5560B)
1 // This program creates tasks in Todoist, based on time specificiations in the 2 // cron format. It takes one argument (the path to the input file) and requires 3 // that your Todoist developer token be set in the TODOIST_TOKEN environment 4 // variable. 5 // 6 // The input file must be a text file where each line is either empty, a comment 7 // (starting with #), or a cron specification followed by the name of the task. 8 // Note that the cron specification must be provided as five whitespace 9 // separated fields; this program doesn't currently support all the features of 10 // robfig/cron (e.g. '@monthly'). 11 // 12 // The input file is checked for changes each minute, and the program creates 13 // Todoist tasks when their cron run time occurs. 14 package main 15 16 import ( 17 "bufio" 18 "bytes" 19 "context" 20 "fmt" 21 "io" 22 "log" 23 "net/http" 24 "os" 25 "path/filepath" 26 "regexp" 27 "strconv" 28 "strings" 29 "time" 30 31 "github.com/cenkalti/backoff/v5" 32 "github.com/robfig/cron/v3" 33 ) 34 35 type InputLine struct { 36 RawSchedule, RawTask string 37 } 38 39 type TaskAddingJob struct { 40 Schedule cron.Schedule 41 Task cron.Job 42 EntryID cron.EntryID 43 Delete bool 44 } 45 46 type Task struct { 47 Name string 48 } 49 50 func (t Task) Run() { 51 _, err := backoff.Retry( 52 context.Background(), 53 func() (bool, error) { return true, createTask(t.Name) }, 54 ) 55 if err != nil { 56 log.Printf("Failed to create task '%s': %s", t.Name, err) 57 } 58 } 59 60 var jobsFromInput = map[InputLine]TaskAddingJob{} 61 var todoistToken string 62 var httpClient = http.Client{ 63 Timeout: 10 * time.Second, 64 } 65 66 func main() { 67 if err := setToken(); err != nil { 68 log.Fatalf("setting token: %s", err) 69 } 70 71 p, err := inputFilePath() 72 if err != nil { 73 log.Fatalf("getting input file: %s", err) 74 } 75 log.Printf("Started watching '%s'", p) 76 77 c := cron.New() 78 c.Start() 79 80 for { 81 err = readInput(p) 82 if err != nil { 83 log.Printf("Error reading input: %s", err) 84 goto SLEEP 85 } 86 87 for inputLine, taskAddingJob := range jobsFromInput { 88 if jobsFromInput[inputLine].EntryID == 0 { 89 taskAddingJob.EntryID = c.Schedule( 90 jobsFromInput[inputLine].Schedule, 91 jobsFromInput[inputLine].Task, 92 ) 93 jobsFromInput[inputLine] = taskAddingJob 94 95 log.Printf( 96 "Added '%s' with recurrence '%s'", 97 inputLine.RawTask, 98 inputLine.RawSchedule, 99 ) 100 } else if taskAddingJob.Delete { 101 c.Remove(taskAddingJob.EntryID) 102 delete(jobsFromInput, inputLine) 103 log.Printf("Removed '%s'", inputLine.RawTask) 104 } 105 } 106 107 SLEEP: 108 time.Sleep(time.Minute) 109 } 110 111 } 112 113 func setToken() error { 114 token := os.Getenv("TODOIST_TOKEN") 115 if token == "" { 116 return fmt.Errorf("TODOIST_TOKEN env var must be present and non-empty") 117 } 118 todoistToken = token 119 120 return nil 121 } 122 123 func inputFilePath() (string, error) { 124 args := os.Args 125 if len(args) != 2 { 126 return "", fmt.Errorf("expected one argument (input file path), got %d", len(args)-1) 127 } 128 p := filepath.Clean(args[1]) 129 130 // While we're here, check that the input path actually exists and 131 // is stat-able. 132 if _, err := os.Stat(p); err != nil { 133 return "", fmt.Errorf("cannot stat input file: %w", err) 134 } 135 136 return p, nil 137 } 138 139 var inputFileRe = regexp.MustCompile(`([^\s]+\s+[^\s]+\s+[^\s]+\s+[^\s]+\s+[^\s]+)\s+(.*)`) 140 141 func readInput(p string) error { 142 f, err := os.Open(p) 143 if err != nil { 144 return fmt.Errorf("opening '%s': %w", p, err) 145 } 146 defer f.Close() 147 148 previousInput := map[InputLine]struct{}{} 149 for k := range jobsFromInput { 150 previousInput[k] = struct{}{} 151 } 152 currentInput := map[InputLine]struct{}{} 153 154 scanner := bufio.NewScanner(f) 155 for scanner.Scan() { 156 line := strings.TrimSpace(scanner.Text()) 157 if line == "" || strings.HasPrefix(line, "#") { 158 continue 159 } 160 161 matches := inputFileRe.FindStringSubmatch(line) 162 if matches == nil || len(matches) != 1+2 { 163 log.Printf("failed to parse input line: '%s'", line) 164 continue 165 } 166 167 inputLine := InputLine{ 168 RawSchedule: matches[1], 169 RawTask: matches[2], 170 } 171 172 currentInput[inputLine] = struct{}{} 173 if _, ok := previousInput[inputLine]; ok { 174 continue 175 } 176 177 schedule, err := cron.ParseStandard(inputLine.RawSchedule) 178 if err != nil { 179 log.Printf( 180 "Failed to add '%s' with recurrence '%s': %s", 181 inputLine.RawTask, 182 inputLine.RawSchedule, 183 err, 184 ) 185 continue 186 } 187 188 jobsFromInput[inputLine] = TaskAddingJob{ 189 Schedule: schedule, 190 Task: Task{Name: inputLine.RawTask}, 191 } 192 } 193 if err := scanner.Err(); err != nil { 194 return fmt.Errorf("scanning: %w", err) 195 } 196 197 // Mark for pruning any tasks that we previously saw, but are now missing. 198 for k := range previousInput { 199 if _, ok := currentInput[k]; !ok { 200 v := jobsFromInput[k] 201 v.Delete = true 202 jobsFromInput[k] = v 203 } 204 } 205 206 return nil 207 } 208 209 func createTask(task string) error { 210 jsonBody := []byte(fmt.Sprintf(`{"content": %s}`, strconv.Quote(task))) 211 req, err := http.NewRequest( 212 http.MethodPost, 213 "https://api.todoist.com/api/v1/tasks", 214 bytes.NewBuffer(jsonBody), 215 ) 216 if err != nil { 217 return fmt.Errorf("creating request: %w", err) 218 } 219 req.Header.Add("Authorization", "Bearer "+todoistToken) 220 req.Header.Set("Content-Type", "application/json") 221 222 resp, err := httpClient.Do(req) 223 if err != nil { 224 return fmt.Errorf("doing request: %w", err) 225 } 226 defer resp.Body.Close() 227 if resp.StatusCode != http.StatusOK { 228 bodyBytes, err := io.ReadAll(resp.Body) 229 if err != nil { 230 return fmt.Errorf("reading body when preparing error message (HTTP status was %d): %w", resp.StatusCode, err) 231 } 232 return fmt.Errorf("expected 200, got status code %d: body was %s", resp.StatusCode, strconv.Quote(string(bodyBytes))) 233 } 234 235 log.Printf("Created task '%s'", task) 236 return nil 237 }