โ– humdrum codex / dots v1.0.0
3.9 KB raw
  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
}