diff --git a/README.md b/README.md index 087c499..4482d42 100644 --- a/README.md +++ b/README.md @@ -166,10 +166,10 @@ LLM agents: run `crono-export prime` for a one-screen orientation describing bot [Cronometer](https://cronometer.com) is a nutrition tracking app with one of the best micronutrient databases of any consumer tool — a major reason it's commonly recommended for bariatric patients, anyone tracking specific vitamin/mineral targets, or athletes managing recovery nutrition. -This CLI is an unofficial tool for exporting your own data. It uses the same web export endpoints the Cronometer SPA uses, via [`jrmycanady/gocronometer`](https://github.com/jrmycanady/gocronometer). It is intended for personal single-user use only — see the upstream library's notes on appropriate use. +This CLI is an unofficial tool for exporting your own data. It speaks directly to the same web export endpoints the Cronometer SPA uses, via an MIT-licensed in-tree HTTP client (`internal/cronoapi`). It is intended for personal single-user use only. ## License MIT — see [LICENSE](LICENSE). -The underlying [`gocronometer`](https://github.com/jrmycanady/gocronometer) library is GPLv2-licensed. +The CLI is MIT-clean: it has no transitive GPL dependencies. See [LICENSING.md](LICENSING.md) for the history. diff --git a/cmd/format.go b/cmd/format.go index 7f477ce..4e8f04f 100644 --- a/cmd/format.go +++ b/cmd/format.go @@ -9,7 +9,7 @@ import ( "sort" "strings" - "github.com/jrmycanady/gocronometer" + "github.com/quantcli/crono-export-cli/internal/cronoapi" "github.com/spf13/cobra" ) @@ -61,13 +61,13 @@ func emit(cmd *cobra.Command, kind recordKind, v any) error { func renderMarkdown(w io.Writer, kind recordKind, v any) error { switch kind { case kindServings: - recs, _ := v.(gocronometer.ServingRecords) + recs, _ := v.(cronoapi.ServingRecords) return renderServings(w, recs) case kindBiometrics: - recs, _ := v.(gocronometer.BiometricRecords) + recs, _ := v.(cronoapi.BiometricRecords) return renderBiometrics(w, recs) case kindExercises: - recs, _ := v.(gocronometer.ExerciseRecords) + recs, _ := v.(cronoapi.ExerciseRecords) return renderExercises(w, recs) case kindNutrition: rows, _ := v.([]map[string]string) @@ -115,12 +115,12 @@ func strippedSuffix(field string) (name, unit string) { // ---- servings --------------------------------------------------------- -func renderServings(w io.Writer, recs gocronometer.ServingRecords) error { +func renderServings(w io.Writer, recs cronoapi.ServingRecords) error { if len(recs) == 0 { return emptyMsg(w) } // Group by local calendar date. - byDate := map[string][]gocronometer.ServingRecord{} + byDate := map[string][]cronoapi.ServingRecord{} for _, r := range recs { d := r.RecordedTime.Format("2006-01-02") byDate[d] = append(byDate[d], r) @@ -144,7 +144,7 @@ func renderServings(w io.Writer, recs gocronometer.ServingRecords) error { return nil } -func renderServingRecord(w io.Writer, r gocronometer.ServingRecord) { +func renderServingRecord(w io.Writer, r cronoapi.ServingRecord) { header := fmt.Sprintf("### %s · %s", strDefault(r.Group, "—"), r.FoodName) if r.QuantityValue != 0 || r.QuantityUnits != "" { header += fmt.Sprintf(" (%s %s)", fmtFloat(r.QuantityValue), r.QuantityUnits) @@ -192,11 +192,11 @@ func strDefault(s, fallback string) string { // ---- biometrics ------------------------------------------------------- -func renderBiometrics(w io.Writer, recs gocronometer.BiometricRecords) error { +func renderBiometrics(w io.Writer, recs cronoapi.BiometricRecords) error { if len(recs) == 0 { return emptyMsg(w) } - byDate := map[string][]gocronometer.BiometricRecord{} + byDate := map[string][]cronoapi.BiometricRecord{} for _, r := range recs { d := r.RecordedTime.Format("2006-01-02") byDate[d] = append(byDate[d], r) @@ -224,11 +224,11 @@ func renderBiometrics(w io.Writer, recs gocronometer.BiometricRecords) error { // ---- exercises -------------------------------------------------------- -func renderExercises(w io.Writer, recs gocronometer.ExerciseRecords) error { +func renderExercises(w io.Writer, recs cronoapi.ExerciseRecords) error { if len(recs) == 0 { return emptyMsg(w) } - byDate := map[string][]gocronometer.ExerciseRecord{} + byDate := map[string][]cronoapi.ExerciseRecord{} for _, r := range recs { d := r.RecordedTime.Format("2006-01-02") byDate[d] = append(byDate[d], r) diff --git a/go.mod b/go.mod index 2e5cc8f..a2fd26b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/quantcli/crono-export-cli go 1.25.10 require ( - github.com/jrmycanady/gocronometer v1.5.1 github.com/quantcli/common/compat v0.0.0-20260510225630-4c588c19cd1b github.com/spf13/cobra v1.10.2 ) @@ -11,5 +10,4 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/net v0.46.0 // indirect ) diff --git a/go.sum b/go.sum index fd0f48f..3753e29 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jrmycanady/gocronometer v1.5.1 h1:m2J31jEuLlL4RRdQLY33IFs4TAwmfevvJYl2SZxBSQ0= -github.com/jrmycanady/gocronometer v1.5.1/go.mod h1:swnvYB6twU20LDzNpAz8JOX5mCHktTW06zlSXmmyZWc= github.com/quantcli/common/compat v0.0.0-20260510225630-4c588c19cd1b h1:fO7EfkEqzLRC8Ev22jIq05fPs+JwAB7bCDy6FA+GA5k= github.com/quantcli/common/compat v0.0.0-20260510225630-4c588c19cd1b/go.mod h1:VBC/zEphSZgCZS1rhWsR3A8EWYSbTkP/MwqWHL7266s= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -11,6 +9,4 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cronoapi/client.go b/internal/cronoapi/client.go new file mode 100644 index 0000000..43dc4c2 --- /dev/null +++ b/internal/cronoapi/client.go @@ -0,0 +1,204 @@ +package cronoapi + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strings" + "time" +) + +// DefaultBaseURL is the production Cronometer host. +const DefaultBaseURL = "https://cronometer.com" + +const ( + defaultTimeout = 60 * time.Second + userAgentHdr = "crono-export-cli (clean-room; https://github.com/quantcli/crono-export-cli)" +) + +// Client is an authenticated Cronometer session. Construct it with +// NewClient, then call Login. After Login the client holds the GWT +// session auth token and user ID and can call any of the Export* +// methods. Logout tears down the session, best-effort. +// +// Client is not safe for concurrent use by multiple goroutines. +type Client struct { + HTTPClient *http.Client + + baseURL string + permutation string + userAgent string + + userID int + authToken string +} + +// NewClient returns a fresh Client. If httpClient is nil a default +// client with a 60-second timeout and a cookie jar is constructed. +// If httpClient is non-nil but has no Jar set, a cookie jar is +// installed so session cookies round-trip across requests. +func NewClient(httpClient *http.Client) *Client { + if httpClient == nil { + jar, _ := cookiejar.New(nil) + httpClient = &http.Client{ + Jar: jar, + Timeout: defaultTimeout, + } + } else if httpClient.Jar == nil { + jar, _ := cookiejar.New(nil) + httpClient.Jar = jar + } + return &Client{ + HTTPClient: httpClient, + baseURL: DefaultBaseURL, + permutation: DefaultGWTPermutation, + userAgent: userAgentHdr, + } +} + +// SetBaseURL overrides the Cronometer host. Intended for tests. +func (c *Client) SetBaseURL(u string) { c.baseURL = strings.TrimRight(u, "/") } + +// SetPermutation overrides the GWT permutation hash sent on +// /cronometer/app calls. Useful when Cronometer rotates their deploy +// hash and we need to point at the new value without a code release. +func (c *Client) SetPermutation(p string) { c.permutation = p } + +// AuthToken returns the GWT session auth token from the most recent +// successful Login. Empty before Login or after Logout. Exposed for +// debugging; not normally needed by callers. +func (c *Client) AuthToken() string { return c.authToken } + +// UserID returns the Cronometer user ID from the most recent +// successful Login. Zero before Login or after Logout. +func (c *Client) UserID() int { return c.userID } + +// anticsrfRe extracts the hidden anti-CSRF token from the login HTML. +// WIRE_SHAPES.md §(1). +var anticsrfRe = regexp.MustCompile(`name="anticsrf"\s+value="([^"]+)"`) + +// fetchAntiCSRF performs the anonymous GET /login/ that bootstraps the +// session cookie jar and returns the anti-CSRF form token embedded in +// the response HTML. +func (c *Client) fetchAntiCSRF(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/login/", nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", c.userAgent) + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("GET /login/: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GET /login/: HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read /login/ body: %w", err) + } + m := anticsrfRe.FindSubmatch(body) + if m == nil { + return "", fmt.Errorf("anti-CSRF token not found in /login/ response") + } + return string(m[1]), nil +} + +// submitLogin posts the credential form to /login. Cronometer responds +// with a small JSON body and additional Set-Cookie entries on success. +// The cookie jar on c.HTTPClient picks those up automatically. We do +// not parse the JSON success body — its only documented failure mode +// is `{"error":"AntiCSRF Token Invalid"}`, which we surface explicitly. +func (c *Client) submitLogin(ctx context.Context, username, password, csrfToken string) error { + form := url.Values{} + form.Set("anticsrf", csrfToken) + form.Set("password", password) + form.Set("username", username) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/login", strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", c.userAgent) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("POST /login: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read /login body: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("POST /login: HTTP %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + bodyStr := string(body) + if strings.Contains(bodyStr, `"error"`) { + return fmt.Errorf("login failed: %s", truncate(bodyStr, 200)) + } + return nil +} + +// Login performs the three-step Cronometer authentication handshake: +// +// 1. GET /login/ — bootstrap cookies + read anti-CSRF token. +// 2. POST /login — credential submission. +// 3. POST /cronometer/app (GWT-RPC authenticate) — fetch userID + +// session auth token used to mint export nonces. +// +// Steps map 1:1 to WIRE_SHAPES.md §(1), §(2), and §(3). +func (c *Client) Login(ctx context.Context, username, password string) error { + if username == "" || password == "" { + return fmt.Errorf("login: username and password required") + } + csrf, err := c.fetchAntiCSRF(ctx) + if err != nil { + return err + } + if err := c.submitLogin(ctx, username, password, csrf); err != nil { + return err + } + + // Step 3: GWT-RPC authenticate. UTC offset in minutes for the host + // timezone — captured payload sent -300 (NYC DST). We send the + // current local zone's offset so the response reflects the user's + // local calendar. + _, offsetSec := time.Now().Zone() + body, err := c.gwtCall(ctx, authenticateBody(c.permutation, offsetSec/60)) + if err != nil { + return fmt.Errorf("authenticate: %w", err) + } + uid, tok, err := parseAuthenticateResponse(body) + if err != nil { + return err + } + c.userID = uid + c.authToken = tok + return nil +} + +// Logout calls the GWT-RPC logout method. WIRE_SHAPES.md §(14) notes +// this is best-effort: crono-export-cli already calls it via defer and +// ignores the returned error. +func (c *Client) Logout(ctx context.Context) error { + if c.authToken == "" { + return nil + } + body, err := c.gwtCall(ctx, logoutBody(c.permutation, c.authToken)) + c.authToken = "" + c.userID = 0 + if err != nil { + return err + } + if !strings.HasPrefix(body, "//OK") { + return fmt.Errorf("logout: unexpected response: %s", truncate(body, 80)) + } + return nil +} diff --git a/internal/cronoapi/client_test.go b/internal/cronoapi/client_test.go new file mode 100644 index 0000000..94a6aa0 --- /dev/null +++ b/internal/cronoapi/client_test.go @@ -0,0 +1,503 @@ +package cronoapi + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// fakeCronometer is a hand-rolled httptest stand-in for cronometer.com. +// It implements the 14-exchange storyboard from WIRE_SHAPES.md so the +// clean-room client can be exercised end-to-end without leaving the +// process. All response bodies here are synthesised — no real account +// data and no captured CSV bodies (those were redacted in Phase 3a for +// privacy posture). +type fakeCronometer struct { + csrfToken string + authToken string + userID int + permutation string + postedLoginRaw string + postedLogout bool + + // Synthesised CSV bodies the test can override per-endpoint. + servingsCSV string + exercisesCSV string + biometricsCSV string + dailySummaryCSV string + notesCSV string + + t *testing.T + server *httptest.Server +} + +func newFakeCronometer(t *testing.T) *fakeCronometer { + t.Helper() + f := &fakeCronometer{ + csrfToken: "abcdef0123456789abcdef0123456789", + authToken: "11112222333344445555666677778888", + userID: 7654321, + permutation: "TESTPERMUTATIONHASH00000000000000", + t: t, + } + mux := http.NewServeMux() + mux.HandleFunc("/login/", f.handleLoginGet) + mux.HandleFunc("/login", f.handleLoginPost) + mux.HandleFunc("/cronometer/app", f.handleGWT) + mux.HandleFunc("/export", f.handleExport) + f.server = httptest.NewServer(mux) + t.Cleanup(f.server.Close) + return f +} + +func (f *fakeCronometer) handleLoginGet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + // Seed a session cookie so the cookie jar round-trips on subsequent + // requests, mirroring WIRE_SHAPES.md §(1) "4 Set-Cookie entries". + http.SetCookie(w, &http.Cookie{Name: "session", Value: "s1", Path: "/"}) + w.Header().Set("Content-Type", "text/html;charset=UTF-8") + fmt.Fprintf(w, `
`, f.csrfToken) +} + +func (f *fakeCronometer) handleLoginPost(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if c, _ := r.Cookie("session"); c == nil { + http.Error(w, "missing session cookie", http.StatusUnauthorized) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + f.postedLoginRaw = r.PostForm.Encode() + if r.PostFormValue("anticsrf") != f.csrfToken { + w.Header().Set("Content-Type", "application/json;charset=UTF-8") + _, _ = w.Write([]byte(`{"error":"AntiCSRF Token Invalid"}`)) + return + } + // Additional Set-Cookie marking the authenticated session. + http.SetCookie(w, &http.Cookie{Name: "auth", Value: "ok", Path: "/"}) + w.Header().Set("Content-Type", "application/json;charset=UTF-8") + _, _ = w.Write([]byte(`{"ok":true,"user":"x"}`)) +} + +func (f *fakeCronometer) handleGWT(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if got := r.Header.Get("Content-Type"); got != gwtContentType { + f.t.Errorf("/cronometer/app: Content-Type = %q, want %q", got, gwtContentType) + } + if got := r.Header.Get("X-Gwt-Module-Base"); got != gwtModuleBase { + f.t.Errorf("/cronometer/app: X-Gwt-Module-Base = %q, want %q", got, gwtModuleBase) + } + if got := r.Header.Get("X-Gwt-Permutation"); got != f.permutation { + f.t.Errorf("/cronometer/app: X-Gwt-Permutation = %q, want %q", got, f.permutation) + } + body, _ := io.ReadAll(r.Body) + bodyStr := string(body) + switch { + case strings.Contains(bodyStr, "|authenticate|"): + // Synthesise an authenticate response: leading userID then a + // scatter of quoted hex strings ending in the session token. + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, + `//OK[%d,1,2,3,"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","%s","unused"]`, + f.userID, f.authToken, + ) + case strings.Contains(bodyStr, "|generateAuthorizationToken|"): + if !strings.Contains(bodyStr, f.authToken) { + f.t.Errorf("generateAuthorizationToken: request body missing session auth token") + } + // One-shot nonce per WIRE_SHAPES.md §(4) response shape. + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`//OK[1,["cccccccccccccccccccccccccccccccc"],0,7]`)) + case strings.Contains(bodyStr, "|logout|"): + f.postedLogout = true + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`//OK[1,0,7]`)) + default: + f.t.Errorf("/cronometer/app: unknown method in body: %q", truncate(bodyStr, 120)) + http.Error(w, "unknown GWT method", http.StatusBadRequest) + } +} + +func (f *fakeCronometer) handleExport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + q := r.URL.Query() + for _, want := range []string{"start", "end", "generate", "nonce"} { + if q.Get(want) == "" { + f.t.Errorf("/export: missing query param %q (url=%q)", want, r.URL.RawQuery) + } + } + w.Header().Set("Content-Type", "text/csv") + switch q.Get("generate") { + case exportGenServings: + _, _ = io.WriteString(w, f.servingsCSV) + case exportGenExercises: + _, _ = io.WriteString(w, f.exercisesCSV) + case exportGenBiometrics: + _, _ = io.WriteString(w, f.biometricsCSV) + case exportGenDailySummary: + _, _ = io.WriteString(w, f.dailySummaryCSV) + case exportGenNotes: + _, _ = io.WriteString(w, f.notesCSV) + default: + http.Error(w, "unknown generate type", http.StatusBadRequest) + } +} + +// ----- tests -------------------------------------------------------- + +func newTestClient(t *testing.T, f *fakeCronometer) *Client { + t.Helper() + c := NewClient(nil) + c.SetBaseURL(f.server.URL) + c.SetPermutation(f.permutation) + return c +} + +func TestLoginAndLogout(t *testing.T) { + f := newFakeCronometer(t) + c := newTestClient(t, f) + + if err := c.Login(context.Background(), "alice", "p@ssw0rd"); err != nil { + t.Fatalf("Login: %v", err) + } + if c.UserID() != f.userID { + t.Errorf("UserID = %d, want %d", c.UserID(), f.userID) + } + if c.AuthToken() != f.authToken { + t.Errorf("AuthToken = %q, want %q", c.AuthToken(), f.authToken) + } + + // /login body must contain the round-tripped CSRF token and + // url-encoded credentials (WIRE_SHAPES.md §(2)). + posted, err := url.ParseQuery(f.postedLoginRaw) + if err != nil { + t.Fatalf("parse posted login form: %v", err) + } + if got := posted.Get("anticsrf"); got != f.csrfToken { + t.Errorf("posted anticsrf = %q, want %q", got, f.csrfToken) + } + if got := posted.Get("username"); got != "alice" { + t.Errorf("posted username = %q, want %q", got, "alice") + } + if got := posted.Get("password"); got != "p@ssw0rd" { + t.Errorf("posted password = %q, want %q", got, "p@ssw0rd") + } + + if err := c.Logout(context.Background()); err != nil { + t.Fatalf("Logout: %v", err) + } + if !f.postedLogout { + t.Error("Logout did not call the GWT logout RPC") + } + if c.AuthToken() != "" || c.UserID() != 0 { + t.Errorf("after Logout: AuthToken=%q UserID=%d, want empty/zero", c.AuthToken(), c.UserID()) + } + // Logout with no auth token must be a no-op. + if err := c.Logout(context.Background()); err != nil { + t.Errorf("Logout (no-op): %v", err) + } +} + +func TestLoginRejectsBadCredentials(t *testing.T) { + f := newFakeCronometer(t) + c := newTestClient(t, f) + // Force the CSRF mismatch path on the fake. + f.csrfToken = "expected" + c2 := newTestClient(t, f) + // Hand-roll a login that posts the wrong CSRF — easiest is to + // reset the in-memory expected value after the GET fires; here we + // just call submitLogin directly with a wrong token to cover the + // `"error"` branch. + _ = c // unused for this branch + if err := c2.submitLogin(context.Background(), "alice", "x", "wrong-csrf"); err == nil { + t.Fatal("submitLogin with wrong CSRF: expected error, got nil") + } +} + +func TestExportServingsParsedWithLocation(t *testing.T) { + f := newFakeCronometer(t) + // Synthesised CSV covering the documented field set + a couple of + // nutrient columns. Real Cronometer CSV column headers were not + // preserved in fixtures (privacy posture); this is a hand-written + // sample shaped per docs/cronometer-protocol.md "Record types we + // read". + f.servingsCSV = strings.Join([]string{ + `Day,Group,Food Name,Amount,Category,Energy (kcal),Protein (g),Vitamin B12 (µg),Vitamin D (IU)`, + `2026-05-04,Breakfast,"Oats, rolled",50.00 g,Cereal,194,6.5,0,0`, + `2026-05-04,Breakfast,"Milk, whole",250 ml,Dairy,150,8,1.2,120`, + }, "\n") + + c := newTestClient(t, f) + if err := c.Login(context.Background(), "alice", "x"); err != nil { + t.Fatal(err) + } + defer c.Logout(context.Background()) + + start := time.Date(2026, 5, 4, 0, 0, 0, 0, time.Local) + end := time.Date(2026, 5, 11, 0, 0, 0, 0, time.Local) + recs, err := c.ExportServingsParsedWithLocation(context.Background(), start, end, time.Local) + if err != nil { + t.Fatalf("ExportServings: %v", err) + } + if len(recs) != 2 { + t.Fatalf("got %d records, want 2", len(recs)) + } + + want := recs[0] + if want.FoodName != "Oats, rolled" { + t.Errorf("recs[0].FoodName = %q, want %q", want.FoodName, "Oats, rolled") + } + if want.Group != "Breakfast" { + t.Errorf("recs[0].Group = %q, want %q", want.Group, "Breakfast") + } + if want.QuantityValue != 50.0 || want.QuantityUnits != "g" { + t.Errorf("recs[0] quantity = (%v, %q), want (50, \"g\")", want.QuantityValue, want.QuantityUnits) + } + if want.Category != "Cereal" { + t.Errorf("recs[0].Category = %q, want %q", want.Category, "Cereal") + } + if want.EnergyKcal != 194 || want.ProteinG != 6.5 { + t.Errorf("recs[0] nutrients: EnergyKcal=%v ProteinG=%v", want.EnergyKcal, want.ProteinG) + } + if recs[1].B12Ug != 1.2 { + t.Errorf("recs[1].B12Ug = %v, want 1.2", recs[1].B12Ug) + } + if recs[1].VitaminDIU != 120 { + t.Errorf("recs[1].VitaminDIU = %v, want 120", recs[1].VitaminDIU) + } + if got := recs[0].RecordedTime.Format("2006-01-02"); got != "2026-05-04" { + t.Errorf("recs[0].RecordedTime date = %q, want %q", got, "2026-05-04") + } + if recs[0].RecordedTime.Location() != time.Local { + t.Errorf("recs[0].RecordedTime zone = %v, want time.Local", recs[0].RecordedTime.Location()) + } +} + +func TestExportExercisesParsedWithLocation(t *testing.T) { + f := newFakeCronometer(t) + f.exercisesCSV = strings.Join([]string{ + `Day,Exercise,Minutes,Calories Burned,Category`, + `2026-05-05,Running,30,310,Cardio`, + `2026-05-06,Yoga,45,150,Flexibility`, + }, "\n") + + c := newTestClient(t, f) + if err := c.Login(context.Background(), "u", "p"); err != nil { + t.Fatal(err) + } + defer c.Logout(context.Background()) + + recs, err := c.ExportExercisesParsedWithLocation(context.Background(), + time.Date(2026, 5, 4, 0, 0, 0, 0, time.Local), + time.Date(2026, 5, 11, 0, 0, 0, 0, time.Local), + time.Local, + ) + if err != nil { + t.Fatal(err) + } + if len(recs) != 2 { + t.Fatalf("got %d records, want 2", len(recs)) + } + if recs[0].Exercise != "Running" || recs[0].Minutes != 30 || recs[0].CaloriesBurned != 310 || recs[0].Group != "Cardio" { + t.Errorf("recs[0] = %+v", recs[0]) + } +} + +func TestExportBiometricsParsedWithLocation(t *testing.T) { + f := newFakeCronometer(t) + f.biometricsCSV = strings.Join([]string{ + `Day,Metric,Amount,Unit`, + `2026-05-04,Weight,180.5,lbs`, + `2026-05-04,Body Fat,18.2,%`, + }, "\n") + + c := newTestClient(t, f) + if err := c.Login(context.Background(), "u", "p"); err != nil { + t.Fatal(err) + } + defer c.Logout(context.Background()) + + recs, err := c.ExportBiometricRecordsParsedWithLocation(context.Background(), + time.Date(2026, 5, 4, 0, 0, 0, 0, time.Local), + time.Date(2026, 5, 11, 0, 0, 0, 0, time.Local), + time.Local, + ) + if err != nil { + t.Fatal(err) + } + if len(recs) != 2 { + t.Fatalf("got %d records, want 2", len(recs)) + } + if recs[0].Metric != "Weight" || recs[0].Amount != 180.5 || recs[0].Unit != "lbs" { + t.Errorf("recs[0] = %+v", recs[0]) + } + if recs[1].Metric != "Body Fat" || recs[1].Unit != "%" { + t.Errorf("recs[1] = %+v", recs[1]) + } +} + +func TestExportDailyNutritionAndNotesAreRawCSV(t *testing.T) { + f := newFakeCronometer(t) + f.dailySummaryCSV = "Date,Calories,Protein\n2026-05-04,1800,90\n" + f.notesCSV = "Day,Note\n2026-05-04,Slept poorly\n" + + c := newTestClient(t, f) + if err := c.Login(context.Background(), "u", "p"); err != nil { + t.Fatal(err) + } + defer c.Logout(context.Background()) + + gotNut, err := c.ExportDailyNutrition(context.Background(), + time.Date(2026, 5, 4, 0, 0, 0, 0, time.Local), + time.Date(2026, 5, 11, 0, 0, 0, 0, time.Local), + ) + if err != nil { + t.Fatal(err) + } + if gotNut != f.dailySummaryCSV { + t.Errorf("ExportDailyNutrition body mismatch:\n got %q\nwant %q", gotNut, f.dailySummaryCSV) + } + + gotNotes, err := c.ExportNotes(context.Background(), + time.Date(2026, 5, 4, 0, 0, 0, 0, time.Local), + time.Date(2026, 5, 11, 0, 0, 0, 0, time.Local), + ) + if err != nil { + t.Fatal(err) + } + if gotNotes != f.notesCSV { + t.Errorf("ExportNotes body mismatch:\n got %q\nwant %q", gotNotes, f.notesCSV) + } +} + +func TestExportRequiresLogin(t *testing.T) { + f := newFakeCronometer(t) + c := newTestClient(t, f) + _, err := c.ExportServingsParsedWithLocation(context.Background(), + time.Now(), time.Now(), time.Local) + if err == nil { + t.Fatal("expected error when calling export without Login, got nil") + } + if !strings.Contains(err.Error(), "not logged in") { + t.Errorf("error = %v, want %q", err, "not logged in") + } +} + +func TestHeaderToNutrientField(t *testing.T) { + cases := []struct { + header, want string + }{ + {"Energy (kcal)", "EnergyKcal"}, + {"Protein (g)", "ProteinG"}, + {"Vitamin B12 (µg)", "B12Ug"}, + {"Vitamin B12 (ug)", "B12Ug"}, + {"Vitamin D (IU)", "VitaminDIU"}, + {"Net Carbs (g)", "NetCarbsG"}, + {"Trans-Fats (g)", "TransFatsG"}, + {"Cholesterol (mg)", "CholesterolMg"}, + {"NoUnitColumn", ""}, + {"Food Name", ""}, + {"Energy (joules)", ""}, // unrecognised unit + } + for _, tc := range cases { + got := headerToNutrientField(tc.header) + if got != tc.want { + t.Errorf("headerToNutrientField(%q) = %q, want %q", tc.header, got, tc.want) + } + } +} + +func TestParseAuthenticateResponse(t *testing.T) { + body := `//OK[1234567,1,2,3,"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","SESSION_TOKEN_HERE_NOT_HEX","deadbeefdeadbeefdeadbeefdeadbeef","tail"]` + uid, tok, err := parseAuthenticateResponse(body) + if err != nil { + t.Fatal(err) + } + if uid != 1234567 { + t.Errorf("uid = %d, want 1234567", uid) + } + if tok != "deadbeefdeadbeefdeadbeefdeadbeef" { + t.Errorf("token = %q, want last 32-hex string", tok) + } +} + +func TestParseAuthorizationTokenResponse(t *testing.T) { + body := `//OK[1,["cafebabecafebabecafebabecafebabe"],0,7]` + nonce, err := parseAuthorizationTokenResponse(body) + if err != nil { + t.Fatal(err) + } + if nonce != "cafebabecafebabecafebabecafebabe" { + t.Errorf("nonce = %q", nonce) + } +} + +func TestServingsCSVWithBOM(t *testing.T) { + bom := "\xef\xbb\xbf" + raw := bom + "Day,Food Name,Energy (kcal)\n2026-05-04,Apple,52\n" + recs, err := parseServingsCSV(raw, time.UTC) + if err != nil { + t.Fatal(err) + } + if len(recs) != 1 || recs[0].FoodName != "Apple" || recs[0].EnergyKcal != 52 { + t.Errorf("BOM-handling failed: %+v", recs) + } +} + +func TestEmptyCSVIsHeaderOnly(t *testing.T) { + // Mimics WIRE_SHAPES.md §(5/7/9) "header-only" responses for + // exercises/biometrics/notes when the window holds no data. + recs, err := parseServingsCSV("Day,Food Name\n", time.UTC) + if err != nil { + t.Fatal(err) + } + if len(recs) != 0 { + t.Errorf("expected 0 records from header-only CSV, got %d", len(recs)) + } + if _, err := parseServingsCSV("", time.UTC); err != nil { + t.Errorf("parseServingsCSV(\"\"): %v", err) + } +} + +func TestGWTBodyShapesAreLiteralFromSpec(t *testing.T) { + // Pin the exact GWT-RPC framing strings so a future drift gets + // caught here rather than at runtime. + perm := "ABCDEF0123456789ABCDEF0123456789" + got := authenticateBody(perm, -300) + want := "7|0|5|https://cronometer.com/cronometer/|ABCDEF0123456789ABCDEF0123456789|com.cronometer.shared.rpc.CronometerService|authenticate|java.lang.Integer/3438268394|1|2|3|4|1|5|5|-300|" + if got != want { + t.Errorf("authenticateBody:\n got %q\nwant %q", got, want) + } + + got = generateAuthorizationTokenBody(perm, "11112222333344445555666677778888", 1234567, 3600) + want = "7|0|8|https://cronometer.com/cronometer/|ABCDEF0123456789ABCDEF0123456789|com.cronometer.shared.rpc.CronometerService|generateAuthorizationToken|java.lang.String/2004016611|I|com.cronometer.shared.user.AuthScope/2065601159|11112222333344445555666677778888|1|2|3|4|4|5|6|6|7|8|1234567|3600|7|2|" + if got != want { + t.Errorf("generateAuthorizationTokenBody:\n got %q\nwant %q", got, want) + } + + got = logoutBody(perm, "11112222333344445555666677778888") + want = "7|0|6|https://cronometer.com/cronometer/|ABCDEF0123456789ABCDEF0123456789|com.cronometer.shared.rpc.CronometerService|logout|java.lang.String/2004016611|11112222333344445555666677778888|1|2|3|4|1|5|6|" + if got != want { + t.Errorf("logoutBody:\n got %q\nwant %q", got, want) + } +} diff --git a/internal/cronoapi/doc.go b/internal/cronoapi/doc.go new file mode 100644 index 0000000..94c8a04 --- /dev/null +++ b/internal/cronoapi/doc.go @@ -0,0 +1,17 @@ +// Package cronoapi is the MIT-licensed, clean-room Cronometer HTTP client +// used by crono-export-cli. +// +// This package was authored from two inputs only: +// +// 1. The Phase 1 protocol spec at docs/cronometer-protocol.md, which +// itself was derived from crono-export-cli's own MIT-licensed call +// sites. +// 2. The Phase 3a wire-shape capture at +// internal/cronoclient/testdata/cronometer/WIRE_SHAPES.md, which +// documents the observable HTTP behaviour of cronometer.com from +// a real recording session. +// +// No source code from github.com/jrmycanady/gocronometer (GPL-2.0) was +// consulted during authoring. The package replaces that dependency end +// to end; see QUA-37 for the clean-room rationale. +package cronoapi diff --git a/internal/cronoapi/export.go b/internal/cronoapi/export.go new file mode 100644 index 0000000..fe18bc1 --- /dev/null +++ b/internal/cronoapi/export.go @@ -0,0 +1,482 @@ +package cronoapi + +import ( + "context" + "encoding/csv" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + "time" +) + +const ( + exportGenServings = "servings" + exportGenExercises = "exercises" + exportGenBiometrics = "biometrics" + exportGenDailySummary = "dailySummary" + exportGenNotes = "notes" + + exportNonceTTL = 3600 // seconds, per WIRE_SHAPES.md §(4) + + csvDateLayout = "2006-01-02" +) + +// exportRaw mints a one-shot nonce via GWT-RPC, then GETs the export +// CSV and returns the raw body as a string. WIRE_SHAPES.md §(4-5). +func (c *Client) exportRaw(ctx context.Context, generate string, start, end time.Time) (string, error) { + if c.authToken == "" { + return "", fmt.Errorf("%s export: not logged in (call Login first)", generate) + } + // Step 1: mint per-export nonce. + nonceBody, err := c.gwtCall(ctx, generateAuthorizationTokenBody(c.permutation, c.authToken, c.userID, exportNonceTTL)) + if err != nil { + return "", fmt.Errorf("%s nonce: %w", generate, err) + } + nonce, err := parseAuthorizationTokenResponse(nonceBody) + if err != nil { + return "", fmt.Errorf("%s nonce: %w", generate, err) + } + + // Step 2: GET /export with the nonce. + q := url.Values{} + q.Set("start", start.Format(csvDateLayout)) + q.Set("end", end.Format(csvDateLayout)) + q.Set("generate", generate) + q.Set("nonce", nonce) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/export?"+q.Encode(), nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", c.userAgent) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("GET /export?generate=%s: %w", generate, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read %s export body: %w", generate, err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GET /export?generate=%s: HTTP %d: %s", generate, resp.StatusCode, truncate(string(body), 200)) + } + return string(body), nil +} + +// ExportServingsParsedWithLocation downloads the servings CSV and +// parses it into typed ServingRecords. RecordedTime values are parsed +// in loc; if loc is nil, time.UTC is used. +func (c *Client) ExportServingsParsedWithLocation(ctx context.Context, start, end time.Time, loc *time.Location) (ServingRecords, error) { + raw, err := c.exportRaw(ctx, exportGenServings, start, end) + if err != nil { + return nil, err + } + return parseServingsCSV(raw, loc) +} + +// ExportExercisesParsedWithLocation downloads the exercises CSV and +// parses it into typed ExerciseRecords. +func (c *Client) ExportExercisesParsedWithLocation(ctx context.Context, start, end time.Time, loc *time.Location) (ExerciseRecords, error) { + raw, err := c.exportRaw(ctx, exportGenExercises, start, end) + if err != nil { + return nil, err + } + return parseExercisesCSV(raw, loc) +} + +// ExportBiometricRecordsParsedWithLocation downloads the biometrics +// CSV and parses it into typed BiometricRecords. +func (c *Client) ExportBiometricRecordsParsedWithLocation(ctx context.Context, start, end time.Time, loc *time.Location) (BiometricRecords, error) { + raw, err := c.exportRaw(ctx, exportGenBiometrics, start, end) + if err != nil { + return nil, err + } + return parseBiometricsCSV(raw, loc) +} + +// ExportDailyNutrition downloads the daily-summary CSV and returns it +// verbatim. crono-export-cli parses the raw string with encoding/csv +// into []map[string]string. +func (c *Client) ExportDailyNutrition(ctx context.Context, start, end time.Time) (string, error) { + return c.exportRaw(ctx, exportGenDailySummary, start, end) +} + +// ExportNotes downloads the notes CSV and returns it verbatim. +func (c *Client) ExportNotes(ctx context.Context, start, end time.Time) (string, error) { + return c.exportRaw(ctx, exportGenNotes, start, end) +} + +// ---- CSV parsing ------------------------------------------------------ + +// readCSV trims a BOM if present, then parses raw with encoding/csv into +// (header, rows). Returns ("", nil) on empty input. +func readCSV(raw string) ([]string, [][]string, error) { + if raw == "" { + return nil, nil, nil + } + const utf8BOM = "\xef\xbb\xbf" + if strings.HasPrefix(raw, utf8BOM) { + raw = strings.TrimPrefix(raw, utf8BOM) + } + r := csv.NewReader(strings.NewReader(raw)) + r.FieldsPerRecord = -1 + rows, err := r.ReadAll() + if err != nil { + return nil, nil, err + } + if len(rows) == 0 { + return nil, nil, nil + } + return rows[0], rows[1:], nil +} + +// parseFloat returns 0 for empty or unparseable input. Cronometer +// emits blank cells for missing values; we treat them as zero rather +// than as a parse error to match how crono-export-cli's renderer +// already filters zero-valued nutrients. +func parseFloat(s string) float64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + f, err := strconv.ParseFloat(strings.ReplaceAll(s, ",", ""), 64) + if err != nil { + return 0 + } + return f +} + +// parseRecordedTime is tolerant of the timestamp shapes Cronometer +// emits on the various export endpoints (date-only, ISO date+time, +// space-separated date+time). Empty input returns the zero time. +// The parsed value is materialised in loc so subsequent +// .Format("2006-01-02") matches the user's local calendar. +func parseRecordedTime(s string, loc *time.Location) time.Time { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{} + } + if loc == nil { + loc = time.UTC + } + layouts := []string{ + csvDateLayout, + "2006-01-02 15:04", + "2006-01-02 15:04:05", + "2006-01-02T15:04", + "2006-01-02T15:04:05", + time.RFC3339, + } + for _, l := range layouts { + if t, err := time.ParseInLocation(l, s, loc); err == nil { + return t + } + } + return time.Time{} +} + +// headerToFieldName converts a Cronometer CSV column name like +// "Energy (kcal)" or "Vitamin B12 (µg)" into the Go field name on +// ServingRecord that the renderer expects, e.g., "EnergyKcal" or +// "B12Ug". Returns "" when the header is not a nutrient column the +// caller should map onto a struct field. +// +// The mapping mirrors cmd/format.go's strippedSuffix in reverse: the +// renderer splits "EnergyKcal" → ("Energy", "kcal") using a fixed +// suffix table; this function recombines a CSV header with its unit +// into that same Go name. +func headerToNutrientField(header string) string { + open := strings.LastIndex(header, "(") + close := strings.LastIndex(header, ")") + if open < 0 || close < open { + return "" + } + name := strings.TrimSpace(header[:open]) + unit := strings.TrimSpace(header[open+1 : close]) + if name == "" || unit == "" { + return "" + } + // Strip whitespace, hyphens, and other non-letter/digit chars from + // the nutrient name so "Net Carbs" → "NetCarbs", "Trans-Fats" → + // "TransFats", "Vitamin B12" → "B12" (drop the "Vitamin " prefix + // per the captured Go field names — see ServingRecord.B12Ug). + goName := compactName(name) + if goName == "" { + return "" + } + goUnit := unitToGoSuffix(unit) + if goUnit == "" { + return "" + } + return goName + goUnit +} + +// compactName strips spaces and punctuation from a CSV column name and +// applies the small number of public Cronometer aliases observed in +// our spec doc (the B-vitamins drop the "Vitamin " prefix; "Net Carbs" +// concatenates, etc.). +func compactName(s string) string { + // Drop a leading "Vitamin " for B-vitamins so "Vitamin B12" → "B12" + // (the ServingRecord field is B12Ug, not VitaminB12Ug). Vitamin A, + // C, D, E, K keep the "Vitamin" prefix per the captured field set. + if strings.HasPrefix(s, "Vitamin B") { + s = strings.TrimPrefix(s, "Vitamin ") + } + var b strings.Builder + upperNext := true + for _, r := range s { + switch { + case r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + if upperNext { + b.WriteRune(r) + upperNext = false + } else { + b.WriteRune(r) + } + case r >= 'a' && r <= 'z': + if upperNext { + b.WriteRune(r - 32) + upperNext = false + } else { + b.WriteRune(r) + } + default: + upperNext = true + } + } + return b.String() +} + +// unitToGoSuffix converts a CSV column's parenthesised unit to the +// Go-style suffix used by ServingRecord field names. Mirrors the +// fixed suffix table in cmd/format.go's strippedSuffix. +func unitToGoSuffix(u string) string { + switch strings.ToLower(u) { + case "kcal": + return "Kcal" + case "g": + return "G" + case "mg": + return "Mg" + case "µg", "ug", "mcg": + return "Ug" + case "iu", "ui": + return "IU" + } + return "" +} + +// servingFieldByName is built once at package load: a name→reflect.Value +// helper isn't safe across instances, so we just cache the list of +// nutrient field names declared on ServingRecord. The CSV parser uses +// reflect to set fields by name on a per-row instance. +var servingNutrientFields = buildServingNutrientFieldSet() + +func buildServingNutrientFieldSet() map[string]bool { + set := map[string]bool{} + t := reflect.TypeOf(ServingRecord{}) + skip := map[string]bool{ + "RecordedTime": true, "Group": true, "FoodName": true, + "QuantityValue": true, "QuantityUnits": true, "Category": true, + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if skip[f.Name] { + continue + } + if f.Type.Kind() == reflect.Float64 { + set[f.Name] = true + } + } + return set +} + +// parseServingsCSV maps each CSV header onto either a documented named +// field or a nutrient field on ServingRecord. Columns whose header +// can't be mapped (e.g., a new Cronometer-added nutrient column) are +// silently dropped — a future WIRE_SHAPES.md recapture should extend +// the ServingRecord type to include them. +func parseServingsCSV(raw string, loc *time.Location) (ServingRecords, error) { + header, rows, err := readCSV(raw) + if err != nil { + return nil, fmt.Errorf("servings: %w", err) + } + if header == nil { + return ServingRecords{}, nil + } + + type binding struct { + col int + setter func(*ServingRecord, string) + } + var bindings []binding + for i, col := range header { + i := i + c := strings.TrimSpace(col) + switch c { + case "Day", "Date", "Time", "RecordedTime", "Logged At": + bindings = append(bindings, binding{i, func(r *ServingRecord, v string) { + r.RecordedTime = parseRecordedTime(v, loc) + }}) + case "Group", "Meal": + bindings = append(bindings, binding{i, func(r *ServingRecord, v string) { + r.Group = strings.TrimSpace(v) + }}) + case "Food Name", "Food", "FoodName": + bindings = append(bindings, binding{i, func(r *ServingRecord, v string) { + r.FoodName = strings.TrimSpace(v) + }}) + case "Amount", "Quantity", "Serving Amount": + bindings = append(bindings, binding{i, func(r *ServingRecord, v string) { + r.QuantityValue, r.QuantityUnits = splitQuantity(v) + }}) + case "Units", "Serving Units": + bindings = append(bindings, binding{i, func(r *ServingRecord, v string) { + r.QuantityUnits = strings.TrimSpace(v) + }}) + case "Category": + bindings = append(bindings, binding{i, func(r *ServingRecord, v string) { + r.Category = strings.TrimSpace(v) + }}) + default: + if f := headerToNutrientField(c); f != "" && servingNutrientFields[f] { + field := f + bindings = append(bindings, binding{i, func(r *ServingRecord, v string) { + setFloatField(r, field, parseFloat(v)) + }}) + } + } + } + + out := make(ServingRecords, 0, len(rows)) + for _, row := range rows { + var rec ServingRecord + for _, b := range bindings { + if b.col < len(row) { + b.setter(&rec, row[b.col]) + } + } + out = append(out, rec) + } + return out, nil +} + +// splitQuantity handles CSV cells like "1.00 g" or "1.5 cup". When +// the cell is purely numeric the unit is left empty (the caller's +// separate "Units" column, if any, fills it in). +func splitQuantity(s string) (float64, string) { + s = strings.TrimSpace(s) + if s == "" { + return 0, "" + } + if sp := strings.IndexAny(s, " \t"); sp > 0 { + return parseFloat(s[:sp]), strings.TrimSpace(s[sp+1:]) + } + return parseFloat(s), "" +} + +// setFloatField sets a float64 field on a ServingRecord by name. The +// field name must already be present in servingNutrientFields. +func setFloatField(r *ServingRecord, name string, v float64) { + rv := reflect.ValueOf(r).Elem() + f := rv.FieldByName(name) + if f.IsValid() && f.CanSet() && f.Kind() == reflect.Float64 { + f.SetFloat(v) + } +} + +// parseExercisesCSV parses the exercises CSV into ExerciseRecords. +func parseExercisesCSV(raw string, loc *time.Location) (ExerciseRecords, error) { + header, rows, err := readCSV(raw) + if err != nil { + return nil, fmt.Errorf("exercises: %w", err) + } + if header == nil { + return ExerciseRecords{}, nil + } + + idx := func(names ...string) int { + for _, want := range names { + for i, col := range header { + if strings.EqualFold(strings.TrimSpace(col), want) { + return i + } + } + } + return -1 + } + iDate := idx("Day", "Date", "RecordedTime", "Logged At") + iName := idx("Exercise", "Activity") + iMins := idx("Minutes", "Duration (min)", "Duration") + iKcal := idx("Calories Burned", "Calories Burned (kcal)", "Energy (kcal)") + iGroup := idx("Category", "Group") + + get := func(row []string, i int) string { + if i < 0 || i >= len(row) { + return "" + } + return row[i] + } + + out := make(ExerciseRecords, 0, len(rows)) + for _, row := range rows { + out = append(out, ExerciseRecord{ + RecordedTime: parseRecordedTime(get(row, iDate), loc), + Exercise: strings.TrimSpace(get(row, iName)), + Minutes: parseFloat(get(row, iMins)), + CaloriesBurned: parseFloat(get(row, iKcal)), + Group: strings.TrimSpace(get(row, iGroup)), + }) + } + return out, nil +} + +// parseBiometricsCSV parses the biometrics CSV into BiometricRecords. +func parseBiometricsCSV(raw string, loc *time.Location) (BiometricRecords, error) { + header, rows, err := readCSV(raw) + if err != nil { + return nil, fmt.Errorf("biometrics: %w", err) + } + if header == nil { + return BiometricRecords{}, nil + } + + idx := func(names ...string) int { + for _, want := range names { + for i, col := range header { + if strings.EqualFold(strings.TrimSpace(col), want) { + return i + } + } + } + return -1 + } + iDate := idx("Day", "Date", "RecordedTime", "Logged At") + iMetric := idx("Metric", "Name") + iAmount := idx("Amount", "Value") + iUnit := idx("Unit", "Units") + + get := func(row []string, i int) string { + if i < 0 || i >= len(row) { + return "" + } + return row[i] + } + + out := make(BiometricRecords, 0, len(rows)) + for _, row := range rows { + out = append(out, BiometricRecord{ + RecordedTime: parseRecordedTime(get(row, iDate), loc), + Metric: strings.TrimSpace(get(row, iMetric)), + Amount: parseFloat(get(row, iAmount)), + Unit: strings.TrimSpace(get(row, iUnit)), + }) + } + return out, nil +} diff --git a/internal/cronoapi/gwt.go b/internal/cronoapi/gwt.go new file mode 100644 index 0000000..b45b80b --- /dev/null +++ b/internal/cronoapi/gwt.go @@ -0,0 +1,142 @@ +package cronoapi + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "strings" +) + +// DefaultGWTPermutation is the GWT permutation hash captured against +// cronometer.com on 2026-05-12. Cronometer rotates this value when they +// redeploy their frontend; callers can override via Client.SetPermutation. +// +// Provenance: WIRE_SHAPES.md §"X-Gwt-Permutation". +const DefaultGWTPermutation = "7B121DC5483BF272B1BC1916DA9FA963" + +const ( + gwtModuleBase = "https://cronometer.com/cronometer/" + gwtServiceIfc = "com.cronometer.shared.rpc.CronometerService" + gwtContentType = "text/x-gwt-rpc; charset=UTF-8" +) + +// gwtCall posts a raw GWT-RPC body to /cronometer/app and returns the +// response body as text. It enforces the cronometer.com-required headers +// (Content-Type, X-Gwt-Module-Base, X-Gwt-Permutation) and lets the +// http.Client's cookie jar manage session cookies. +func (c *Client) gwtCall(ctx context.Context, body string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/cronometer/app", strings.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", gwtContentType) + req.Header.Set("X-Gwt-Module-Base", gwtModuleBase) + req.Header.Set("X-Gwt-Permutation", c.permutation) + req.Header.Set("User-Agent", c.userAgent) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("gwt-rpc: HTTP %d: %s", resp.StatusCode, truncate(string(raw), 200)) + } + return string(raw), nil +} + +// authenticateBody returns the GWT-RPC payload for +// CronometerService.authenticate(int utcOffsetMinutes). The framing +// follows the literal call template captured in WIRE_SHAPES.md §(3). +func authenticateBody(permutation string, utcOffsetMinutes int) string { + return fmt.Sprintf( + "7|0|5|%s|%s|%s|authenticate|java.lang.Integer/3438268394|1|2|3|4|1|5|5|%d|", + gwtModuleBase, permutation, gwtServiceIfc, utcOffsetMinutes, + ) +} + +// generateAuthorizationTokenBody returns the GWT-RPC payload for +// CronometerService.generateAuthorizationToken(String, int, AuthScope). +// Captured framing per WIRE_SHAPES.md §(4). The trailing +// "7|2|" tail is the string-table back-reference set observed across +// every export type in the capture; it is re-emitted verbatim until a +// future capture proves it should vary. +func generateAuthorizationTokenBody(permutation, authToken string, userID int, ttlSeconds int) string { + return fmt.Sprintf( + "7|0|8|%s|%s|%s|generateAuthorizationToken|java.lang.String/2004016611|I|com.cronometer.shared.user.AuthScope/2065601159|%s|1|2|3|4|4|5|6|6|7|8|%d|%d|7|2|", + gwtModuleBase, permutation, gwtServiceIfc, authToken, userID, ttlSeconds, + ) +} + +// logoutBody returns the GWT-RPC payload for +// CronometerService.logout(String authToken). Captured framing per +// WIRE_SHAPES.md §(14). +func logoutBody(permutation, authToken string) string { + return fmt.Sprintf( + "7|0|6|%s|%s|%s|logout|java.lang.String/2004016611|%s|1|2|3|4|1|5|6|", + gwtModuleBase, permutation, gwtServiceIfc, authToken, + ) +} + +// GWT-RPC responses are prefixed `//OK[` on success and `//EX[` on +// exception. parseGwtOK strips the //OK prefix and returns the inner +// payload (everything between the [ and the trailing ]). +var ( + gwtOKRe = regexp.MustCompile(`^//OK\[(.*)\]\s*$`) + gwtUserIDRe = regexp.MustCompile(`^//OK\[(\d+),`) + gwtAuthTokRe = regexp.MustCompile(`"([0-9a-fA-F]{32})"`) + gwtNonceRe = regexp.MustCompile(`\["([0-9a-fA-F]{32})"\]`) +) + +// parseAuthenticateResponse pulls (userID, sessionAuthToken) out of the +// //OK[...] body of an authenticate response. The body is a flat array +// of GWT-interned values; we use targeted regexes instead of decoding +// the full string table (WIRE_SHAPES.md §(3) "Robust decoder approach"). +// +// The session auth token is taken as the last 32-hex string in the +// response — empirically the position the subsequent +// generateAuthorizationToken calls echo back as their first argument. +func parseAuthenticateResponse(body string) (userID int, authToken string, err error) { + if !strings.HasPrefix(body, "//OK[") { + return 0, "", fmt.Errorf("authenticate: unexpected response prefix: %q", truncate(body, 80)) + } + idMatch := gwtUserIDRe.FindStringSubmatch(body) + if idMatch == nil { + return 0, "", fmt.Errorf("authenticate: could not extract userId from response") + } + if _, err := fmt.Sscanf(idMatch[1], "%d", &userID); err != nil { + return 0, "", fmt.Errorf("authenticate: parse userId %q: %w", idMatch[1], err) + } + tokens := gwtAuthTokRe.FindAllStringSubmatch(body, -1) + if len(tokens) == 0 { + return 0, "", fmt.Errorf("authenticate: no 32-hex session token found in response") + } + return userID, tokens[len(tokens)-1][1], nil +} + +// parseAuthorizationTokenResponse pulls the export nonce out of a +// generateAuthorizationToken response. The body shape is +// `//OK[1,[""],0,7]` (WIRE_SHAPES.md §(4) response shape). +func parseAuthorizationTokenResponse(body string) (string, error) { + if !strings.HasPrefix(body, "//OK[") { + return "", fmt.Errorf("auth token: unexpected response prefix: %q", truncate(body, 80)) + } + m := gwtNonceRe.FindStringSubmatch(body) + if m == nil { + return "", fmt.Errorf("auth token: no 32-hex nonce found in response: %q", truncate(body, 80)) + } + return m[1], nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/cronoapi/records.go b/internal/cronoapi/records.go new file mode 100644 index 0000000..a64a663 --- /dev/null +++ b/internal/cronoapi/records.go @@ -0,0 +1,105 @@ +package cronoapi + +import "time" + +// ServingRecord is one row from the Cronometer "servings" CSV export — +// a single food item logged on a particular date. +// +// The named fields (RecordedTime, Group, FoodName, QuantityValue, +// QuantityUnits, Category) are the ones crono-export-cli's renderer +// consumes by name. Every other field on this struct is a nutrient +// column whose Go name follows the documented "" convention +// so that cmd/format.go's reflection walker can render them. +// +// The nutrient set below covers the columns Cronometer's public web +// export emits today. Columns Cronometer adds later that don't match +// a declared field name are dropped on parse — recapture WIRE_SHAPES.md +// and extend this struct when that happens. +type ServingRecord struct { + RecordedTime time.Time `json:"RecordedTime"` + Group string `json:"Group"` + FoodName string `json:"FoodName"` + QuantityValue float64 `json:"QuantityValue"` + QuantityUnits string `json:"QuantityUnits"` + Category string `json:"Category"` + + EnergyKcal float64 `json:"EnergyKcal"` + AlcoholG float64 `json:"AlcoholG"` + CaffeineMg float64 `json:"CaffeineMg"` + WaterG float64 `json:"WaterG"` + CarbsG float64 `json:"CarbsG"` + FiberG float64 `json:"FiberG"` + StarchG float64 `json:"StarchG"` + SugarsG float64 `json:"SugarsG"` + AddedSugarsG float64 `json:"AddedSugarsG"` + NetCarbsG float64 `json:"NetCarbsG"` + FatG float64 `json:"FatG"` + MonounsaturatedG float64 `json:"MonounsaturatedG"` + PolyunsaturatedG float64 `json:"PolyunsaturatedG"` + SaturatedG float64 `json:"SaturatedG"` + TransFatsG float64 `json:"TransFatsG"` + CholesterolMg float64 `json:"CholesterolMg"` + Omega3G float64 `json:"Omega3G"` + Omega6G float64 `json:"Omega6G"` + ProteinG float64 `json:"ProteinG"` + CystineG float64 `json:"CystineG"` + HistidineG float64 `json:"HistidineG"` + IsoleucineG float64 `json:"IsoleucineG"` + LeucineG float64 `json:"LeucineG"` + LysineG float64 `json:"LysineG"` + MethionineG float64 `json:"MethionineG"` + PhenylalanineG float64 `json:"PhenylalanineG"` + ThreonineG float64 `json:"ThreonineG"` + TryptophanG float64 `json:"TryptophanG"` + TyrosineG float64 `json:"TyrosineG"` + ValineG float64 `json:"ValineG"` + B1Mg float64 `json:"B1Mg"` + B2Mg float64 `json:"B2Mg"` + B3Mg float64 `json:"B3Mg"` + B5Mg float64 `json:"B5Mg"` + B6Mg float64 `json:"B6Mg"` + B12Ug float64 `json:"B12Ug"` + FolateUg float64 `json:"FolateUg"` + VitaminAUg float64 `json:"VitaminAUg"` + VitaminCMg float64 `json:"VitaminCMg"` + VitaminDIU float64 `json:"VitaminDIU"` + VitaminEMg float64 `json:"VitaminEMg"` + VitaminKUg float64 `json:"VitaminKUg"` + CalciumMg float64 `json:"CalciumMg"` + CopperMg float64 `json:"CopperMg"` + IronMg float64 `json:"IronMg"` + MagnesiumMg float64 `json:"MagnesiumMg"` + ManganeseMg float64 `json:"ManganeseMg"` + PhosphorusMg float64 `json:"PhosphorusMg"` + PotassiumMg float64 `json:"PotassiumMg"` + SeleniumUg float64 `json:"SeleniumUg"` + SodiumMg float64 `json:"SodiumMg"` + ZincMg float64 `json:"ZincMg"` + CholineMg float64 `json:"CholineMg"` +} + +// ServingRecords is a slice of ServingRecord. +type ServingRecords []ServingRecord + +// ExerciseRecord is one row from the Cronometer "exercises" CSV export. +type ExerciseRecord struct { + RecordedTime time.Time `json:"RecordedTime"` + Exercise string `json:"Exercise"` + Minutes float64 `json:"Minutes"` + CaloriesBurned float64 `json:"CaloriesBurned"` + Group string `json:"Group"` +} + +// ExerciseRecords is a slice of ExerciseRecord. +type ExerciseRecords []ExerciseRecord + +// BiometricRecord is one row from the Cronometer "biometrics" CSV export. +type BiometricRecord struct { + RecordedTime time.Time `json:"RecordedTime"` + Metric string `json:"Metric"` + Amount float64 `json:"Amount"` + Unit string `json:"Unit"` +} + +// BiometricRecords is a slice of BiometricRecord. +type BiometricRecords []BiometricRecord diff --git a/internal/cronoclient/client.go b/internal/cronoclient/client.go index fe417b9..6a24444 100644 --- a/internal/cronoclient/client.go +++ b/internal/cronoclient/client.go @@ -8,13 +8,13 @@ import ( "strings" "time" - "github.com/jrmycanady/gocronometer" + "github.com/quantcli/crono-export-cli/internal/cronoapi" ) -// Client wraps a logged-in gocronometer.Client and exposes export methods +// Client wraps a logged-in cronoapi.Client and exposes export methods // that return JSON-ready Go values. type Client struct { - inner *gocronometer.Client + inner *cronoapi.Client } // NewLoggedIn creates a client and logs in using CRONOMETER_USERNAME and @@ -25,7 +25,7 @@ func NewLoggedIn(ctx context.Context) (*Client, error) { if user == "" || pass == "" { return nil, fmt.Errorf("CRONOMETER_USERNAME and CRONOMETER_PASSWORD must be set") } - inner := gocronometer.NewClient(nil) + inner := cronoapi.NewClient(nil) if err := inner.Login(ctx, user, pass); err != nil { return nil, fmt.Errorf("login failed: %w", err) } diff --git a/internal/cronoclient/daterange.go b/internal/cronoclient/daterange.go index 082eea3..407d5c4 100644 --- a/internal/cronoclient/daterange.go +++ b/internal/cronoclient/daterange.go @@ -1,6 +1,7 @@ -// Package cronoclient wraps github.com/jrmycanady/gocronometer with a small -// session helper, typed export methods that return JSON-ready Go values, and -// shared Cobra flag plumbing for date-range selection. +// Package cronoclient wraps the internal MIT-licensed cronoapi client +// with a small session helper, typed export methods that return +// JSON-ready Go values, and shared Cobra flag plumbing for date-range +// selection. package cronoclient import (