todoist-repeater

Create recurring tasks in Todoist with cron-style syntax
Log | Files | Refs | README

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 }