2025-06-17 16:39:51 +02:00
|
|
|
package mite
|
|
|
|
|
|
|
|
import (
|
2025-06-18 11:06:12 +02:00
|
|
|
"bytes"
|
2025-06-17 16:39:51 +02:00
|
|
|
"encoding/json"
|
2025-06-20 16:12:48 +02:00
|
|
|
"errors"
|
2025-06-17 16:39:51 +02:00
|
|
|
"fmt"
|
|
|
|
"net/http"
|
|
|
|
"time"
|
|
|
|
)
|
|
|
|
|
|
|
|
type APIClient struct {
|
|
|
|
domain string
|
|
|
|
apiKey string
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewClient(domain, apikey string) APIClient {
|
|
|
|
return APIClient{
|
|
|
|
domain: domain,
|
|
|
|
apiKey: apikey,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
type APIObject interface {
|
|
|
|
GetID() int
|
|
|
|
GetName() string
|
|
|
|
}
|
|
|
|
|
|
|
|
// https://demo.mite.de/
|
|
|
|
//curl -H 'X-MiteApiKey: YOUR_API_KEY' https://demo.mite.de/projects.json
|
|
|
|
|
|
|
|
type APIProjects []ProjectHolder
|
|
|
|
type ProjectHolder struct {
|
|
|
|
Project Project `json:"project"`
|
|
|
|
}
|
|
|
|
type Projects []Project
|
|
|
|
type Project struct {
|
|
|
|
Budget int `json:"budget"`
|
|
|
|
BudgetType string `json:"budget_type"`
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
CustomerID int `json:"customer_id"`
|
|
|
|
HourlyRate int `json:"hourly_rate"`
|
|
|
|
ID int `json:"id"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
Note string `json:"note"`
|
|
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
Archived bool `json:"archived"`
|
|
|
|
CustomerName string `json:"customer_name"`
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p Project) String() string {
|
|
|
|
return fmt.Sprintf("%d: %s (%s)", p.ID, p.Name, p.CustomerName)
|
|
|
|
}
|
2025-06-20 17:08:15 +02:00
|
|
|
func (p Project) GetID() int { return p.ID }
|
|
|
|
func (p Project) GetName() string {
|
|
|
|
return p.Name + " (" + p.CustomerName + ")"
|
|
|
|
}
|
2025-06-17 16:39:51 +02:00
|
|
|
|
|
|
|
func (a APIClient) GetProjects() (Projects, error) {
|
|
|
|
// GET /projects.json
|
|
|
|
p := APIProjects{}
|
2025-06-20 16:12:48 +02:00
|
|
|
err := a.get("/projects.json", &p)
|
2025-06-17 16:39:51 +02:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
out := Projects{}
|
|
|
|
for i := range p {
|
|
|
|
out = append(out, p[i].Project)
|
|
|
|
}
|
|
|
|
return out, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
type apiCustomers []customerHolder
|
|
|
|
type customerHolder struct {
|
|
|
|
Customer Customer `json:"customer"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type Customers []Customer
|
|
|
|
type Customer struct {
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
HourlyRate int `json:"hourly_rate"`
|
|
|
|
ID int `json:"id"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
Note string `json:"note"`
|
|
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
Archived bool `json:"archived"`
|
|
|
|
ActiveHourlyRate string `json:"active_hourly_rate"`
|
|
|
|
HourlyRatesPerService []struct {
|
|
|
|
ServiceID int `json:"service_id"`
|
|
|
|
HourlyRate int `json:"hourly_rate"`
|
|
|
|
} `json:"hourly_rates_per_service"`
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c Customer) String() string {
|
|
|
|
return fmt.Sprintf("%d: %s", c.ID, c.Name)
|
|
|
|
}
|
|
|
|
func (c Customer) GetID() int { return c.ID }
|
|
|
|
func (c Customer) GetName() string { return c.Name }
|
|
|
|
|
|
|
|
func (a APIClient) GetCustomers() (Customers, error) {
|
|
|
|
// GET /customers.json
|
|
|
|
p := apiCustomers{}
|
2025-06-20 16:12:48 +02:00
|
|
|
err := a.get("/customers.json", &p)
|
2025-06-17 16:39:51 +02:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
out := Customers{}
|
|
|
|
for i := range p {
|
|
|
|
out = append(out, p[i].Customer)
|
|
|
|
}
|
|
|
|
return out, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
type apiServices []serviceHolder
|
|
|
|
type serviceHolder struct {
|
|
|
|
Service Service `json:"service"`
|
|
|
|
}
|
|
|
|
type Services []Service
|
|
|
|
type Service struct {
|
|
|
|
Billable bool `json:"billable"`
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
HourlyRate int `json:"hourly_rate"`
|
|
|
|
ID int `json:"id"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
Note string `json:"note"`
|
|
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
Archived bool `json:"archived"`
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s Service) String() string {
|
|
|
|
return fmt.Sprintf("%d: %s", s.ID, s.Name)
|
|
|
|
}
|
2025-06-18 13:20:33 +02:00
|
|
|
func (s Service) GetID() int { return s.ID }
|
|
|
|
func (s Service) GetName() string {
|
|
|
|
prefix := "💰"
|
|
|
|
if !s.Billable {
|
|
|
|
prefix = "☮️"
|
|
|
|
}
|
|
|
|
return prefix + " " + s.Name
|
|
|
|
|
|
|
|
}
|
2025-06-17 16:39:51 +02:00
|
|
|
|
|
|
|
func (a APIClient) GetServices() (Services, error) {
|
|
|
|
// GET /services.json
|
|
|
|
p := apiServices{}
|
2025-06-20 16:12:48 +02:00
|
|
|
err := a.get("/services.json", &p)
|
2025-06-17 16:39:51 +02:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
out := Services{}
|
|
|
|
for i := range p {
|
|
|
|
out = append(out, p[i].Service)
|
|
|
|
}
|
|
|
|
return out, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
type apiTimeEntry []timeEntryHolder
|
|
|
|
type timeEntryHolder struct {
|
|
|
|
TimeEntry TimeEntry `json:"time_entry"`
|
|
|
|
}
|
|
|
|
type TimeEntries []TimeEntry
|
|
|
|
type TimeEntry struct {
|
|
|
|
Billable bool `json:"billable"`
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
DateAt string `json:"date_at"`
|
|
|
|
ID int `json:"id"`
|
|
|
|
Locked bool `json:"locked"`
|
|
|
|
Minutes int `json:"minutes"`
|
|
|
|
StartedTime interface{} `json:"started_time"`
|
|
|
|
ProjectID int `json:"project_id"`
|
|
|
|
Revenue float64 `json:"revenue"`
|
|
|
|
HourlyRate int `json:"hourly_rate"`
|
|
|
|
ServiceID int `json:"service_id"`
|
|
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
UserID int `json:"user_id"`
|
|
|
|
Note string `json:"note"`
|
|
|
|
UserName string `json:"user_name"`
|
|
|
|
CustomerID int `json:"customer_id"`
|
|
|
|
CustomerName string `json:"customer_name"`
|
|
|
|
ProjectName string `json:"project_name"`
|
|
|
|
ServiceName string `json:"service_name"`
|
|
|
|
}
|
|
|
|
|
|
|
|
func (t TimeEntry) String() string {
|
|
|
|
return fmt.Sprintf("%d: %s - %s for %dm", t.ID, t.DateAt, t.CustomerName, t.Minutes)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (te TimeEntries) ByDate(t time.Time) TimeEntries {
|
|
|
|
ymd := t.Format(time.DateOnly)
|
|
|
|
out := TimeEntries{}
|
|
|
|
for i := range te {
|
|
|
|
if te[i].DateAt == ymd {
|
|
|
|
out = append(out, te[i])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return out
|
|
|
|
}
|
|
|
|
|
|
|
|
func (te TimeEntries) MinutesByDate() map[string]int {
|
|
|
|
out := map[string]int{}
|
|
|
|
for i := range te {
|
|
|
|
dt := te[i].DateAt
|
|
|
|
if _, ok := out[dt]; ok {
|
|
|
|
out[dt] += te[i].Minutes
|
|
|
|
} else {
|
|
|
|
out[dt] = te[i].Minutes
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return out
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetTimeEntries the "to" time is inclusive - ie time entries on that date are included
|
|
|
|
func (a APIClient) GetTimeEntries(from, to time.Time) (TimeEntries, error) {
|
|
|
|
// GET /... YYYY-MM-DD from= to=
|
|
|
|
p := apiTimeEntry{}
|
|
|
|
u := fmt.Sprintf("/time_entries.json?from=%s&to=%s", from.Format(time.DateOnly), to.Format(time.DateOnly))
|
|
|
|
|
2025-06-20 16:12:48 +02:00
|
|
|
err := a.get(u, &p)
|
2025-06-17 16:39:51 +02:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
out := TimeEntries{}
|
|
|
|
for i := range p {
|
|
|
|
out = append(out, p[i].TimeEntry)
|
|
|
|
}
|
|
|
|
return out, nil
|
|
|
|
}
|
|
|
|
|
2025-06-18 11:06:12 +02:00
|
|
|
type requestAddTimeEntry struct {
|
|
|
|
RequestTimeEntryHolder struct {
|
|
|
|
DateAt string `json:"date_at"`
|
|
|
|
Minutes int `json:"minutes"`
|
2025-06-19 09:56:54 +02:00
|
|
|
ProjectID int `json:"project_id,omitempty"`
|
|
|
|
ServiceID int `json:"service_id,omitempty"`
|
2025-06-18 11:06:12 +02:00
|
|
|
Note string `json:"note"`
|
|
|
|
} `json:"time_entry"`
|
|
|
|
}
|
|
|
|
|
2025-06-20 17:08:15 +02:00
|
|
|
type apiPostTimeEntry timeEntryHolder
|
|
|
|
|
|
|
|
func (a APIClient) AddTimeEntry(date string, minutes int, notes string, projectId, serviceId int) (int, error) {
|
2025-06-18 11:06:12 +02:00
|
|
|
// POST /time_entries.json
|
|
|
|
// {
|
|
|
|
// "time_entry": {
|
|
|
|
// "date_at": "2015-9-15",
|
|
|
|
// "minutes": 185,
|
|
|
|
// "service_id": 243
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
req := requestAddTimeEntry{}
|
|
|
|
req.RequestTimeEntryHolder.DateAt = date
|
|
|
|
req.RequestTimeEntryHolder.Minutes = minutes
|
|
|
|
req.RequestTimeEntryHolder.Note = notes
|
|
|
|
req.RequestTimeEntryHolder.ProjectID = projectId
|
|
|
|
req.RequestTimeEntryHolder.ServiceID = serviceId
|
|
|
|
|
2025-06-20 17:08:15 +02:00
|
|
|
res := apiPostTimeEntry{}
|
|
|
|
|
|
|
|
err := a.post("/time_entries.json", req, &res)
|
|
|
|
return res.TimeEntry.ID, err
|
2025-06-20 16:12:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
type apiTimeTrackerEntry struct {
|
|
|
|
TimeTrackerHolder timeTrackerHolder `json:"tracker"`
|
|
|
|
}
|
2025-06-18 11:06:12 +02:00
|
|
|
|
2025-06-20 16:12:48 +02:00
|
|
|
type timeTrackerHolder struct {
|
|
|
|
TrackingTimeEntry *TrackingTimeEntry `json:"tracking_time_entry"`
|
2025-06-20 16:40:28 +02:00
|
|
|
StoppedTimeEntry *StoppedTimeEntry `json:"stopped_time_entry"`
|
2025-06-18 11:06:12 +02:00
|
|
|
}
|
|
|
|
|
2025-06-20 16:12:48 +02:00
|
|
|
type TrackingTimeEntry struct {
|
|
|
|
ID int `json:"id"`
|
|
|
|
Minutes int `json:"minutes"`
|
|
|
|
Since time.Time `json:"since"`
|
|
|
|
}
|
|
|
|
|
2025-06-20 16:40:28 +02:00
|
|
|
type StoppedTimeEntry struct {
|
|
|
|
ID int `json:"id"`
|
|
|
|
Minutes int `json:"minutes"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// {
|
|
|
|
// "tracker": {
|
|
|
|
// "tracking_time_entry": {
|
|
|
|
// "id": 36135322,
|
|
|
|
// "minutes": 0,
|
|
|
|
// "since": "2015-10-15T17:33:52+02:00"
|
|
|
|
// },
|
|
|
|
// "stopped_time_entry": {
|
|
|
|
// "id": 36134329,
|
|
|
|
// "minutes": 46
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
|
2025-06-20 16:12:48 +02:00
|
|
|
// {
|
|
|
|
// "tracker": {
|
|
|
|
// "tracking_time_entry": {
|
|
|
|
// "id": 36135321,
|
|
|
|
// "minutes": 247,
|
|
|
|
// "since": "2015-10-15T17:05:04+02:00"
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
|
|
|
|
var ErrNoTracker = errors.New("no time tracker running")
|
|
|
|
|
|
|
|
// GetTimeTracker gets the current running time tracker. If no tracker is
|
|
|
|
// running, the error returned will be ErrNoTracker
|
|
|
|
func (a APIClient) GetTimeTracker() (TrackingTimeEntry, error) {
|
|
|
|
r := apiTimeTrackerEntry{}
|
|
|
|
err := a.get("/tracker.json", &r)
|
|
|
|
if err != nil {
|
|
|
|
return TrackingTimeEntry{}, err
|
|
|
|
}
|
|
|
|
if r.TimeTrackerHolder.TrackingTimeEntry == nil {
|
|
|
|
return TrackingTimeEntry{}, ErrNoTracker
|
|
|
|
}
|
|
|
|
return *r.TimeTrackerHolder.TrackingTimeEntry, nil
|
|
|
|
}
|
|
|
|
|
2025-06-20 16:40:28 +02:00
|
|
|
func (a APIClient) StartTimeTracker(id int) (TrackingTimeEntry, *StoppedTimeEntry, error) {
|
|
|
|
url := fmt.Sprintf("/tracker/%d.json", id)
|
|
|
|
r := apiTimeTrackerEntry{}
|
|
|
|
|
|
|
|
err := a.patch(url, &r)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return TrackingTimeEntry{}, nil, err
|
|
|
|
}
|
|
|
|
if r.TimeTrackerHolder.TrackingTimeEntry == nil {
|
|
|
|
// I don't think this should happen, the patch should have been a 404?
|
|
|
|
|
|
|
|
panic(fmt.Sprintf("unexpected failure to find a tracking entry in a successful PATCH\n%#v", r.TimeTrackerHolder))
|
|
|
|
}
|
|
|
|
return *r.TimeTrackerHolder.TrackingTimeEntry, r.TimeTrackerHolder.StoppedTimeEntry, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (a APIClient) StopTimeTracker(id int) (StoppedTimeEntry, error) {
|
|
|
|
url := fmt.Sprintf("/tracker/%d.json", id)
|
|
|
|
r := apiTimeTrackerEntry{}
|
|
|
|
|
|
|
|
err := a.delete(url, &r)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return StoppedTimeEntry{}, err
|
|
|
|
}
|
|
|
|
if r.TimeTrackerHolder.StoppedTimeEntry == nil {
|
|
|
|
// I don't think this should happen, the patch should have been a 404?
|
|
|
|
|
|
|
|
panic(fmt.Sprintf("unexpected failure to find a tracking entry in a successful DELETE\n%#v", r.TimeTrackerHolder))
|
|
|
|
}
|
|
|
|
return *r.TimeTrackerHolder.StoppedTimeEntry, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (a APIClient) delete(path string, data any) error {
|
|
|
|
|
|
|
|
req, err := http.NewRequest("DELETE", baseurl(a.domain, path), nil)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
req.Header.Add("X-MiteApiKey", a.apiKey)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
|
|
return fmt.Errorf("expected 2XX, got %d", resp.StatusCode)
|
|
|
|
}
|
|
|
|
err = json.NewDecoder(resp.Body).Decode(&data)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (a APIClient) patch(path string, data any) error {
|
|
|
|
|
|
|
|
req, err := http.NewRequest("PATCH", baseurl(a.domain, path), nil)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
req.Header.Add("X-MiteApiKey", a.apiKey)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
|
|
return fmt.Errorf("expected 2XX, got %d", resp.StatusCode)
|
|
|
|
}
|
|
|
|
err = json.NewDecoder(resp.Body).Decode(&data)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2025-06-20 17:08:15 +02:00
|
|
|
func (a APIClient) post(path string, dataIn any, dataOut any) error {
|
2025-06-18 11:06:12 +02:00
|
|
|
|
2025-06-20 17:08:15 +02:00
|
|
|
b, err := json.Marshal(dataIn)
|
2025-06-18 11:06:12 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2025-06-20 16:12:48 +02:00
|
|
|
req, err := http.NewRequest("POST", baseurl(a.domain, path), bytes.NewBuffer(b))
|
2025-06-18 11:06:12 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2025-06-20 16:12:48 +02:00
|
|
|
req.Header.Add("X-MiteApiKey", a.apiKey)
|
2025-06-18 11:06:12 +02:00
|
|
|
req.Header.Add("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
|
|
return fmt.Errorf("expected 2XX, got %d", resp.StatusCode)
|
|
|
|
}
|
|
|
|
|
2025-06-20 17:08:15 +02:00
|
|
|
err = json.NewDecoder(resp.Body).Decode(&dataOut)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2025-06-18 11:06:12 +02:00
|
|
|
return nil
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2025-06-20 16:12:48 +02:00
|
|
|
func (a APIClient) get(path string, data any) error {
|
|
|
|
req, err := http.NewRequest("GET", baseurl(a.domain, path), nil)
|
2025-06-17 16:39:51 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2025-06-20 16:12:48 +02:00
|
|
|
req.Header.Add("X-MiteApiKey", a.apiKey)
|
2025-06-17 16:39:51 +02:00
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
|
|
return fmt.Errorf("expected 2XX, got %d", resp.StatusCode)
|
|
|
|
}
|
|
|
|
err = json.NewDecoder(resp.Body).Decode(&data)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func baseurl(domain, path string) string {
|
|
|
|
return fmt.Sprintf("https://%s.mite.de/%s", domain, path)
|
|
|
|
}
|