1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
|
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/humdrum-tiv/dots-cli/internal/day"
)
// ErrUnauthorized means the stored token was rejected (or absent).
var ErrUnauthorized = errors.New("not linked โ run `dots auth`")
// ErrForbidden means the server refused for subscription reasons (403).
var ErrForbidden = errors.New("requires an active subscription")
type Client struct {
BaseURL string
Token string
HTTP *http.Client
}
func New(baseURL, token string) *Client {
return &Client{BaseURL: baseURL, Token: token, HTTP: &http.Client{Timeout: 15 * time.Second}}
}
// DayData is the CLI's view of GET /api/widget/data.
type DayData struct {
BlockSize int
Timezone string
Types []day.ActivityType
Blocks map[int]string // only logged slots
PlanBlocks map[int]string // only planned slots
}
type widgetPayload struct {
Day struct {
BlockSize int `json:"blockSize"`
Timezone string `json:"timezone"`
} `json:"day"`
ActivityTypes []struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
IsArchived bool `json:"isArchived"`
} `json:"activityTypes"`
Blocks map[string]*string `json:"blocks"`
PlanBlocks map[string]*string `json:"planBlocks"`
}
func (c *Client) do(method, path string, query url.Values, body any) (*http.Response, error) {
u := c.BaseURL + path
if query != nil {
u += "?" + query.Encode()
}
var buf *bytes.Buffer = &bytes.Buffer{}
if body != nil {
if err := json.NewEncoder(buf).Encode(body); err != nil {
return nil, err
}
}
req, err := http.NewRequest(method, u, buf)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("can't reach %s: %w", c.BaseURL, err)
}
switch {
case resp.StatusCode == http.StatusUnauthorized:
resp.Body.Close()
return nil, ErrUnauthorized
case resp.StatusCode == http.StatusForbidden:
resp.Body.Close()
return nil, ErrForbidden
case resp.StatusCode >= 400:
resp.Body.Close()
return nil, fmt.Errorf("server error: %s", resp.Status)
}
return resp, nil
}
func intKeyMap(in map[string]*string) map[int]string {
out := make(map[int]string)
for k, v := range in {
if v == nil {
continue
}
i, err := strconv.Atoi(k)
if err != nil {
continue
}
out[i] = *v
}
return out
}
func (c *Client) GetDay(date, tz string) (*DayData, error) {
q := url.Values{"date": {date}, "tz": {tz}}
resp, err := c.do(http.MethodGet, "/api/widget/data", q, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var p widgetPayload
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return nil, fmt.Errorf("bad response: %w", err)
}
if p.Day.BlockSize != 15 && p.Day.BlockSize != 30 && p.Day.BlockSize != 60 {
return nil, fmt.Errorf("bad response: unsupported block size %d", p.Day.BlockSize)
}
d := &DayData{
BlockSize: p.Day.BlockSize,
Timezone: p.Day.Timezone,
Blocks: intKeyMap(p.Blocks),
PlanBlocks: intKeyMap(p.PlanBlocks),
}
for _, t := range p.ActivityTypes {
if t.IsArchived {
continue
}
d.Types = append(d.Types, day.ActivityType{ID: t.ID, Name: t.Name, Color: t.Color})
}
return d, nil
}
func (c *Client) WriteBlocks(date string, assignments map[int]*string) error {
body := make(map[string]*string, len(assignments))
for k, v := range assignments {
body[strconv.Itoa(k)] = v
}
resp, err := c.do(http.MethodPut, "/api/days/"+date+"/blocks", nil, body)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
func (c *Client) AddNote(date, content string) error {
resp, err := c.do(http.MethodPost, "/api/days/"+date+"/notes", nil, map[string]string{"content": content})
if err != nil {
return err
}
resp.Body.Close()
return nil
}
|