package mite import ( "bytes" "encoding/json" "errors" "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) } func (p Project) GetID() int { return p.ID } func (p Project) GetName() string { return p.Name + " (" + p.CustomerName + ")" } func (a APIClient) GetProjects() (Projects, error) { // GET /projects.json p := APIProjects{} err := a.get("/projects.json", &p) 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{} err := a.get("/customers.json", &p) 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) } func (s Service) GetID() int { return s.ID } func (s Service) GetName() string { prefix := "💰" if !s.Billable { prefix = "☮️" } return prefix + " " + s.Name } func (a APIClient) GetServices() (Services, error) { // GET /services.json p := apiServices{} err := a.get("/services.json", &p) 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)) err := a.get(u, &p) if err != nil { return nil, err } out := TimeEntries{} for i := range p { out = append(out, p[i].TimeEntry) } return out, nil } type requestAddTimeEntry struct { RequestTimeEntryHolder struct { DateAt string `json:"date_at"` Minutes int `json:"minutes"` ProjectID int `json:"project_id,omitempty"` ServiceID int `json:"service_id,omitempty"` Note string `json:"note"` } `json:"time_entry"` } type apiPostTimeEntry timeEntryHolder func (a APIClient) AddTimeEntry(date string, minutes int, notes string, projectId, serviceId int) (int, error) { // 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 res := apiPostTimeEntry{} err := a.post("/time_entries.json", req, &res) return res.TimeEntry.ID, err } type apiTimeTrackerEntry struct { TimeTrackerHolder timeTrackerHolder `json:"tracker"` } type timeTrackerHolder struct { TrackingTimeEntry *TrackingTimeEntry `json:"tracking_time_entry"` StoppedTimeEntry *StoppedTimeEntry `json:"stopped_time_entry"` } type TrackingTimeEntry struct { ID int `json:"id"` Minutes int `json:"minutes"` Since time.Time `json:"since"` } 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 // } // } // } // { // "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 } 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 } func (a APIClient) post(path string, dataIn any, dataOut any) error { b, err := json.Marshal(dataIn) if err != nil { return err } req, err := http.NewRequest("POST", baseurl(a.domain, path), bytes.NewBuffer(b)) if err != nil { return err } req.Header.Add("X-MiteApiKey", a.apiKey) 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) } err = json.NewDecoder(resp.Body).Decode(&dataOut) if err != nil { return err } return nil } func (a APIClient) get(path string, data any) error { req, err := http.NewRequest("GET", 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 baseurl(domain, path string) string { return fmt.Sprintf("https://%s.mite.de/%s", domain, path) }