mirror of
https://github.com/taigrr/wtf
synced 2025-01-18 04:03:14 -08:00
Add Asana Module (#1052)
* Add Asana Module * Add Asana Module to the widget_maker * Asana Module - addressing linting concerns * Asana Module - ran go mod tidy
This commit is contained in:
committed by
GitHub
parent
6ecec4f149
commit
a8b0fa897a
280
modules/asana/client.go
Normal file
280
modules/asana/client.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package asana
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
asana "bitbucket.org/mikehouston/asana-go"
|
||||
)
|
||||
|
||||
func fetchTasksFromProject(token, projectId, mode string) ([]*TaskItem, error) {
|
||||
taskItems := []*TaskItem{}
|
||||
uidToName := make(map[string]string)
|
||||
|
||||
client := asana.NewClientWithAccessToken(token)
|
||||
|
||||
uid, err := getCurrentUserId(client, mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := &asana.TaskQuery{
|
||||
Project: projectId,
|
||||
}
|
||||
|
||||
fetchedTasks, _, err := getTasksFromAsana(client, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching tasks: %s", err)
|
||||
}
|
||||
|
||||
processFetchedTasks(client, &fetchedTasks, &taskItems, &uidToName, mode, projectId, uid)
|
||||
|
||||
return taskItems, nil
|
||||
}
|
||||
|
||||
func fetchTasksFromProjectSections(token, projectId string, sections []string, mode string) ([]*TaskItem, error) {
|
||||
taskItems := []*TaskItem{}
|
||||
uidToName := make(map[string]string)
|
||||
|
||||
client := asana.NewClientWithAccessToken(token)
|
||||
|
||||
uid, err := getCurrentUserId(client, mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &asana.Project{
|
||||
ID: projectId,
|
||||
}
|
||||
|
||||
for _, section := range sections {
|
||||
|
||||
sectionId, err := findSection(client, p, section)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching tasks: %s", err)
|
||||
}
|
||||
|
||||
q := &asana.TaskQuery{
|
||||
Section: sectionId,
|
||||
}
|
||||
|
||||
fetchedTasks, _, err := getTasksFromAsana(client, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching tasks: %s", err)
|
||||
}
|
||||
|
||||
if len(fetchedTasks) > 0 {
|
||||
taskItem := &TaskItem{
|
||||
name: section,
|
||||
taskType: TASK_SECTION,
|
||||
}
|
||||
|
||||
taskItems = append(taskItems, taskItem)
|
||||
}
|
||||
|
||||
processFetchedTasks(client, &fetchedTasks, &taskItems, &uidToName, mode, projectId, uid)
|
||||
|
||||
}
|
||||
|
||||
return taskItems, nil
|
||||
}
|
||||
|
||||
func fetchTasksFromWorkspace(token, workspaceId, mode string) ([]*TaskItem, error) {
|
||||
taskItems := []*TaskItem{}
|
||||
uidToName := make(map[string]string)
|
||||
|
||||
client := asana.NewClientWithAccessToken(token)
|
||||
|
||||
uid, err := getCurrentUserId(client, mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := &asana.TaskQuery{
|
||||
Workspace: workspaceId,
|
||||
Assignee: "me",
|
||||
}
|
||||
|
||||
fetchedTasks, _, err := getTasksFromAsana(client, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching tasks: %s", err)
|
||||
}
|
||||
|
||||
processFetchedTasks(client, &fetchedTasks, &taskItems, &uidToName, mode, workspaceId, uid)
|
||||
|
||||
return taskItems, nil
|
||||
|
||||
}
|
||||
|
||||
func toggleTaskCompletionById(token, taskId string) error {
|
||||
client := asana.NewClientWithAccessToken(token)
|
||||
|
||||
t := &asana.Task{
|
||||
ID: taskId,
|
||||
}
|
||||
|
||||
err := t.Fetch(client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fetching task: %s", err)
|
||||
}
|
||||
|
||||
updateReq := &asana.UpdateTaskRequest{}
|
||||
|
||||
if *t.Completed {
|
||||
f := false
|
||||
updateReq.Completed = &f
|
||||
} else {
|
||||
t := true
|
||||
updateReq.Completed = &t
|
||||
}
|
||||
|
||||
err = t.Update(client, updateReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating task: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func processFetchedTasks(client *asana.Client, fetchedTasks *[]*asana.Task, taskItems *[]*TaskItem, uidToName *map[string]string, mode, projectId, uid string) {
|
||||
|
||||
for _, task := range *fetchedTasks {
|
||||
switch {
|
||||
case strings.HasSuffix(mode, "_all"):
|
||||
if task.Assignee != nil {
|
||||
// Check if we have already looked up this user
|
||||
if assigneeName, ok := (*uidToName)[task.Assignee.ID]; ok {
|
||||
task.Assignee.Name = assigneeName
|
||||
} else {
|
||||
// We haven't looked up this user before, perform the lookup now
|
||||
assigneeName, err := getOtherUserEmail(client, task.Assignee.ID)
|
||||
if err != nil {
|
||||
task.Assignee.Name = "Error"
|
||||
}
|
||||
(*uidToName)[task.Assignee.ID] = assigneeName
|
||||
task.Assignee.Name = assigneeName
|
||||
}
|
||||
} else {
|
||||
task.Assignee = &asana.User{
|
||||
Name: "Unassigned",
|
||||
}
|
||||
}
|
||||
taskItem := buildTaskItem(task, projectId)
|
||||
(*taskItems) = append((*taskItems), taskItem)
|
||||
case !strings.HasSuffix(mode, "_all") && task.Assignee != nil && task.Assignee.ID == uid:
|
||||
taskItem := buildTaskItem(task, projectId)
|
||||
(*taskItems) = append((*taskItems), taskItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildTaskItem(task *asana.Task, projectId string) *TaskItem {
|
||||
dueOnString := ""
|
||||
if task.DueOn != nil {
|
||||
dueOn := time.Time(*task.DueOn)
|
||||
currentYear, _, _ := time.Now().Date()
|
||||
if currentYear != dueOn.Year() {
|
||||
dueOnString = dueOn.Format("Jan 2 2006")
|
||||
} else {
|
||||
dueOnString = dueOn.Format("Jan 2")
|
||||
}
|
||||
}
|
||||
|
||||
assignString := ""
|
||||
if task.Assignee != nil {
|
||||
assignString = task.Assignee.Name
|
||||
}
|
||||
|
||||
taskItem := &TaskItem{
|
||||
name: task.Name,
|
||||
id: task.ID,
|
||||
numSubtasks: task.NumSubtasks,
|
||||
dueOn: dueOnString,
|
||||
url: fmt.Sprintf("https://app.asana.com/0/%s/%s/f", projectId, task.ID),
|
||||
taskType: TASK_TYPE,
|
||||
completed: *task.Completed,
|
||||
assignee: assignString,
|
||||
}
|
||||
|
||||
return taskItem
|
||||
|
||||
}
|
||||
|
||||
func getOtherUserEmail(client *asana.Client, uid string) (string, error) {
|
||||
if uid == "" {
|
||||
return "", fmt.Errorf("missing uid")
|
||||
}
|
||||
|
||||
u := &asana.User{
|
||||
ID: uid,
|
||||
}
|
||||
|
||||
err := u.Fetch(client, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error fetching user: %s", err)
|
||||
}
|
||||
|
||||
return u.Email, nil
|
||||
}
|
||||
|
||||
func getCurrentUserId(client *asana.Client, mode string) (string, error) {
|
||||
if strings.HasSuffix(mode, "_all") {
|
||||
return "", nil
|
||||
}
|
||||
u, err := client.CurrentUser()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error getting current user: %s", err)
|
||||
}
|
||||
|
||||
return u.ID, nil
|
||||
}
|
||||
|
||||
func findSection(client *asana.Client, project *asana.Project, sectionName string) (string, error) {
|
||||
sectionId := ""
|
||||
|
||||
sections, _, err := project.Sections(client, &asana.Options{
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error getting sections: %s", err)
|
||||
}
|
||||
|
||||
for _, section := range sections {
|
||||
if section.Name == sectionName {
|
||||
sectionId = section.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if sectionId == "" {
|
||||
return "", fmt.Errorf("we didn't find the section %s", sectionName)
|
||||
}
|
||||
|
||||
return sectionId, nil
|
||||
}
|
||||
|
||||
func getTasksFromAsana(client *asana.Client, q *asana.TaskQuery) ([]*asana.Task, bool, error) {
|
||||
moreTasks := false
|
||||
|
||||
tasks, np, err := client.QueryTasks(q, &asana.Options{
|
||||
Limit: 100,
|
||||
Fields: []string{
|
||||
"assignee",
|
||||
"name",
|
||||
"num_subtasks",
|
||||
"due_on",
|
||||
"completed",
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("error querying tasks: %s", err)
|
||||
}
|
||||
|
||||
if np != nil {
|
||||
moreTasks = true
|
||||
}
|
||||
|
||||
return tasks, moreTasks, nil
|
||||
}
|
||||
20
modules/asana/keyboard.go
Normal file
20
modules/asana/keyboard.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package asana
|
||||
|
||||
import "github.com/gdamore/tcell"
|
||||
|
||||
func (widget *Widget) initializeKeyboardControls() {
|
||||
widget.InitializeHelpTextKeyboardControl(widget.ShowHelp)
|
||||
widget.InitializeRefreshKeyboardControl(widget.Refresh)
|
||||
|
||||
widget.SetKeyboardChar("j", widget.Next, "Select next task")
|
||||
widget.SetKeyboardChar("k", widget.Prev, "Select previous task")
|
||||
widget.SetKeyboardChar("q", widget.Unselect, "Unselect task")
|
||||
widget.SetKeyboardChar("o", widget.openTask, "Open task in browser")
|
||||
widget.SetKeyboardChar("x", widget.toggleTaskCompletion, "Toggles the task's completion state")
|
||||
widget.SetKeyboardChar("?", widget.ShowHelp, "Shows help")
|
||||
|
||||
widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next task")
|
||||
widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous task")
|
||||
widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Unselect task")
|
||||
widget.SetKeyboardKey(tcell.KeyEnter, widget.openTask, "Open task in browser")
|
||||
}
|
||||
48
modules/asana/settings.go
Normal file
48
modules/asana/settings.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package asana
|
||||
|
||||
import (
|
||||
"github.com/olebedev/config"
|
||||
"github.com/wtfutil/wtf/cfg"
|
||||
"github.com/wtfutil/wtf/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultFocusable = true
|
||||
defaultTitle = "Asana"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
*cfg.Common
|
||||
|
||||
projectId string `help:"The Asana Project ID. If the mode is 'project' or 'project_sections' this is required to known which Asana Project to pull your tasks from" values:"A valid Asana Project ID string" optional:"true"`
|
||||
|
||||
workspaceId string `help:"The Asana Workspace ID. If mode is 'workspace' this is required" values:"A valid Asana Workspace ID string" optional:"true"`
|
||||
|
||||
sections []string `help:"The Asana Section Labels to fetch from the Project. Required if the mode is 'project_sections'" values:"An array of Asana Section Label strings" optional:"true"`
|
||||
|
||||
allUsers bool `help:"Fetch tasks for all users, defaults to false" values:"bool" optional:"true"`
|
||||
|
||||
mode string `help:"What mode to query Asana, 'project', 'project_sections', 'workspace'" values:"A string with either 'project', 'project_sections' or 'workspace'"`
|
||||
|
||||
hideComplete bool `help:"Hide completed tasks, defaults to false" values:"bool" optional:"true"`
|
||||
|
||||
apiKey string `help:"Your Asana Personal Access Token. Leave this blank to use the WTF_ASANA_TOKEN environment variable." values:"Your Asana Personal Access Token as a string" optional:"true"`
|
||||
|
||||
token string
|
||||
}
|
||||
|
||||
func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings {
|
||||
settings := Settings{
|
||||
Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig,
|
||||
globalConfig),
|
||||
projectId: ymlConfig.UString("projectId", ""),
|
||||
apiKey: ymlConfig.UString("apiKey", ""),
|
||||
workspaceId: ymlConfig.UString("workspaceId", ""),
|
||||
sections: utils.ToStrs(ymlConfig.UList("sections")),
|
||||
allUsers: ymlConfig.UBool("allUsers", false),
|
||||
mode: ymlConfig.UString("mode", ""),
|
||||
hideComplete: ymlConfig.UBool("hideComplete", false),
|
||||
}
|
||||
|
||||
return &settings
|
||||
}
|
||||
259
modules/asana/widget.go
Normal file
259
modules/asana/widget.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package asana
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
"github.com/wtfutil/wtf/utils"
|
||||
"github.com/wtfutil/wtf/view"
|
||||
)
|
||||
|
||||
type TaskType int
|
||||
|
||||
const (
|
||||
TASK_TYPE TaskType = iota
|
||||
TASK_SECTION
|
||||
TASK_BREAK
|
||||
)
|
||||
|
||||
type TaskItem struct {
|
||||
name string
|
||||
numSubtasks int32
|
||||
dueOn string
|
||||
id string
|
||||
url string
|
||||
taskType TaskType
|
||||
completed bool
|
||||
assignee string
|
||||
}
|
||||
|
||||
type Widget struct {
|
||||
view.ScrollableWidget
|
||||
|
||||
tasks []*TaskItem
|
||||
|
||||
mu sync.Mutex
|
||||
err error
|
||||
settings *Settings
|
||||
tviewApp *tview.Application
|
||||
}
|
||||
|
||||
func NewWidget(tviewApp *tview.Application, pages *tview.Pages, settings *Settings) *Widget {
|
||||
widget := &Widget{
|
||||
ScrollableWidget: view.NewScrollableWidget(tviewApp, pages, settings.Common),
|
||||
|
||||
tviewApp: tviewApp,
|
||||
settings: settings,
|
||||
}
|
||||
|
||||
widget.SetRenderFunction(widget.Render)
|
||||
widget.initializeKeyboardControls()
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
/* -------------------- Exported Functions -------------------- */
|
||||
|
||||
func (widget *Widget) Refresh() {
|
||||
widget.tasks = nil
|
||||
widget.err = nil
|
||||
widget.SetItemCount(0)
|
||||
|
||||
widget.mu.Lock()
|
||||
defer widget.mu.Unlock()
|
||||
tasks, err := widget.Fetch(
|
||||
widget.settings.workspaceId,
|
||||
widget.settings.projectId,
|
||||
widget.settings.mode,
|
||||
widget.settings.sections,
|
||||
widget.settings.allUsers,
|
||||
)
|
||||
if err != nil {
|
||||
widget.err = err
|
||||
} else {
|
||||
widget.tasks = tasks
|
||||
widget.SetItemCount(len(tasks))
|
||||
}
|
||||
|
||||
widget.Render()
|
||||
}
|
||||
|
||||
func (widget *Widget) Render() {
|
||||
widget.Redraw(widget.content)
|
||||
}
|
||||
|
||||
func (widget *Widget) Fetch(workspaceId, projectId, mode string, sections []string, allUsers bool) ([]*TaskItem, error) {
|
||||
|
||||
availableModes := map[string]interface{}{
|
||||
"project": nil,
|
||||
"project_sections": nil,
|
||||
"workspace": nil,
|
||||
}
|
||||
|
||||
if _, ok := availableModes[mode]; !ok {
|
||||
return nil, fmt.Errorf("missing mode, or mode is invalid - please set to project, project_sections or workspace")
|
||||
}
|
||||
|
||||
if widget.settings.apiKey != "" {
|
||||
widget.settings.token = widget.settings.apiKey
|
||||
} else {
|
||||
widget.settings.token = os.Getenv("WTF_ASANA_TOKEN")
|
||||
}
|
||||
|
||||
if widget.settings.token == "" {
|
||||
return nil, fmt.Errorf("missing environment variable token or apikey config")
|
||||
}
|
||||
|
||||
subMode := mode
|
||||
if allUsers && mode != "workspace" {
|
||||
subMode += "_all"
|
||||
}
|
||||
|
||||
if projectId == "" && strings.HasPrefix(subMode, "project") {
|
||||
return nil, fmt.Errorf("missing project id")
|
||||
}
|
||||
|
||||
if workspaceId == "" && subMode == "workspace" {
|
||||
return nil, fmt.Errorf("missing workspace id")
|
||||
}
|
||||
|
||||
var tasks []*TaskItem
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(subMode, "project_sections"):
|
||||
tasks, err = fetchTasksFromProjectSections(widget.settings.token, projectId, sections, subMode)
|
||||
case strings.HasPrefix(subMode, "project"):
|
||||
tasks, err = fetchTasksFromProject(widget.settings.token, projectId, subMode)
|
||||
case subMode == "workspace":
|
||||
tasks, err = fetchTasksFromWorkspace(widget.settings.token, workspaceId, subMode)
|
||||
default:
|
||||
err = fmt.Errorf("no mode found")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tasks, nil
|
||||
|
||||
}
|
||||
|
||||
/* -------------------- Unexported Functions -------------------- */
|
||||
|
||||
func (widget *Widget) content() (string, string, bool) {
|
||||
|
||||
title := widget.CommonSettings().Title
|
||||
if widget.err != nil {
|
||||
return title, widget.err.Error(), true
|
||||
}
|
||||
|
||||
data := widget.tasks
|
||||
if len(data) == 0 {
|
||||
return title, "No data", false
|
||||
}
|
||||
|
||||
var str string
|
||||
|
||||
for idx, taskItem := range data {
|
||||
switch {
|
||||
case taskItem.taskType == TASK_TYPE:
|
||||
if widget.settings.hideComplete && taskItem.completed {
|
||||
continue
|
||||
}
|
||||
|
||||
rowColor := widget.RowColor(idx)
|
||||
|
||||
completed := "[ []"
|
||||
if taskItem.completed {
|
||||
completed = "[x[]"
|
||||
}
|
||||
|
||||
row := ""
|
||||
|
||||
if widget.settings.allUsers && taskItem.assignee != "" {
|
||||
row = fmt.Sprintf(
|
||||
"[%s] %s %s: %s",
|
||||
rowColor,
|
||||
completed,
|
||||
taskItem.assignee,
|
||||
taskItem.name,
|
||||
)
|
||||
} else {
|
||||
row = fmt.Sprintf(
|
||||
"[%s] %s %s",
|
||||
rowColor,
|
||||
completed,
|
||||
taskItem.name,
|
||||
)
|
||||
}
|
||||
|
||||
if taskItem.numSubtasks > 0 {
|
||||
row += fmt.Sprintf(" (%d)", taskItem.numSubtasks)
|
||||
}
|
||||
|
||||
if taskItem.dueOn != "" {
|
||||
row += fmt.Sprintf(" due: %s", taskItem.dueOn)
|
||||
}
|
||||
|
||||
row += " [white]"
|
||||
|
||||
str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name))
|
||||
|
||||
case taskItem.taskType == TASK_SECTION:
|
||||
if idx > 1 {
|
||||
row := "[white] "
|
||||
|
||||
str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name))
|
||||
}
|
||||
row := fmt.Sprintf(
|
||||
"[white] %s [white]",
|
||||
taskItem.name,
|
||||
)
|
||||
|
||||
str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name))
|
||||
|
||||
row = "[white] "
|
||||
|
||||
str += utils.HighlightableHelper(widget.View, row, idx, len(taskItem.name))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return title, str, false
|
||||
|
||||
}
|
||||
|
||||
func (widget *Widget) openTask() {
|
||||
sel := widget.GetSelected()
|
||||
|
||||
if sel >= 0 && widget.tasks != nil && sel < len(widget.tasks) {
|
||||
task := widget.tasks[sel]
|
||||
if task.taskType == TASK_TYPE && task.url != "" {
|
||||
utils.OpenFile(task.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (widget *Widget) toggleTaskCompletion() {
|
||||
sel := widget.GetSelected()
|
||||
|
||||
if sel >= 0 && widget.tasks != nil && sel < len(widget.tasks) {
|
||||
task := widget.tasks[sel]
|
||||
if task.taskType == TASK_TYPE {
|
||||
widget.mu.Lock()
|
||||
|
||||
err := toggleTaskCompletionById(widget.settings.token, task.id)
|
||||
if err != nil {
|
||||
widget.err = err
|
||||
}
|
||||
|
||||
widget.mu.Unlock()
|
||||
widget.Refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user