From 0378c42cba6f27659dd4538334f1e15a7776ec71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20Sad=C5=82o?= Date: Sat, 4 Jul 2026 22:57:24 +0200 Subject: [PATCH 1/4] feat(todo_plus): add Todoist filter support with project names and date colors --- modules/todo_plus/backend/project.go | 3 + modules/todo_plus/backend/todoist.go | 258 ++++++++++++++++++++++++++- modules/todo_plus/display.go | 17 +- modules/todo_plus/settings.go | 2 + 4 files changed, 273 insertions(+), 7 deletions(-) diff --git a/modules/todo_plus/backend/project.go b/modules/todo_plus/backend/project.go index 8280900cb..7f8ef7eee 100644 --- a/modules/todo_plus/backend/project.go +++ b/modules/todo_plus/backend/project.go @@ -4,6 +4,9 @@ type Task struct { ID string Completed bool Name string + Prefix string + DateSuffix string + Overdue bool } type Project struct { diff --git a/modules/todo_plus/backend/todoist.go b/modules/todo_plus/backend/todoist.go index 77396d7dd..42c5d55e4 100644 --- a/modules/todo_plus/backend/todoist.go +++ b/modules/todo_plus/backend/todoist.go @@ -1,15 +1,52 @@ package backend import ( + "encoding/json" "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" "github.com/gopherlibs/todoist/api" "github.com/olebedev/config" ) +type todoistFilter struct { + ID string `json:"id"` + Name string `json:"name"` + Query string `json:"query"` +} + +type todoistProject struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type syncResponse struct { + Filters []todoistFilter `json:"filters"` +} + +type filterTaskItem struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Content string `json:"content"` + Checked bool `json:"checked"` + Due *struct { + Date string `json:"date"` + Datetime string `json:"datetime"` + } `json:"due"` +} + type Todoist struct { - client *api.Client - projects []interface{} + client *api.Client + apiKey string + projects []interface{} + filters []interface{} + filterMap map[string]todoistFilter + projectNameMap map[string]string } func (todo *Todoist) Title() string { @@ -17,9 +54,67 @@ func (todo *Todoist) Title() string { } func (todo *Todoist) Setup(config *config.Config) { - - todo.client = api.New(config.UString("apiKey")) + todo.apiKey = config.UString("apiKey") + todo.client = api.New(todo.apiKey) todo.projects = config.UList("projects") + todo.filters = config.UList("filters") + todo.fetchProjects() + todo.fetchFilters() +} + +func (todo *Todoist) doGet(urlStr string) (*http.Response, error) { + req, _ := http.NewRequest("GET", urlStr, nil) + req.Header.Add("Authorization", "Bearer "+todo.apiKey) + return http.DefaultClient.Do(req) +} + +func (todo *Todoist) doPostForm(path string, body io.Reader) (*http.Response, error) { + u, _ := url.Parse("https://api.todoist.com" + path) + req, _ := http.NewRequest("POST", u.String(), body) + req.Header.Add("Authorization", "Bearer "+todo.apiKey) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + return http.DefaultClient.Do(req) +} + +func (todo *Todoist) fetchProjects() { + todo.projectNameMap = make(map[string]string) + + projects, err := todo.client.Projects() + if err != nil { + return + } + + for _, p := range projects.Results { + todo.projectNameMap[p.ID] = p.Name + } +} + +func (todo *Todoist) fetchFilters() { + todo.filterMap = make(map[string]todoistFilter) + + resp, err := todo.doPostForm("/api/v1/sync", strings.NewReader("sync_token=*&resource_types=%5B%22filters%22%5D")) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return + } + + var syncResp syncResponse + if err := json.Unmarshal(body, &syncResp); err != nil { + return + } + + for _, f := range syncResp.Filters { + todo.filterMap[f.ID] = f + } } func (todo *Todoist) BuildProjects() []*Project { @@ -30,12 +125,17 @@ func (todo *Todoist) BuildProjects() []*Project { proj := todo.GetProject(i) projects = append(projects, proj) } + + for _, f := range todo.filters { + filterID := fmt.Sprintf("%v", f) + proj := todo.GetFilterProject(filterID) + projects = append(projects, proj) + } + return projects } func (todo *Todoist) GetProject(id string) *Project { - // Todoist seems to experience a lot of network issues on their side - // If we can't connect, handle it with an empty project until we can proj := &Project{ Index: -1, backend: todo, @@ -58,6 +158,28 @@ func (todo *Todoist) GetProject(id string) *Project { return proj } +func (todo *Todoist) GetFilterProject(filterID string) *Project { + proj := &Project{ + Index: -1, + backend: todo, + } + proj.ID = "filter:" + filterID + proj.Name = "Filter #" + filterID + + f, ok := todo.filterMap[filterID] + if !ok { + proj.Err = fmt.Errorf("filter %s not found (loaded %d filters)", filterID, len(todo.filterMap)) + return proj + } + + proj.Name = f.Name + tasks, err := todo.LoadTasksByFilter(f.Query) + proj.Err = err + proj.Tasks = tasks + + return proj +} + func toTask(task api.Task) Task { return Task{ ID: task.ID, @@ -67,6 +189,14 @@ func toTask(task api.Task) Task { } func (todo *Todoist) LoadTasks(id string) ([]Task, error) { + if strings.HasPrefix(id, "filter:") { + filterID := strings.TrimPrefix(id, "filter:") + f, ok := todo.filterMap[filterID] + if !ok { + return nil, fmt.Errorf("filter %s not found", filterID) + } + return todo.LoadTasksByFilter(f.Query) + } tasks, err := todo.client.Tasks(id) if err != nil { @@ -80,6 +210,118 @@ func (todo *Todoist) LoadTasks(id string) ([]Task, error) { return finalTasks, nil } +func (todo *Todoist) LoadTasksByFilter(query string) ([]Task, error) { + u, _ := url.Parse("https://api.todoist.com/api/v1/tasks/filter") + params := u.Query() + params.Set("query", query) + params.Set("limit", "200") + u.RawQuery = params.Encode() + + resp, err := todo.doGet(u.String()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("tasks/filter API returned %d: %s", resp.StatusCode, string(body)) + } + + var tasksResp struct { + Results []filterTaskItem `json:"results"` + } + err = json.Unmarshal(body, &tasksResp) + if err != nil { + return nil, err + } + + type taskWithDate struct { + task Task + datetime *time.Time + } + + var withDate []taskWithDate + var withoutDate []Task + + for _, item := range tasksResp.Results { + projName := todo.projectNameMap[item.ProjectID] + + prefix := "" + if projName != "" { + prefix = "#" + projName + } + + suffix := "" + var dt *time.Time + + if item.Due != nil && item.Due.Date != "" { + var t time.Time + var err error + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02"} { + t, err = time.Parse(layout, item.Due.Date) + if err == nil { + break + } + } + if err == nil { + if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 { + suffix = " " + t.Format("Jan 2") + } else { + suffix = " " + t.Format("Jan 2 15:04") + } + dt = &t + } + } + + overdue := false + if dt != nil { + dueUTC := dt.UTC() + now := time.Now().UTC() + if dueUTC.Before(now) { + if dueUTC.Hour() == 0 && dueUTC.Minute() == 0 && dueUTC.Second() == 0 { + if now.Year() > dueUTC.Year() || now.YearDay() > dueUTC.YearDay() { + overdue = true + } + } else { + overdue = true + } + } + } + + t := Task{ + ID: item.ID, + Completed: item.Checked, + Name: item.Content, + Prefix: prefix, + DateSuffix: suffix, + Overdue: overdue, + } + + if dt != nil { + withDate = append(withDate, taskWithDate{t, dt}) + } else { + withoutDate = append(withoutDate, t) + } + } + + sort.Slice(withDate, func(i, j int) bool { + return withDate[i].datetime.Before(*withDate[j].datetime) + }) + + var finalTasks []Task + for _, wd := range withDate { + finalTasks = append(finalTasks, wd.task) + } + finalTasks = append(finalTasks, withoutDate...) + + return finalTasks, nil +} + func (todo *Todoist) CloseTask(task *Task) error { if task != nil { _, err := todo.client.TaskClose(task.ID) @@ -102,5 +344,9 @@ func (todo *Todoist) Sources() []string { i := fmt.Sprintf("%v", id) result = append(result, i) } + for _, f := range todo.filters { + filterID := fmt.Sprintf("%v", f) + result = append(result, "filter:"+filterID) + } return result } diff --git a/modules/todo_plus/display.go b/modules/todo_plus/display.go index 7f3d0348b..75f785656 100644 --- a/modules/todo_plus/display.go +++ b/modules/todo_plus/display.go @@ -26,10 +26,25 @@ func (widget *Widget) content() (string, string, bool) { str := "" for idx, item := range proj.Tasks { + displayName := tview.Escape(item.Name) + + if item.Prefix != "" { + displayName = fmt.Sprintf("[yellow]%s[-] %s", + tview.Escape(item.Prefix), displayName) + } + + if item.DateSuffix != "" { + if item.Overdue { + displayName += fmt.Sprintf("[red]%s[-]", item.DateSuffix) + } else { + displayName += item.DateSuffix + } + } + row := fmt.Sprintf( `[%s]| | %s[%s]`, widget.RowColor(idx), - tview.Escape(item.Name), + displayName, widget.RowColor(idx), ) diff --git a/modules/todo_plus/settings.go b/modules/todo_plus/settings.go index 24aa803cb..841befd9f 100644 --- a/modules/todo_plus/settings.go +++ b/modules/todo_plus/settings.go @@ -42,8 +42,10 @@ func FromTodoist(name string, ymlConfig *config.Config, globalConfig *config.Con apiKey := ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_TODOIST_TOKEN"))) cfg.ModuleSecret(name, globalConfig, &apiKey).Load() projects := ymlConfig.UList("projects") + filters := ymlConfig.UList("filters") backend, _ := config.ParseYaml("apiKey: " + apiKey) _ = backend.Set(".projects", projects) + _ = backend.Set(".filters", filters) settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), From c902ce71f51628353fffed60bb5d54ca0033cc59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20Sad=C5=82o?= Date: Sat, 4 Jul 2026 23:19:05 +0200 Subject: [PATCH 2/4] fix: use client for calls instead of raw http --- modules/todo_plus/backend/todoist.go | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/modules/todo_plus/backend/todoist.go b/modules/todo_plus/backend/todoist.go index 42c5d55e4..387bbea77 100644 --- a/modules/todo_plus/backend/todoist.go +++ b/modules/todo_plus/backend/todoist.go @@ -4,8 +4,6 @@ import ( "encoding/json" "fmt" "io" - "net/http" - "net/url" "sort" "strings" "time" @@ -62,20 +60,6 @@ func (todo *Todoist) Setup(config *config.Config) { todo.fetchFilters() } -func (todo *Todoist) doGet(urlStr string) (*http.Response, error) { - req, _ := http.NewRequest("GET", urlStr, nil) - req.Header.Add("Authorization", "Bearer "+todo.apiKey) - return http.DefaultClient.Do(req) -} - -func (todo *Todoist) doPostForm(path string, body io.Reader) (*http.Response, error) { - u, _ := url.Parse("https://api.todoist.com" + path) - req, _ := http.NewRequest("POST", u.String(), body) - req.Header.Add("Authorization", "Bearer "+todo.apiKey) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - return http.DefaultClient.Do(req) -} - func (todo *Todoist) fetchProjects() { todo.projectNameMap = make(map[string]string) @@ -92,7 +76,8 @@ func (todo *Todoist) fetchProjects() { func (todo *Todoist) fetchFilters() { todo.filterMap = make(map[string]todoistFilter) - resp, err := todo.doPostForm("/api/v1/sync", strings.NewReader("sync_token=*&resource_types=%5B%22filters%22%5D")) + syncURL, _ := todo.client.BaseURL().Parse("/api/v1/sync") + resp, err := todo.client.PostForm(syncURL, strings.NewReader("sync_token=*&resource_types=%5B%22filters%22%5D")) if err != nil { return } @@ -211,13 +196,13 @@ func (todo *Todoist) LoadTasks(id string) ([]Task, error) { } func (todo *Todoist) LoadTasksByFilter(query string) ([]Task, error) { - u, _ := url.Parse("https://api.todoist.com/api/v1/tasks/filter") + u, _ := todo.client.BaseURL().Parse("/api/v1/tasks/filter") params := u.Query() params.Set("query", query) params.Set("limit", "200") u.RawQuery = params.Encode() - resp, err := todo.doGet(u.String()) + resp, err := todo.client.Get(u) if err != nil { return nil, err } From f8561df9a2dea1f55c1b69a0907c3b67d2f31421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20Sad=C5=82o?= Date: Sat, 4 Jul 2026 23:32:53 +0200 Subject: [PATCH 3/4] fix: methods used to communicate with todoist api --- modules/todo_plus/backend/todoist.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/modules/todo_plus/backend/todoist.go b/modules/todo_plus/backend/todoist.go index 387bbea77..42c5d55e4 100644 --- a/modules/todo_plus/backend/todoist.go +++ b/modules/todo_plus/backend/todoist.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "io" + "net/http" + "net/url" "sort" "strings" "time" @@ -60,6 +62,20 @@ func (todo *Todoist) Setup(config *config.Config) { todo.fetchFilters() } +func (todo *Todoist) doGet(urlStr string) (*http.Response, error) { + req, _ := http.NewRequest("GET", urlStr, nil) + req.Header.Add("Authorization", "Bearer "+todo.apiKey) + return http.DefaultClient.Do(req) +} + +func (todo *Todoist) doPostForm(path string, body io.Reader) (*http.Response, error) { + u, _ := url.Parse("https://api.todoist.com" + path) + req, _ := http.NewRequest("POST", u.String(), body) + req.Header.Add("Authorization", "Bearer "+todo.apiKey) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + return http.DefaultClient.Do(req) +} + func (todo *Todoist) fetchProjects() { todo.projectNameMap = make(map[string]string) @@ -76,8 +92,7 @@ func (todo *Todoist) fetchProjects() { func (todo *Todoist) fetchFilters() { todo.filterMap = make(map[string]todoistFilter) - syncURL, _ := todo.client.BaseURL().Parse("/api/v1/sync") - resp, err := todo.client.PostForm(syncURL, strings.NewReader("sync_token=*&resource_types=%5B%22filters%22%5D")) + resp, err := todo.doPostForm("/api/v1/sync", strings.NewReader("sync_token=*&resource_types=%5B%22filters%22%5D")) if err != nil { return } @@ -196,13 +211,13 @@ func (todo *Todoist) LoadTasks(id string) ([]Task, error) { } func (todo *Todoist) LoadTasksByFilter(query string) ([]Task, error) { - u, _ := todo.client.BaseURL().Parse("/api/v1/tasks/filter") + u, _ := url.Parse("https://api.todoist.com/api/v1/tasks/filter") params := u.Query() params.Set("query", query) params.Set("limit", "200") u.RawQuery = params.Encode() - resp, err := todo.client.Get(u) + resp, err := todo.doGet(u.String()) if err != nil { return nil, err } From 68133477acd4c40fad8b71d4696de3654dfaaca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20Sad=C5=82o?= Date: Sat, 4 Jul 2026 23:36:32 +0200 Subject: [PATCH 4/4] fix: remove unused type, gofmt project.go --- modules/todo_plus/backend/project.go | 10 +++++----- modules/todo_plus/backend/todoist.go | 5 ----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/modules/todo_plus/backend/project.go b/modules/todo_plus/backend/project.go index 7f8ef7eee..5b7016491 100644 --- a/modules/todo_plus/backend/project.go +++ b/modules/todo_plus/backend/project.go @@ -1,12 +1,12 @@ package backend type Task struct { - ID string - Completed bool - Name string - Prefix string + ID string + Completed bool + Name string + Prefix string DateSuffix string - Overdue bool + Overdue bool } type Project struct { diff --git a/modules/todo_plus/backend/todoist.go b/modules/todo_plus/backend/todoist.go index 42c5d55e4..4ad16bbba 100644 --- a/modules/todo_plus/backend/todoist.go +++ b/modules/todo_plus/backend/todoist.go @@ -20,11 +20,6 @@ type todoistFilter struct { Query string `json:"query"` } -type todoistProject struct { - ID string `json:"id"` - Name string `json:"name"` -} - type syncResponse struct { Filters []todoistFilter `json:"filters"` }