Skip to content
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,32 @@ jobs:
# Path the compat-tagged test reads from os.Getenv.
LIFTOFF_EXPORT_BIN: /tmp/liftoff-export
run: go test -tags=compat -run TestContractFormats ./...

artifacts:
# Cross-compile review binaries for the platforms reviewers actually use.
# Goreleaser owns tagged releases; this job exists so a PR's diff can be
# tried end-to-end without a local Go toolchain — download from the run
# page, chmod +x, run. Retention is short (14d) to keep storage tidy.
name: review binaries
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: cross-compile
run: |
mkdir -p dist
for target in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64; do
os="${target%/*}"; arch="${target#*/}"
GOOS=$os GOARCH=$arch go build -trimpath -ldflags="-s -w" \
-o "dist/liftoff-export-${os}-${arch}" .
done
ls -lh dist/
- uses: actions/upload-artifact@v4
with:
name: liftoff-export
path: dist/
retention-days: 14
if-no-files-found: error
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,19 @@ liftoff-export bodyweights stats # Stats with monthly graph an
liftoff-export bodyweights stats --since 2025-01-01
```

### Routines

Routines are reusable workout templates saved in the Liftoff app (the upstream API calls them "presets"; the JSON output preserves that naming):

```sh
liftoff-export routines list # List all your saved routines (fitdown)
liftoff-export routines list --format json # Full JSON for jq / agents
liftoff-export routines show Push # One routine by name (case-insensitive)
liftoff-export routines show cmkbk9ugu0eej3pv0oyd41x8c # …or by id
```

Folder-organized routines are not rendered yet — file an issue if you need folder support.

## Output Format

Workouts are printed in [fitdown](https://github.com/datavis-tech/fitdown) format by default:
Expand Down
8 changes: 8 additions & 0 deletions cmd/prime.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ SUBCOMMANDS
Filters: --exercise NAME, --detail
bodyweights list Recorded bodyweights, one per line
bodyweights stats Current/high/low + monthly trend + plateau
routines list Saved workout routines (fitdown notation)
routines show NAME-OR-ID One routine by name (case-insensitive) or id

Inspect any subcommand's row schema with: <subcommand> --since 1d --format json

Expand All @@ -38,6 +40,8 @@ EXAMPLES
jq '.[] | select(.type == "WR") | {name, vol: ([.sessions[].volume] | add)}'
liftoff-export bodyweights list --since 90d --format json |
jq '[.[]] | (.[-1].weight - .[0].weight)'
liftoff-export routines list --format json |
jq '[.[] | {name, exCount: (.exerciseData|length)}]'

GOTCHAS
- Workout dates are LOCAL — 11pm workouts bucket on the day you logged them.
Expand All @@ -47,6 +51,10 @@ GOTCHAS
workout). No workout that day means no bodyweight that day.
- 'workouts stats' bins exercises by name. Renaming an exercise in
Liftoff splits it into two summaries.
- 'routines' are what Liftoff calls "presets" internally; the JSON
preserves that naming (id, name, exerciseData, isFavorite, etc.).
Folder-organized routines aren't rendered yet — file an issue if you
organize routines into folders.
`

