package ticktick import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "log/slog" "net/http" "net/url" "os" "time" "github.com/spf13/viper" ) var ( authState string ) const ( DateTimeLayout = "2006-01-02T15:04:05-0700" ) type Project struct { Id string `json:"id"` Name string `json:"name"` Color string `json:"color,omitempty"` SortOrder int64 `json:"sortOrder,omitempty"` Closed bool `json:"closed,omitempty"` GroupId string `json:"groupId,omitempty"` ViewMode string `json:"viewMode,omitempty"` Permission string `json:"permission,omitempty"` Kind string `json:"kind,omitempty"` } type Column struct { Id string `json:"id"` Name string `json:"name"` ProjectId string `json:"projectId"` SortOrder int64 `json:"sortOrder,omitempty"` } type Task struct { Id string `json:"id"` ProjectId string `json:"projectId"` Title string `json:"title"` IsAllDay bool `json:"isAllDay,omitempty"` CompletedTime string `json:"completedTime,omitempty"` Content string `json:"content,omitempty"` Desc string `json:"desc,omitempty"` DueDate string `json:"dueDate,omitempty"` Items []interface{} `json:"items,omitempty"` Priority int `json:"priority,omitempty"` Reminders []string `json:"reminders,omitempty"` RepeatFlag string `json:"repeatFlag,omitempty"` SortOrder int64 `json:"sortOrder,omitempty"` StartDate string `json:"startDate,omitempty"` Status int32 `json:"status,omitempty"` TimeZone string `json:"timeZone,omitempty"` } type ProjectData struct { Project Project `json:"project"` Tasks []Task `json:"tasks"` Columns []Column `json:"columns,omitempty"` } func Init() { if !viper.InConfig("ticktick.clientId") { slog.Error("TickTick clientId not found in config file, exiting..") os.Exit(1) } if !viper.InConfig("ticktick.clientSecret") { slog.Error("TickTick clientSecret not found in config file, exiting..") os.Exit(1) } if viper.InConfig("ticktick.token") { _, err := getProjects() if err != nil { if err.Error() == "error response from TickTick: 401 Unauthorized" { } } } else { beginAuth() } } func HandleAuthCode(w http.ResponseWriter, r *http.Request) { state := r.URL.Query().Get("state") code := r.URL.Query().Get("code") if state != authState { slog.Warn(fmt.Sprintln("HandleAuthCode Invalid state", state)) http.Error(w, "Invalid state", http.StatusBadRequest) return } params := map[string]string{ "code": code, "grant_type": "authorization_code", "scope": "tasks:read tasks:write", "redirect_uri": viper.GetString("ticktick.redirectUri"), } formedParams := url.Values{} for key, value := range params { formedParams.Add(key, value) } req, err := http.NewRequest("POST", "https://ticktick.com/oauth/token", bytes.NewBufferString(formedParams.Encode())) if err != nil { slog.Warn(fmt.Sprintln("HandleAuthCode Error creating request", err)) http.Error(w, "Error creating request", http.StatusInternalServerError) return } client := &http.Client{ Timeout: time.Second * 10, } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetBasicAuth(viper.GetString("ticktick.clientId"), viper.GetString("ticktick.clientSecret")) resp, err := client.Do(req) if err != nil { slog.Warn(fmt.Sprintln("HandleAuthCode Error sending request", err)) http.Error(w, "Error sending request", http.StatusInternalServerError) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { slog.Warn(fmt.Sprintln("HandleAuthCode Unexpected response status", resp.StatusCode)) http.Error(w, "Unexpected response status", http.StatusInternalServerError) return } decoder := json.NewDecoder(resp.Body) var tokenResponse map[string]interface{} err = decoder.Decode(&tokenResponse) if err != nil { slog.Warn(fmt.Sprintln("HandleAuthCode Error decoding response", err)) http.Error(w, "Error decoding response", http.StatusInternalServerError) return } token := tokenResponse["access_token"].(string) viper.Set("ticktick.token", token) err = viper.WriteConfig() if err != nil { slog.Warn(fmt.Sprintln("HandleAuthCode Error writing config", err)) http.Error(w, "Error writing config", http.StatusInternalServerError) return } w.Write([]byte("Authorization successful")) } func GetTasks(projectId string) []Task { getTaskUrl := fmt.Sprintf("https://api.ticktick.com/open/v1/project/%s/data", projectId) token := viper.GetString("ticktick.token") req, err := http.NewRequest("GET", getTaskUrl, nil) req.Header.Set("Authorization", "Bearer "+token) if err != nil { slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error creating request to TickTick", err)) return nil } var projectData ProjectData client := &http.Client{ Timeout: time.Second * 10, } resp, err := client.Do(req) if err != nil { slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error sending request to TickTick", err)) return nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound { slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error response from TickTick", resp.Status)) return nil } if resp.StatusCode == http.StatusNotFound { return nil } decoder := json.NewDecoder(resp.Body) err = decoder.Decode(&projectData) if err != nil { slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error decoding response from TickTick", err)) return nil } return projectData.Tasks } func HasDuplicateTask(projectId string, taskTitile string) bool { tasks := GetTasks(projectId) for _, task := range tasks { if task.Title == taskTitile { return true } } return false } func CreateTask(task Task) error { if HasDuplicateTask(task.ProjectId, task.Title) { return nil } token := viper.GetString("ticktick.token") createTaskUrl := "https://api.ticktick.com/open/v1/task" payload, err := json.Marshal(task) if err != nil { slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error marshalling", err)) return err } req, err := http.NewRequest("POST", createTaskUrl, bytes.NewBuffer(payload)) if err != nil { slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error creating request to TickTick", err)) return err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") client := &http.Client{ Timeout: time.Second * 10, } resp, err := client.Do(req) if err != nil { slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error sending request to TickTick", err)) return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error response from TickTick", resp.Status)) return fmt.Errorf("error response from TickTick: %s", resp.Status) } return nil } func getProjects() ([]Project, error) { token := viper.GetString("ticktick.token") req, err := http.NewRequest("GET", "https://api.ticktick.com/open/v1/project/", nil) req.Header.Set("Authorization", "Bearer "+token) if err != nil { slog.Warn(fmt.Sprintln("Error creating request to TickTick", err)) return nil, err } client := &http.Client{ Timeout: time.Second * 10, } resp, err := client.Do(req) if err != nil { slog.Warn(fmt.Sprintln("Error sending request to TickTick", err)) return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { slog.Warn(fmt.Sprintln("Error response from TickTick", resp.Status)) return nil, fmt.Errorf("error response from TickTick: %s", resp.Status) } var projects []Project decoder := json.NewDecoder(resp.Body) err = decoder.Decode(&projects) if err != nil { slog.Warn(fmt.Sprintln("Error decoding response from TickTick", err)) return nil, err } return projects, nil } func beginAuth() { if !viper.InConfig("ticktick.redirectUri") { slog.Error("TickTick redirectUri not found in config file, exiting..") os.Exit(1) } baseUrl := "https://ticktick.com/oauth/authorize?" authUrl, _ := url.Parse(baseUrl) authStateBytes := make([]byte, 6) _, err := rand.Read(authStateBytes) if err != nil { slog.Error(fmt.Sprintln("Error generating auth state", err)) os.Exit(1) } authState = hex.EncodeToString(authStateBytes) params := url.Values{} params.Add("client_id", viper.GetString("ticktick.clientId")) params.Add("response_type", "code") params.Add("redirect_uri", viper.GetString("ticktick.redirectUri")) params.Add("state", authState) params.Add("scope", "tasks:read tasks:write") authUrl.RawQuery = params.Encode() slog.Info(fmt.Sprintln("Please visit the following URL to authorize TickTick:", authUrl.String())) }