Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions modules/todo_plus/backend/project.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package backend

type Task struct {
ID string
Completed bool
Name string
ID string
Completed bool
Name string
Prefix string
DateSuffix string
Overdue bool
}

type Project struct {
Expand Down
253 changes: 247 additions & 6 deletions modules/todo_plus/backend/todoist.go
Original file line number Diff line number Diff line change
@@ -1,25 +1,115 @@
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 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 {
return "Todoist"
}

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 {
Expand All @@ -30,12 +120,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,
Expand All @@ -58,6 +153,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,
Expand All @@ -67,6 +184,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 {
Expand All @@ -80,6 +205,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)
Expand All @@ -102,5 +339,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
}
17 changes: 16 additions & 1 deletion modules/todo_plus/display.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)

Expand Down
2 changes: 2 additions & 0 deletions modules/todo_plus/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down