var primeCmd = &cobra.Command{
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ func Execute() {
func init() {
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(bodyweightsCmd)
rootCmd.AddCommand(routinesCmd)
rootCmd.AddCommand(workoutsCmd)
}
310 changes: 310 additions & 0 deletions cmd/routines.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
package cmd

import (
"encoding/json"
"fmt"
"io"
"os"
"strings"

"github.com/quantcli/liftoff-export-cli/internal/client"
"github.com/spf13/cobra"
)

// Preset mirrors a Liftoff fitnessService preset (what the app calls a saved
// workout template; users call them "routines"). The field names match the
// upstream JSON so `--format json | jq` reads naturally against API docs.
type Preset struct {
ID string `json:"id"`
CreatedAt string `json:"createdAt"`
UserID string `json:"userId"`
Name string `json:"name"`
Image *string `json:"image"`
MarketPresetID *string `json:"marketPresetId"`
BookmarkID string `json:"bookmarkId"`
Completed int `json:"completed"`
AvgDuration int `json:"avgDuration"`
IsFavorite bool `json:"isFavorite"`
FolderID *string `json:"folderId"`
ExerciseData []PresetExerciseData `json:"exerciseData"`
}

// PresetExerciseData mirrors an entry in a preset's exerciseData array.
// Shape parallels workouts.ExerciseData, with the additional preset linkage
// fields (presetCuid, exerciseDataId on sets).
type PresetExerciseData struct {
ID string `json:"id"`
PresetCUID string `json:"presetCuid"`
ExerciseIndex int `json:"exerciseIndex"`
ExerciseName string `json:"exerciseName"`
ExerciseID string `json:"exerciseId"`
ExerciseTypes string `json:"exerciseTypes"`
Superset *string `json:"superset"`
OverrideWeightUnit *string `json:"overrideWeightUnit"`
ExerciseCUID *string `json:"exerciseCuid"`
MarketExerciseCUID *string `json:"marketExerciseCuid"`
ExerciseNotes *string `json:"exerciseNotes"`
SetsData []PresetSetData `json:"setsData"`
}

type PresetSetData struct {
ID string `json:"id"`
ExerciseDataID string `json:"exerciseDataId"`
SetIndex int `json:"setIndex"`
SetType string `json:"setType"`
InputOne json.Number `json:"inputOne"`
InputTwo json.Number `json:"inputTwo"`
}

// Folder is a Liftoff routine folder — a labeled group of presets in the
// app's Routines tab. The nested Presets array contains the full Preset
// objects (each with folderId pointing back to this folder).
type Folder struct {
ID string `json:"id"`
CreatedAt string `json:"createdAt"`
UserID string `json:"userId"`
Name string `json:"name"`
PresetsOrder []string `json:"presetsOrder"`
Presets []Preset `json:"presets"`
}

// presetsResponse is the top-level shape of fitnessService.fetchUserPresetsWithFolders.
// PresetsWithoutFolder are what the Liftoff app surfaces under "My Routines"
// (the implicit ungrouped section); folder Presets are surfaced under their
// folder name. Both rendering paths share the same Preset shape.
type presetsResponse struct {
Folders []Folder `json:"folders"`
PresetsWithoutFolder []Preset `json:"presetsWithoutFolder"`
}

// unfiledLabel is the section title we use for PresetsWithoutFolder in
// markdown output. Matches the Liftoff app's UI label so a user comparing
// terminal output to the app sees the same heading.
const unfiledLabel = "My Routines"

var routinesCmd = &cobra.Command{
Use: "routines",
Short: "Saved workout routine (preset) commands",
Long: `Routines are reusable workout templates saved in the Liftoff app.
The upstream API calls them "presets"; the JSON output preserves that
naming. The fitdown markdown renderer treats a routine the same way it
treats a logged workout.`,
}

var routinesListFormatFlag string

var routinesListCmd = &cobra.Command{
Use: "list",
Short: "List all your saved routines",
RunE: func(cmd *cobra.Command, args []string) error {
format, err := validateFormat(routinesListFormatFlag)
if err != nil {
return err
}
resp, err := fetchPresets()
if err != nil {
return err
}
if format == "json" {
return printJSON(resp)
}
return renderRoutinesFitdown(os.Stdout, resp)
},
}

var routinesShowFormatFlag string

var routinesShowCmd = &cobra.Command{
Use: "show <name-or-id>",
Short: "Show one routine by name (case-insensitive exact match) or by id",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
format, err := validateFormat(routinesShowFormatFlag)
if err != nil {
return err
}
resp, err := fetchPresets()
if err != nil {
return err
}
match, err := pickPreset(resp, args[0])
if err != nil {
return err
}
if format == "json" {
return printJSON([]Preset{*match})
}
// `show` emits a single routine without any section heading —
// the routine name is enough context for a one-off lookup.
return renderOnePreset(os.Stdout, *match, "#")
},
}

func fetchPresets() (*presetsResponse, error) {
c := client.New()
var resp presetsResponse
if err := c.Query("fitnessService.fetchUserPresetsWithFolders", nil, &resp); err != nil {
return nil, err
}
// Normalize nil → empty slice so --format json emits `[]` not `null`,
// matching what the upstream API does on an empty account.
if resp.Folders == nil {
resp.Folders = []Folder{}
}
if resp.PresetsWithoutFolder == nil {
resp.PresetsWithoutFolder = []Preset{}
}
return &resp, nil
}

// allPresets flattens both unfiled and foldered presets into one slice for
// lookup. Used by pickPreset so `routines show "Valley Creek 1"` finds a
// routine regardless of whether it lives in a folder.
func allPresets(resp *presetsResponse) []Preset {
out := make([]Preset, 0, len(resp.PresetsWithoutFolder))
out = append(out, resp.PresetsWithoutFolder...)
for _, f := range resp.Folders {
out = append(out, f.Presets...)
}
return out
}

// pickPreset resolves a `show` argument to exactly one preset across the
// full account (unfiled + foldered). Match order: (1) case-insensitive
// exact name match, (2) exact id match. Multiple name-matches return an
// error so the caller can disambiguate by id.
func pickPreset(resp *presetsResponse, arg string) (*Preset, error) {
presets := allPresets(resp)
target := strings.ToLower(strings.TrimSpace(arg))
var nameHits []Preset
for _, p := range presets {
if strings.ToLower(p.Name) == target {
nameHits = append(nameHits, p)
}
}
if len(nameHits) == 1 {
return &nameHits[0], nil
}
if len(nameHits) > 1 {
ids := make([]string, 0, len(nameHits))
for _, p := range nameHits {
ids = append(ids, p.ID)
}
return nil, fmt.Errorf("multiple routines named %q — disambiguate by id: %s", arg, strings.Join(ids, ", "))
}
for i := range presets {
if presets[i].ID == arg {
return &presets[i], nil
}
}
return nil, fmt.Errorf("no routine matches %q", arg)
}

// renderRoutinesFitdown renders the full account: a "# My Routines" H1 with
// each unfiled preset as an H2 underneath, then "# <FolderName>" H1 per
// folder with its foldered presets as H2. "---" rules separate sibling
// routines and section boundaries. Favorite routines are marked with a
// star; per-exercise notes are appended in parens so cues like "Left Only"
// aren't rendered as a markdown heading by mistake.
func renderRoutinesFitdown(w io.Writer, resp *presetsResponse) error {
first := true
emitSection := func(heading string, presets []Preset) {
if len(presets) == 0 {
return
}
if !first {
fmt.Fprintln(w)
fmt.Fprintln(w, "---")
fmt.Fprintln(w)
}
first = false
fmt.Fprintf(w, "# %s\n", heading)
for i, p := range presets {
if i > 0 {
fmt.Fprintln(w)
fmt.Fprintln(w, "---")
}
fmt.Fprintln(w)
renderOnePreset(w, p, "##")
}
}
emitSection(unfiledLabel, resp.PresetsWithoutFolder)
for _, f := range resp.Folders {
emitSection(f.Name, f.Presets)
}
return nil
}

// renderOnePreset prints a single preset under the given heading marker
// (e.g. "##" inside a folder section, "#" for `routines show` where the
// routine has no enclosing section). Body format (exercises, set lines,
// note parens) is identical regardless of caller.
func renderOnePreset(w io.Writer, p Preset, headingMarker string) error {
star := ""
if p.IsFavorite {
star = " ★"
}
fmt.Fprintf(w, "%s Routine: %s%s\n", headingMarker, p.Name, star)
for _, ex := range p.ExerciseData {
fmt.Fprintln(w)
name := ex.ExerciseName
if ex.ExerciseNotes != nil && *ex.ExerciseNotes != "" {
name = fmt.Sprintf("%s (%s)", name, *ex.ExerciseNotes)
}
fmt.Fprintln(w, name)
var lines []string
for _, s := range ex.SetsData {
lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s))
}
// Compress consecutive identical lines into Nx... notation.
for i := 0; i < len(lines); {
j := i + 1
for j < len(lines) && lines[j] == lines[i] {
j++
}
if n := j - i; n > 1 {
fmt.Fprintf(w, "%dx%s\n", n, lines[i])
} else {
fmt.Fprintln(w, lines[i])
}
i = j
}
}
return nil
}

