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 }