diff --git a/.gitignore b/.gitignore index 0a1ae84..9d696f1 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ cronometer-capture/ tools/wirecapture/captures/ tools/wirecapture/wirecapture *.unredacted.json +.DS_Store diff --git a/README.md b/README.md index 4482d42..e9032a1 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,17 @@ export CRONOMETER_USERNAME="you@example.com" export CRONOMETER_PASSWORD="your-cronometer-password" ``` -The CLI logs in on every invocation; there's no token cache. Cronometer doesn't (yet) offer SSO or API tokens for individuals, so a real password is the only auth option. +Cronometer doesn't (yet) offer SSO or API tokens for individuals, so a real password is the only auth option. + +After the first successful login, the session (auth token + cookies) is cached at `$XDG_CACHE_HOME/crono-export/session.json` (file mode `0600`, directory mode `0700`; on macOS this resolves to `~/Library/Caches/crono-export/session.json`). Subsequent invocations reuse the cached session and skip the login handshake — useful for LLM-agent workflows that fire several commands in quick succession (a fresh login on every call trips Cronometer's "Too Many Attempts" throttle after ~6 requests). When the cached session goes stale, the CLI transparently re-logs in and retries once. + +Escape hatches: + +- `CRONOMETER_NO_CACHE=1` forces a fresh login on every invocation (and skips writing the cache). +- `crono-export auth logout` deletes the cached session. +- `crono-export auth status` reports whether credentials are set and whether a session is cached. + +Treat `session.json` like a password: don't sync it to a shared backup, and don't commit it. ## Usage diff --git a/cmd/auth.go b/cmd/auth.go index 7ccfc71..55d3c50 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -5,6 +5,8 @@ import ( "os" "github.com/spf13/cobra" + + "github.com/quantcli/crono-export-cli/internal/cronoclient" ) var authCmd = &cobra.Command{ @@ -36,12 +38,46 @@ https://github.com/quantcli/common/blob/main/CONTRACT.md#5-auth`, case pass == "": return fmt.Errorf("missing CRONOMETER_PASSWORD") } - fmt.Fprintf(cmd.OutOrStdout(), "credentials present for %s (env-var auth, no token cache)\n", user) + cacheState := "no cache" + if os.Getenv("CRONOMETER_NO_CACHE") == "" { + if p := cronoclient.SessionCachePath(); p != "" { + if _, err := os.Stat(p); err == nil { + cacheState = "session cached" + } else { + cacheState = "no session cached" + } + } + } else { + cacheState = "cache disabled (CRONOMETER_NO_CACHE set)" + } + fmt.Fprintf(cmd.OutOrStdout(), "credentials present for %s (%s)\n", user, cacheState) + return nil + }, +} + +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Delete the cached Cronometer session, forcing a fresh login next call", + Long: `Remove the on-disk session cache at $XDG_CACHE_HOME/crono-export/session.json. +Useful after rotating your password or when you suspect a stale session +is causing failures. + +This is a local-only operation: it does NOT call Cronometer's logout +endpoint (that would invalidate the cached cookies for a session we've +already deleted). The next export call will perform a fresh login.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + p, err := cronoclient.DeleteCachedSession() + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "session cache cleared (%s)\n", p) return nil }, } func init() { authCmd.AddCommand(authStatusCmd) + authCmd.AddCommand(authLogoutCmd) rootCmd.AddCommand(authCmd) } diff --git a/cmd/biometrics.go b/cmd/biometrics.go index cc5bf6b..c6abc05 100644 --- a/cmd/biometrics.go +++ b/cmd/biometrics.go @@ -7,13 +7,18 @@ import ( ) var biometricsCmd = &cobra.Command{ - Use: "biometrics", - Short: "Export biometric records (weight, body fat, blood pressure, custom metrics)", + Use: "biometrics", + Short: "Export biometric records (weight, body fat, blood pressure, custom metrics)", + Args: cobra.NoArgs, + PreRunE: ValidateExportFlags, RunE: func(cmd *cobra.Command, _ []string) error { rng, err := cronoclient.ParseDateRangeFromFlags(cmd) if err != nil { return err } + if rng.IsEmpty() { + return emit(cmd, kindBiometrics, emptyValueFor(kindBiometrics)) + } ctx := cmd.Context() c, err := cronoclient.NewLoggedIn(ctx) if err != nil { diff --git a/cmd/exercises.go b/cmd/exercises.go index 9013a7b..f9b4d8d 100644 --- a/cmd/exercises.go +++ b/cmd/exercises.go @@ -7,13 +7,18 @@ import ( ) var exercisesCmd = &cobra.Command{ - Use: "exercises", - Short: "Export logged exercises (cardio, strength, custom activities)", + Use: "exercises", + Short: "Export logged exercises (cardio, strength, custom activities)", + Args: cobra.NoArgs, + PreRunE: ValidateExportFlags, RunE: func(cmd *cobra.Command, _ []string) error { rng, err := cronoclient.ParseDateRangeFromFlags(cmd) if err != nil { return err } + if rng.IsEmpty() { + return emit(cmd, kindExercises, emptyValueFor(kindExercises)) + } ctx := cmd.Context() c, err := cronoclient.NewLoggedIn(ctx) if err != nil { diff --git a/cmd/format.go b/cmd/format.go index 4e8f04f..7829670 100644 --- a/cmd/format.go +++ b/cmd/format.go @@ -31,6 +31,16 @@ func AddFormatFlags(cmd *cobra.Command) { "Output format: markdown (default, fitdown-style) or json") } +// ValidateExportFlags is a PreRunE that fails fast on bad --format or date +// flags before any network call is made. Without this, a typo in --format +// would burn a Cronometer login attempt against the rate limit. +func ValidateExportFlags(cmd *cobra.Command, _ []string) error { + if _, err := chosenFormat(cmd); err != nil { + return err + } + return nil +} + func chosenFormat(cmd *cobra.Command) (string, error) { f, _ := cmd.Flags().GetString("format") switch f { @@ -70,10 +80,10 @@ func renderMarkdown(w io.Writer, kind recordKind, v any) error { recs, _ := v.(cronoapi.ExerciseRecords) return renderExercises(w, recs) case kindNutrition: - rows, _ := v.([]map[string]string) + rows, _ := v.([]map[string]any) return renderNutrition(w, rows) case kindNotes: - rows, _ := v.([]map[string]string) + rows, _ := v.([]map[string]any) return renderNotes(w, rows) } return fmt.Errorf("renderMarkdown: unknown kind %d", kind) @@ -81,9 +91,29 @@ func renderMarkdown(w io.Writer, kind recordKind, v any) error { // ---- shared helpers --------------------------------------------------- -func emptyMsg(w io.Writer) error { - _, err := fmt.Fprintln(w, "_(no records in window)_") - return err +// noteEmpty writes a friendly "no records" note to stderr so humans see it, +// while keeping stdout clean per the contract's "data only on stdout" rule. +// w is ignored — kept for the renderer signatures. +func noteEmpty(_ io.Writer) error { + fmt.Fprintln(os.Stderr, "(no records in window)") + return nil +} + +// emptyValueFor returns the typed empty value for a record kind. Used +// when the caller knows there are no records (e.g. inverted date range) +// and wants to emit them without making a network call. +func emptyValueFor(kind recordKind) any { + switch kind { + case kindServings: + return cronoapi.ServingRecords{} + case kindBiometrics: + return cronoapi.BiometricRecords{} + case kindExercises: + return cronoapi.ExerciseRecords{} + case kindNutrition, kindNotes: + return []map[string]any{} + } + return nil } // fmtFloat trims trailing zeros so 1.95 → "1.95" and 100.000 → "100". @@ -103,7 +133,7 @@ func strippedSuffix(field string) (name, unit string) { {"Kcal", "kcal"}, {"Mg", "mg"}, {"Ug", "µg"}, - {"UI", "IU"}, + {"IU", "IU"}, {"G", "g"}, } { if strings.HasSuffix(field, suf.go_) && len(field) > len(suf.go_) { @@ -117,7 +147,7 @@ func strippedSuffix(field string) (name, unit string) { func renderServings(w io.Writer, recs cronoapi.ServingRecords) error { if len(recs) == 0 { - return emptyMsg(w) + return noteEmpty(w) } // Group by local calendar date. byDate := map[string][]cronoapi.ServingRecord{} @@ -194,7 +224,7 @@ func strDefault(s, fallback string) string { func renderBiometrics(w io.Writer, recs cronoapi.BiometricRecords) error { if len(recs) == 0 { - return emptyMsg(w) + return noteEmpty(w) } byDate := map[string][]cronoapi.BiometricRecord{} for _, r := range recs { @@ -226,7 +256,7 @@ func renderBiometrics(w io.Writer, recs cronoapi.BiometricRecords) error { func renderExercises(w io.Writer, recs cronoapi.ExerciseRecords) error { if len(recs) == 0 { - return emptyMsg(w) + return noteEmpty(w) } byDate := map[string][]cronoapi.ExerciseRecord{} for _, r := range recs { @@ -263,19 +293,19 @@ func renderExercises(w io.Writer, recs cronoapi.ExerciseRecords) error { // ---- nutrition (daily totals, string-keyed CSV) ---------------------- -func renderNutrition(w io.Writer, rows []map[string]string) error { +func renderNutrition(w io.Writer, rows []map[string]any) error { if len(rows) == 0 { - return emptyMsg(w) + return noteEmpty(w) } // Sort by Date asc. sort.SliceStable(rows, func(i, j int) bool { - return rows[i]["Date"] < rows[j]["Date"] + return cellString(rows[i]["Date"]) < cellString(rows[j]["Date"]) }) for di, row := range rows { if di > 0 { fmt.Fprintln(w) } - date := row["Date"] + date := cellString(row["Date"]) if date == "" { date = "(unknown date)" } @@ -294,7 +324,7 @@ func renderNutrition(w io.Writer, rows []map[string]string) error { if isZeroish(v) { continue } - fmt.Fprintf(w, "- %s: %s\n", k, v) + fmt.Fprintf(w, "- %s: %s\n", k, cellString(v)) } } fmt.Fprintln(w) @@ -302,34 +332,46 @@ func renderNutrition(w io.Writer, rows []map[string]string) error { return nil } -// isZeroish reports whether a CSV value should be treated as "no data" and -// hidden from the markdown output. Empty strings, "0", "0.0", "0.00", etc. -// are zeroish; everything else (including "false", "true", arbitrary text) -// is rendered. -func isZeroish(s string) bool { - if s == "" { - return true - } - // Try numeric: if it parses to 0, it's zeroish. - var f float64 - if _, err := fmt.Sscanf(s, "%f", &f); err == nil && f == 0 { - // But only if the entire string was numeric. - t := strings.TrimSpace(s) - for _, r := range t { - if !(r >= '0' && r <= '9') && r != '.' && r != '-' && r != '+' { - return false - } +// cellString renders a coerced CSV cell back to a display string. Floats +// drop trailing zeros so "1.5" stays "1.5" and "100" stays "100". +func cellString(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + case float64: + return fmtFloat(x) + case bool: + if x { + return "true" } + return "false" + default: + return fmt.Sprintf("%v", x) + } +} + +// isZeroish reports whether a coerced CSV value should be hidden from the +// markdown output: nil, empty string, or numeric zero. "false" / "true" / +// arbitrary text is rendered. +func isZeroish(v any) bool { + switch x := v.(type) { + case nil: return true + case string: + return x == "" + case float64: + return x == 0 } return false } // ---- notes ------------------------------------------------------------ -func renderNotes(w io.Writer, rows []map[string]string) error { +func renderNotes(w io.Writer, rows []map[string]any) error { if len(rows) == 0 { - return emptyMsg(w) + return noteEmpty(w) } dateKey := pickKey(rows[0], "Day", "Date") noteKey := pickKey(rows[0], "Note", "Notes", "Comment") @@ -339,31 +381,31 @@ func renderNotes(w io.Writer, rows []map[string]string) error { if di > 0 { fmt.Fprintln(w) } - date := row[dateKey] + date := cellString(row[dateKey]) if date == "" { date = "(unknown date)" } header := "## " + date - if t := row[timeKey]; t != "" { + if t := cellString(row[timeKey]); t != "" { header += " " + t } fmt.Fprintln(w, header) - if note := strings.TrimSpace(row[noteKey]); note != "" { + if note := strings.TrimSpace(cellString(row[noteKey])); note != "" { fmt.Fprintln(w, note) } else { // Fall back to dumping all non-empty fields if we can't find a Note column. for k, v := range row { - if k == dateKey || k == timeKey || v == "" { + if k == dateKey || k == timeKey || isZeroish(v) { continue } - fmt.Fprintf(w, "- %s: %s\n", k, v) + fmt.Fprintf(w, "- %s: %s\n", k, cellString(v)) } } } return nil } -func pickKey(row map[string]string, candidates ...string) string { +func pickKey(row map[string]any, candidates ...string) string { for _, c := range candidates { if _, ok := row[c]; ok { return c @@ -371,4 +413,3 @@ func pickKey(row map[string]string, candidates ...string) string { } return "" } - diff --git a/cmd/format_test.go b/cmd/format_test.go new file mode 100644 index 0000000..31130df --- /dev/null +++ b/cmd/format_test.go @@ -0,0 +1,25 @@ +package cmd + +import "testing" + +func TestStrippedSuffix(t *testing.T) { + cases := []struct { + field, wantName, wantUnit string + }{ + {"EnergyKcal", "Energy", "kcal"}, + {"CarbsG", "Carbs", "g"}, + {"CalciumMg", "Calcium", "mg"}, + {"VitaminAUg", "VitaminA", "µg"}, + {"VitaminDIU", "VitaminD", "IU"}, + {"PolyunsaturatedG", "Polyunsaturated", "g"}, + {"Group", "Group", ""}, + {"G", "G", ""}, + } + for _, c := range cases { + gotName, gotUnit := strippedSuffix(c.field) + if gotName != c.wantName || gotUnit != c.wantUnit { + t.Errorf("strippedSuffix(%q) = (%q, %q), want (%q, %q)", + c.field, gotName, gotUnit, c.wantName, c.wantUnit) + } + } +} diff --git a/cmd/notes.go b/cmd/notes.go index 734ac84..250436d 100644 --- a/cmd/notes.go +++ b/cmd/notes.go @@ -7,13 +7,18 @@ import ( ) var notesCmd = &cobra.Command{ - Use: "notes", - Short: "Export user-entered notes", + Use: "notes", + Short: "Export user-entered notes", + Args: cobra.NoArgs, + PreRunE: ValidateExportFlags, RunE: func(cmd *cobra.Command, _ []string) error { rng, err := cronoclient.ParseDateRangeFromFlags(cmd) if err != nil { return err } + if rng.IsEmpty() { + return emit(cmd, kindNotes, emptyValueFor(kindNotes)) + } ctx := cmd.Context() c, err := cronoclient.NewLoggedIn(ctx) if err != nil { diff --git a/cmd/nutrition.go b/cmd/nutrition.go index 68ecb9e..706689a 100644 --- a/cmd/nutrition.go +++ b/cmd/nutrition.go @@ -7,13 +7,18 @@ import ( ) var nutritionCmd = &cobra.Command{ - Use: "nutrition", - Short: "Export daily total nutrition (one row per day, all macros + micros)", + Use: "nutrition", + Short: "Export daily total nutrition (one row per day, all macros + micros)", + Args: cobra.NoArgs, + PreRunE: ValidateExportFlags, RunE: func(cmd *cobra.Command, _ []string) error { rng, err := cronoclient.ParseDateRangeFromFlags(cmd) if err != nil { return err } + if rng.IsEmpty() { + return emit(cmd, kindNutrition, emptyValueFor(kindNutrition)) + } ctx := cmd.Context() c, err := cronoclient.NewLoggedIn(ctx) if err != nil { diff --git a/cmd/prime.go b/cmd/prime.go index 502358c..f5fe7bb 100644 --- a/cmd/prime.go +++ b/cmd/prime.go @@ -18,10 +18,14 @@ I/O stderr: errors. Exit 0 on success including empty results. AUTH - Env-var only — the CLI logs in on every run, no token cache. + Env-var credentials (always required): CRONOMETER_USERNAME your Cronometer email CRONOMETER_PASSWORD your Cronometer password + Session is cached at $XDG_CACHE_HOME/crono-export/session.json (mode 0600) + so consecutive calls reuse one login. Set CRONOMETER_NO_CACHE=1 to + disable. 'crono-export auth logout' clears the cache. + crono-export auth status Exit 0 if both vars set, 1 with "missing X". DATE FLAGS (every subcommand) @@ -32,7 +36,7 @@ DATE FLAGS (every subcommand) SUBCOMMANDS servings per-food log; one row per food eaten, full nutrient breakdown - nutrition daily totals across all foods (string-valued JSON — see GOTCHAS) + nutrition daily totals across all foods (one row per day, all macros + micros) biometrics weight, body fat, blood pressure, custom metrics exercises logged cardio / strength / custom activities notes user-entered notes per day @@ -47,9 +51,8 @@ EXAMPLES GOTCHAS - 'today' is your LOCAL calendar day, not UTC. - - 'nutrition' and 'notes' JSON values are STRINGS (raw CSV) — cast with - 'jq tonumber' when doing math. 'servings', 'biometrics', 'exercises' - are typed numbers. + - 'RecordedTime' is date-only (midnight in your local zone); Cronometer's + CSV exports don't carry meal-time, so all times sort as 00:00. - Markdown drops zero-valued nutrients; use --format json for every column. - 'servings' rows have a 'Day' field that is always null — use 'RecordedTime'. ` diff --git a/cmd/servings.go b/cmd/servings.go index 605e1e4..d20cb7a 100644 --- a/cmd/servings.go +++ b/cmd/servings.go @@ -7,13 +7,18 @@ import ( ) var servingsCmd = &cobra.Command{ - Use: "servings", - Short: "Export logged food servings (one row per food eaten, full nutrient breakdown)", + Use: "servings", + Short: "Export logged food servings (one row per food eaten, full nutrient breakdown)", + Args: cobra.NoArgs, + PreRunE: ValidateExportFlags, RunE: func(cmd *cobra.Command, _ []string) error { rng, err := cronoclient.ParseDateRangeFromFlags(cmd) if err != nil { return err } + if rng.IsEmpty() { + return emit(cmd, kindServings, emptyValueFor(kindServings)) + } ctx := cmd.Context() c, err := cronoclient.NewLoggedIn(ctx) if err != nil { diff --git a/internal/cronoapi/client.go b/internal/cronoapi/client.go index 43dc4c2..43f00d7 100644 --- a/internal/cronoapi/client.go +++ b/internal/cronoapi/client.go @@ -77,6 +77,42 @@ func (c *Client) AuthToken() string { return c.authToken } // successful Login. Zero before Login or after Logout. func (c *Client) UserID() int { return c.userID } +// Session is a serialisable snapshot of an authenticated client. +// Callers can persist this between invocations to skip the login +// handshake (see Client.Snapshot / Client.Restore). +type Session struct { + UserID int `json:"user_id"` + AuthToken string `json:"auth_token"` + Cookies []*http.Cookie `json:"cookies"` +} + +// Snapshot returns the current session state — userID, GWT auth token, +// and the cookies the jar holds for the configured base URL. Suitable +// for serialisation to disk; restore on a fresh Client with Restore. +func (c *Client) Snapshot() Session { + s := Session{UserID: c.userID, AuthToken: c.authToken} + if c.HTTPClient.Jar != nil { + if u, err := url.Parse(c.baseURL); err == nil { + s.Cookies = c.HTTPClient.Jar.Cookies(u) + } + } + return s +} + +// Restore seeds the cookie jar and auth state from a previously +// captured Session, skipping the login handshake. The caller is +// responsible for verifying the restored session still works (e.g. by +// retrying on failure and falling back to a fresh Login). +func (c *Client) Restore(s Session) { + c.userID = s.UserID + c.authToken = s.AuthToken + if c.HTTPClient.Jar != nil && len(s.Cookies) > 0 { + if u, err := url.Parse(c.baseURL); err == nil { + c.HTTPClient.Jar.SetCookies(u, s.Cookies) + } + } +} + // anticsrfRe extracts the hidden anti-CSRF token from the login HTML. // WIRE_SHAPES.md §(1). var anticsrfRe = regexp.MustCompile(`name="anticsrf"\s+value="([^"]+)"`) diff --git a/internal/cronoapi/gwt.go b/internal/cronoapi/gwt.go index b45b80b..be01ca3 100644 --- a/internal/cronoapi/gwt.go +++ b/internal/cronoapi/gwt.go @@ -17,8 +17,8 @@ import ( const DefaultGWTPermutation = "7B121DC5483BF272B1BC1916DA9FA963" const ( - gwtModuleBase = "https://cronometer.com/cronometer/" - gwtServiceIfc = "com.cronometer.shared.rpc.CronometerService" + gwtModuleBase = "https://cronometer.com/cronometer/" + gwtServiceIfc = "com.cronometer.shared.rpc.CronometerService" gwtContentType = "text/x-gwt-rpc; charset=UTF-8" ) @@ -88,10 +88,10 @@ func logoutBody(permutation, authToken string) string { // 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})"\]`) + 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 diff --git a/internal/cronoclient/client.go b/internal/cronoclient/client.go index 6a24444..683aa8e 100644 --- a/internal/cronoclient/client.go +++ b/internal/cronoclient/client.go @@ -5,6 +5,7 @@ import ( "encoding/csv" "fmt" "os" + "strconv" "strings" "time" @@ -15,10 +16,25 @@ import ( // that return JSON-ready Go values. type Client struct { inner *cronoapi.Client + user string + pass string + // fresh is true if this client just performed a Login (i.e. the + // session is known-good). If false, the session came from the + // on-disk cache and may be stale; a single auto-retry on the first + // export call will fall back to a fresh login. + fresh bool } -// NewLoggedIn creates a client and logs in using CRONOMETER_USERNAME and -// CRONOMETER_PASSWORD from the environment. +// NewLoggedIn creates a client ready to call the export methods. It +// prefers a cached session under $XDG_CACHE_HOME/crono-export/session.json +// (mode 0600) and falls back to a fresh Cronometer login when no +// cache exists or the cache is invalid for the current user. Set +// CRONOMETER_NO_CACHE=1 to force a per-invocation login. +// +// CRONOMETER_USERNAME and CRONOMETER_PASSWORD must be set in either +// case — even with a cache hit we hold the password so the wrapper +// can transparently re-login if the cached session turns out to be +// stale. func NewLoggedIn(ctx context.Context) (*Client, error) { user := os.Getenv("CRONOMETER_USERNAME") pass := os.Getenv("CRONOMETER_PASSWORD") @@ -26,20 +42,65 @@ func NewLoggedIn(ctx context.Context) (*Client, error) { return nil, fmt.Errorf("CRONOMETER_USERNAME and CRONOMETER_PASSWORD must be set") } inner := cronoapi.NewClient(nil) - if err := inner.Login(ctx, user, pass); err != nil { - return nil, fmt.Errorf("login failed: %w", err) + c := &Client{inner: inner, user: user, pass: pass} + + if cacheEnabled() { + if cached, err := loadCachedSession(user); err == nil && cached != nil { + inner.Restore(cached.Session) + return c, nil + } + } + if err := c.login(ctx); err != nil { + return nil, err + } + return c, nil +} + +// login performs a fresh Cronometer login and, on success, persists +// the session to the on-disk cache (unless caching is disabled). +func (c *Client) login(ctx context.Context) error { + if err := c.inner.Login(ctx, c.user, c.pass); err != nil { + return fmt.Errorf("login: %w", err) + } + c.fresh = true + if cacheEnabled() { + _ = saveCachedSession(c.user, c.inner.Snapshot()) + } + return nil +} + +// callWithRetry runs fn; if fn fails AND the session was loaded from +// cache (could be stale), it invalidates the cache, performs a fresh +// login, and retries once. Returns the result of the (possibly +// retried) call. +func callWithRetry[T any](c *Client, ctx context.Context, fn func() (T, error)) (T, error) { + out, err := fn() + if err == nil || c.fresh { + return out, err + } + _, _ = DeleteCachedSession() + if rerr := c.login(ctx); rerr != nil { + return out, err // surface the original error, not the re-login one } - return &Client{inner: inner}, nil + return fn() } -// Logout best-effort, suitable for `defer`. +// Logout best-effort, suitable for `defer`. When the cache is in +// play we intentionally do NOT call the network logout — that would +// invalidate the cached session for the next invocation. We only +// tear down the server-side session when caching is disabled. func (c *Client) Logout() { + if cacheEnabled() { + return + } _ = c.inner.Logout(context.Background()) } // Servings returns parsed serving records (one row per food item logged). func (c *Client) Servings(ctx context.Context, rng DateRange) (any, error) { - recs, err := c.inner.ExportServingsParsedWithLocation(ctx, rng.Start, rng.End, time.Local) + recs, err := callWithRetry(c, ctx, func() (cronoapi.ServingRecords, error) { + return c.inner.ExportServingsParsedWithLocation(ctx, rng.Start, rng.End, time.Local) + }) if err != nil { return nil, fmt.Errorf("export servings: %w", err) } @@ -48,7 +109,9 @@ func (c *Client) Servings(ctx context.Context, rng DateRange) (any, error) { // Exercises returns parsed exercise records. func (c *Client) Exercises(ctx context.Context, rng DateRange) (any, error) { - recs, err := c.inner.ExportExercisesParsedWithLocation(ctx, rng.Start, rng.End, time.Local) + recs, err := callWithRetry(c, ctx, func() (cronoapi.ExerciseRecords, error) { + return c.inner.ExportExercisesParsedWithLocation(ctx, rng.Start, rng.End, time.Local) + }) if err != nil { return nil, fmt.Errorf("export exercises: %w", err) } @@ -57,18 +120,23 @@ func (c *Client) Exercises(ctx context.Context, rng DateRange) (any, error) { // Biometrics returns parsed biometric records (weight, body fat, etc.). func (c *Client) Biometrics(ctx context.Context, rng DateRange) (any, error) { - recs, err := c.inner.ExportBiometricRecordsParsedWithLocation(ctx, rng.Start, rng.End, time.Local) + recs, err := callWithRetry(c, ctx, func() (cronoapi.BiometricRecords, error) { + return c.inner.ExportBiometricRecordsParsedWithLocation(ctx, rng.Start, rng.End, time.Local) + }) if err != nil { return nil, fmt.Errorf("export biometrics: %w", err) } return recs, nil } -// Nutrition returns daily-totals nutrition rows. gocronometer does not +// Nutrition returns daily-totals nutrition rows. cronoapi does not // expose a typed parser for this endpoint, so we hand back a list of -// string-keyed objects derived from the raw CSV header. +// string-keyed objects derived from the raw CSV header. Values are +// best-effort coerced (numeric → float64, true/false → bool, empty → null). func (c *Client) Nutrition(ctx context.Context, rng DateRange) (any, error) { - raw, err := c.inner.ExportDailyNutrition(ctx, rng.Start, rng.End) + raw, err := callWithRetry(c, ctx, func() (string, error) { + return c.inner.ExportDailyNutrition(ctx, rng.Start, rng.End) + }) if err != nil { return nil, fmt.Errorf("export nutrition: %w", err) } @@ -79,9 +147,11 @@ func (c *Client) Nutrition(ctx context.Context, rng DateRange) (any, error) { return rows, nil } -// Notes returns user-entered notes. Same string-keyed shape as Nutrition. +// Notes returns user-entered notes. Same shape as Nutrition. func (c *Client) Notes(ctx context.Context, rng DateRange) (any, error) { - raw, err := c.inner.ExportNotes(ctx, rng.Start, rng.End) + raw, err := callWithRetry(c, ctx, func() (string, error) { + return c.inner.ExportNotes(ctx, rng.Start, rng.End) + }) if err != nil { return nil, fmt.Errorf("export notes: %w", err) } @@ -92,7 +162,7 @@ func (c *Client) Notes(ctx context.Context, rng DateRange) (any, error) { return rows, nil } -func csvToJSON(raw string) ([]map[string]string, error) { +func csvToJSON(raw string) ([]map[string]any, error) { r := csv.NewReader(strings.NewReader(raw)) r.FieldsPerRecord = -1 rows, err := r.ReadAll() @@ -100,20 +170,41 @@ func csvToJSON(raw string) ([]map[string]string, error) { return nil, err } if len(rows) == 0 { - return []map[string]string{}, nil + return []map[string]any{}, nil } header := rows[0] - out := make([]map[string]string, 0, len(rows)-1) + out := make([]map[string]any, 0, len(rows)-1) for _, row := range rows[1:] { - obj := make(map[string]string, len(header)) + obj := make(map[string]any, len(header)) for i, col := range header { + var raw string if i < len(row) { - obj[col] = row[i] - } else { - obj[col] = "" + raw = row[i] } + obj[col] = coerceCSVValue(raw) } out = append(out, obj) } return out, nil } + +// coerceCSVValue best-effort-parses a CSV cell into a typed value: empty +// → nil, numeric → float64, true/false → bool, otherwise the original +// string. Keeps the JSON output of /nutrition and /notes consumable +// with jq without forcing a `tonumber` cast on every column. +func coerceCSVValue(s string) any { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return nil + } + if f, err := strconv.ParseFloat(trimmed, 64); err == nil { + return f + } + switch strings.ToLower(trimmed) { + case "true": + return true + case "false": + return false + } + return s +} diff --git a/internal/cronoclient/client_test.go b/internal/cronoclient/client_test.go new file mode 100644 index 0000000..ed5a2a1 --- /dev/null +++ b/internal/cronoclient/client_test.go @@ -0,0 +1,49 @@ +package cronoclient + +import ( + "reflect" + "testing" +) + +func TestCoerceCSVValue(t *testing.T) { + cases := []struct { + in string + want any + }{ + {"", nil}, + {" ", nil}, + {"1.5", 1.5}, + {"100", 100.0}, + {"-3", -3.0}, + {"true", true}, + {"FALSE", false}, + {"Slept well", "Slept well"}, + {"2026-05-04", "2026-05-04"}, + } + for _, c := range cases { + got := coerceCSVValue(c.in) + if !reflect.DeepEqual(got, c.want) { + t.Errorf("coerceCSVValue(%q) = %v (%T), want %v (%T)", + c.in, got, got, c.want, c.want) + } + } +} + +func TestCsvToJSON(t *testing.T) { + raw := "Date,Calories,Protein,Completed\n2026-05-04,1800,90,true\n2026-05-05,,75.5,false\n" + got, err := csvToJSON(raw) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d rows, want 2", len(got)) + } + row0 := got[0] + if row0["Date"] != "2026-05-04" || row0["Calories"] != 1800.0 || row0["Protein"] != 90.0 || row0["Completed"] != true { + t.Errorf("row0 = %+v", row0) + } + row1 := got[1] + if row1["Calories"] != nil || row1["Protein"] != 75.5 || row1["Completed"] != false { + t.Errorf("row1 = %+v", row1) + } +} diff --git a/internal/cronoclient/daterange.go b/internal/cronoclient/daterange.go index 407d5c4..5e08248 100644 --- a/internal/cronoclient/daterange.go +++ b/internal/cronoclient/daterange.go @@ -6,6 +6,7 @@ package cronoclient import ( "fmt" + "os" "strings" "time" @@ -24,6 +25,14 @@ type DateRange struct { End time.Time } +// IsEmpty reports whether the range covers no days. This happens when +// the user supplies an inverted --since/--until pair; we treat it as +// "no records" rather than an error so the contract's exit-0-on-empty +// rule is honored. +func (r DateRange) IsEmpty() bool { + return r.End.Before(r.Start) +} + // AddDateRangeFlags binds --since and --until on cmd. Each subcommand calls // this so they all share the same flag vocabulary, per the quantcli shared // contract: https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags. @@ -65,10 +74,11 @@ func resolveDateRange(sinceStr, untilStr string, ref time.Time) (DateRange, erro until = today } if since.IsZero() { - since = until + // Default since when only --until is given: 7 days ending at --until. + since = until.AddDate(0, 0, -6) } if until.Before(since) { - return DateRange{}, fmt.Errorf("--until (%s) is before --since (%s)", + fmt.Fprintf(os.Stderr, "warning: --until (%s) is before --since (%s); returning empty result\n", until.Format(dateLayout), since.Format(dateLayout)) } return DateRange{Start: since, End: until}, nil diff --git a/internal/cronoclient/daterange_test.go b/internal/cronoclient/daterange_test.go new file mode 100644 index 0000000..2ad0045 --- /dev/null +++ b/internal/cronoclient/daterange_test.go @@ -0,0 +1,73 @@ +package cronoclient + +import ( + "testing" + "time" +) + +func TestResolveDateRange(t *testing.T) { + ref := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) + day := func(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } + + cases := []struct { + name string + since, until string + wantStart, wantEnd time.Time + wantEmpty bool + }{ + { + name: "both empty defaults to 7d ending today", + wantStart: day(2026, 5, 12), + wantEnd: day(2026, 5, 18), + }, + { + name: "since only defaults until to today", + since: "2026-05-15", + wantStart: day(2026, 5, 15), + wantEnd: day(2026, 5, 18), + }, + { + name: "until only defaults since to 7d before until (#18)", + until: "2026-05-15", + wantStart: day(2026, 5, 9), + wantEnd: day(2026, 5, 15), + }, + { + name: "today / yesterday", + since: "yesterday", + until: "today", + wantStart: day(2026, 5, 17), + wantEnd: day(2026, 5, 18), + }, + { + name: "relative 7d", + since: "7d", + wantStart: day(2026, 5, 11), + wantEnd: day(2026, 5, 18), + }, + { + name: "inverted range returns empty (#21)", + since: "today", + until: "yesterday", + wantStart: day(2026, 5, 18), + wantEnd: day(2026, 5, 17), + wantEmpty: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := resolveDateRange(c.since, c.until, ref) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got.Start.Equal(c.wantStart) || !got.End.Equal(c.wantEnd) { + t.Errorf("got Start=%s End=%s, want Start=%s End=%s", + got.Start.Format(dateLayout), got.End.Format(dateLayout), + c.wantStart.Format(dateLayout), c.wantEnd.Format(dateLayout)) + } + if got.IsEmpty() != c.wantEmpty { + t.Errorf("IsEmpty()=%v, want %v", got.IsEmpty(), c.wantEmpty) + } + }) + } +} diff --git a/internal/cronoclient/session.go b/internal/cronoclient/session.go new file mode 100644 index 0000000..2dad12f --- /dev/null +++ b/internal/cronoclient/session.go @@ -0,0 +1,130 @@ +package cronoclient + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/quantcli/crono-export-cli/internal/cronoapi" +) + +// cacheSchemaVersion is bumped whenever the on-disk session shape +// changes incompatibly. An older cache is silently ignored (treated +// as a miss) so existing users transparently re-login on upgrade. +const cacheSchemaVersion = 1 + +// cachedSession is the on-disk representation of a Cronometer login. +// We key by username so flipping CRONOMETER_USERNAME invalidates the +// cache automatically instead of replaying another user's session. +type cachedSession struct { + Version int `json:"version"` + Username string `json:"username"` + Session cronoapi.Session `json:"session"` + SavedAt time.Time `json:"saved_at"` + UserAgent string `json:"user_agent,omitempty"` +} + +// sessionCachePath returns the path to the persisted session file, +// rooted at the OS user cache dir (respects XDG_CACHE_HOME on Linux, +// ~/Library/Caches on macOS, %LocalAppData% on Windows). +func sessionCachePath() (string, error) { + base, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(base, "crono-export", "session.json"), nil +} + +// cacheEnabled reports whether we should read/write the session cache. +// Set CRONOMETER_NO_CACHE=1 (or any non-empty value) to force per-call +// login. +func cacheEnabled() bool { + return os.Getenv("CRONOMETER_NO_CACHE") == "" +} + +// loadCachedSession returns the persisted session for user, or +// (nil, nil) if no usable cache exists. Errors are only returned for +// genuine I/O surprises — a missing file or a user mismatch is +// silently treated as "no cache" so callers fall through to a fresh +// login. +func loadCachedSession(user string) (*cachedSession, error) { + p, err := sessionCachePath() + if err != nil { + return nil, nil + } + data, err := os.ReadFile(p) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + var s cachedSession + if err := json.Unmarshal(data, &s); err != nil { + return nil, nil + } + if s.Version != cacheSchemaVersion { + return nil, nil + } + if s.Username != user { + return nil, nil + } + if s.Session.AuthToken == "" { + return nil, nil + } + return &s, nil +} + +// saveCachedSession writes snap to disk with mode 0600. Best-effort — +// errors are returned for callers that care to log them but never block +// the export. +func saveCachedSession(user string, snap cronoapi.Session) error { + p, err := sessionCachePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + data, err := json.Marshal(cachedSession{ + Version: cacheSchemaVersion, + Username: user, + Session: snap, + SavedAt: time.Now(), + }) + if err != nil { + return err + } + return os.WriteFile(p, data, 0o600) +} + +// DeleteCachedSession removes the on-disk session file. Exported so +// the `auth logout` subcommand can invoke it. Returns nil if the +// cache didn't exist. +func DeleteCachedSession() (string, error) { + p, err := sessionCachePath() + if err != nil { + return "", err + } + if err := os.Remove(p); err != nil { + if errors.Is(err, os.ErrNotExist) { + return p, nil + } + return p, fmt.Errorf("delete session cache: %w", err) + } + return p, nil +} + +// SessionCachePath returns the cache location for display purposes +// (e.g. by `auth status`). Empty string if the OS cache dir is +// undiscoverable. +func SessionCachePath() string { + p, err := sessionCachePath() + if err != nil { + return "" + } + return p +} diff --git a/internal/cronoclient/session_test.go b/internal/cronoclient/session_test.go new file mode 100644 index 0000000..890e0c3 --- /dev/null +++ b/internal/cronoclient/session_test.go @@ -0,0 +1,95 @@ +package cronoclient + +import ( + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/quantcli/crono-export-cli/internal/cronoapi" +) + +func TestSessionCacheRoundTrip(t *testing.T) { + // Redirect the cache to a temp dir so we don't clobber the real one. + tmp := t.TempDir() + t.Setenv("XDG_CACHE_HOME", tmp) + // macOS UserCacheDir ignores XDG_CACHE_HOME; on platforms where it + // reads HOME instead, also point HOME at the temp dir. + t.Setenv("HOME", tmp) + + user := "alice@example.com" + snap := cronoapi.Session{ + UserID: 42, + AuthToken: "0123456789abcdef0123456789abcdef", + Cookies: []*http.Cookie{ + {Name: "JSESSIONID", Value: "abc", Path: "/"}, + {Name: "PHPSESSID", Value: "def", Path: "/"}, + }, + } + + if err := saveCachedSession(user, snap); err != nil { + t.Fatalf("save: %v", err) + } + got, err := loadCachedSession(user) + if err != nil { + t.Fatalf("load: %v", err) + } + if got == nil { + t.Fatal("load returned nil cache after save") + } + if got.Session.UserID != snap.UserID || got.Session.AuthToken != snap.AuthToken { + t.Errorf("session mismatch: got %+v, want %+v", got.Session, snap) + } + if len(got.Session.Cookies) != 2 { + t.Errorf("cookies: got %d, want 2", len(got.Session.Cookies)) + } + + // Different user → cache miss, not error. + missed, err := loadCachedSession("someone-else@example.com") + if err != nil { + t.Fatalf("load other user: %v", err) + } + if missed != nil { + t.Error("expected nil cache for different user") + } + + // Delete clears it. + if _, err := DeleteCachedSession(); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := os.Stat(SessionCachePath()); !os.IsNotExist(err) { + t.Errorf("cache file still exists after delete: %v", err) + } + // Delete on missing cache is a no-op. + if _, err := DeleteCachedSession(); err != nil { + t.Errorf("delete-when-missing should be no-op, got %v", err) + } +} + +// TestSessionCacheVersionMismatch confirms that a cache written under +// a different schema version is silently treated as a miss, so a +// future incompatible bump triggers a transparent re-login instead of +// a JSON-shape error. +func TestSessionCacheVersionMismatch(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CACHE_HOME", tmp) + t.Setenv("HOME", tmp) + + p := SessionCachePath() + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) + } + // Hand-written cache pretending to be a future schema version. + body := `{"version":999,"username":"alice@example.com","session":{"user_id":1,"auth_token":"deadbeef","cookies":null}}` + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + got, err := loadCachedSession("alice@example.com") + if err != nil { + t.Fatalf("load: %v", err) + } + if got != nil { + t.Errorf("expected nil cache for mismatched version, got %+v", got) + } +}