// fitdownSetLine is split out of workouts.printFitdown so routines can render
// the same notation without duplicating the type switch. Keeping it here
// rather than in workouts.go keeps the workouts file untouched, at the cost
// of a tiny bit of duplication of the WR/BR/AB/WD/DD/ND mapping.
func fitdownSetLine(exTypes string, s PresetSetData) string {
switch exTypes {
case "WR":
return fmt.Sprintf("%s@%s", s.InputTwo, s.InputOne)
case "AB":
return fmt.Sprintf("%s@-%s", s.InputTwo, s.InputOne)
case "BR":
return fmt.Sprintf("%s@+%s", s.InputTwo, s.InputOne)
case "WD":
km, _ := s.InputTwo.Float64()
return fmt.Sprintf("%slb %.3fmi", s.InputOne, km/1.60934)
case "DD":
secs, _ := s.InputTwo.Int64()
km, _ := s.InputOne.Float64()
return fmt.Sprintf("%.2fmi %d:%02d", km/1.60934, secs/60, secs%60)
case "ND":
secs, _ := s.InputTwo.Int64()
return fmt.Sprintf("%d:%02d", secs/60, secs%60)
default:
return fmt.Sprintf("[%s] %s %s", exTypes, s.InputOne, s.InputTwo)
}
}

func init() {
routinesCmd.AddCommand(routinesListCmd)
routinesCmd.AddCommand(routinesShowCmd)
routinesListCmd.Flags().StringVar(&routinesListFormatFlag, "format", "markdown",
"Output format: markdown (default, fitdown-style) or json")
routinesShowCmd.Flags().StringVar(&routinesShowFormatFlag, "format", "markdown",
"Output format: markdown (default, fitdown-style) or json")
}
Loading
Loading