Skip to content

Instantly share code, notes, and snippets.

@rofinn
Created January 17, 2026 02:23
Show Gist options
  • Select an option

  • Save rofinn/a8bbec128a77b6725eecf05e62ea7b4d to your computer and use it in GitHub Desktop.

Select an option

Save rofinn/a8bbec128a77b6725eecf05e62ea7b4d to your computer and use it in GitHub Desktop.
Convert Jeff Nippard workout spreadsheets to MacroFactor Workouts app format.
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pandas",
# "openpyxl",
# "click",
# ]
# ///
"""
nippard2workouts.py
Convert Jeff Nippard workout spreadsheets to MacroFactor Workouts app format.
DESCRIPTION
Parses Jeff Nippard workout program spreadsheets and converts them to format compatible with MacroFactor Workouts.
Despite the advertising, only some of the Jeff Nippard programs are supported out of the box (link below).
It handles exercise name mapping, week/workout organization, and generates properly formatted output files.
Currently, tested with the "Fundamentals Hypertrophy Program" (.xlsx file) which places all workouts in a single block/cycle
due to limitations of the Workouts app.
REQUIREMENTS
- Python 3.11+
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
USAGE
Help:
```sh
> uv run nippard2workouts.py --help
Installed 9 packages in 116ms
Usage: nippard2workouts.py [OPTIONS] INPUT_FILE
Convert Jeff Nippard workout spreadsheets to MacroFactor Workouts format.
INPUT_FILE: Path to the input Excel file (ex
'./Fundamentals_Hypertrophy_Program_Excel_Tracking_Sheet.xlsx')
Options:
-o, --output PATH Output file path. Defaults to <program>.xlsx in
the local directory (ex
'./Fundamentals__Full_Body.xlsx')
-m, --mapping PATH JSON mapping file to add or override the default
mapping. Contains a single object where keys =
program names and values = app names.
-l, --location TEXT Gym/location name for workout labels
-p, --program TEXT Name used within the output file and also the
default for the output file path. (ex
'Fundamentals: Full Body')
-s, --sheet TEXT Sheet name to use or prompts if not provided (ex
'Full Body')
--ordering TEXT Weekly workout ordering where 0=rest, 1=workout.
(ex 01010100 for 3 workouts/week)
--naming [full|condensed] Naming convention: 'full' (Week 1: Name) or
'condensed' (W1: Name)
--help Show this message and exit.
```
Interactive:
```sh
> uv run nippard2workouts.py ~/Downloads/Fundamentals_Hypertrophy_Program_Excel_Tracking_Sheet.xlsx
Gym/location name [Gym]:
Available programs:
1. Body Part Split
2. Upper Lower
3. Full Body
Select program number: 3
Parsing workout data from 'Full Body'...
Found 168 exercises across 8 weeks
Found 3 workout types per week:
- Full Body #1
- Full Body #3
- Full Body #2
Program name [Full Body]:
Using program name: Full Body
Output file path [/Users/rory/Downloads/Full_Body.xlsx]:
Output written to: /Users/rory/Downloads/Full_Body.xlsx
Conversion complete!
```
Non-interactive:
> uv run nippard2workouts.py ~/Downloads/Fundamentals_Hypertrophy_Program_Excel_Tracking_Sheet.xlsx \
--location "My Gym" \
--sheet "Full Body" \
--program "Fundamentals: Full Body" \
--naming condensed \
--ordering 0101001 \
--mapping overrides.json \
--output ./fundamentals-full-body.xlsx
Parsing workout data from 'Full Body'...
Found 168 exercises across 8 weeks
Found 3 workout types per week:
- Full Body #3
- Full Body #2
- Full Body #1
Loaded 1 mapping overrides from overrides.json
Using program name: Fundamentals: Full Body
Output written to: fundamentals-full-body.xlsx
Conversion complete!
```
LINKS
- https://jeffnippard.com/products/fundamentals-hypertrophy-program
- https://help.macrofactorapp.com/en/collections/20-macrofactor-workouts
#heading-4-troubleshooting-import-issues
- https://help.macrofactorapp.com/en/articles/287-how-to-import-a-workout-or-program
"""
import json
import re
from pathlib import Path
import click
import pandas as pd
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font
# Default exercise name mapping (input name -> app name)
DEFAULT_EXERCISE_MAPPING = {
"BARBELL BENCH PRESS": "Barbell Bench Press",
"DUMBBELL INCLINE PRESS": "45° Incline Dumbbell Press",
"CABLE FLYE": "Horizontal Cable Fly",
"ASSISTED DIP": "Pin-Loaded Machine Assisted Dip",
"DUMBBELL SKULL CRUSHER": "Lying Dumbbell Triceps Extension",
"BACK SQUAT": "Barbell Back Squat",
"ROMANIAN DEADLIFT": "Barbell Romanian Deadlift",
"BARBELL HIP THRUST": "Plate-Loaded Machine Hip Thrust (Starting From The Top)",
"LEG EXTENSION": "Pin-Loaded Machine Leg Extension",
"LEG CURL": "Seated Pin-Loaded Machine Hamstring Curl",
"STANDING CALF RAISE": "Standing Pin-Loaded Machine Calf Raise",
"CRUNCH": "Weighted Crunch (Holding Weight Overhead)",
"REVERSE GRIP LAT PULLDOWN": "Underhand Grip Cable Lat Pulldown",
"CABLE SEATED ROW": "Seated Neutral Grip Cable Row",
"CHEST-SUPPORTED T-BAR ROW": "Chest-Supported Neutral Grip T-Bar Row",
"SEATED FACE PULL": "Seated Cable Face Pull",
"DUMBBELL SUPINATED CURL": "Seated Dumbbell Biceps Curl",
"DEADLIFT": "Conventional Deadlift",
"DUMBBELL WALKING LUNGE": "Dumbbell Walking Lunge",
"SINGLE-LEG LEG EXTENSION": "Single Leg Pin-Loaded Machine Leg Extension",
"SINGLE-LEG LYING LEG CURL": "Lying Single Leg Pin-Loaded Machine Hamstring Curl",
"MACHINE SEATED HIP ABDUCTION": "Seated Pin-Loaded Machine Hip Abduction",
"PLANK": "Plank",
"OVERHEAD PRESS": "Seated Dumbbell Overhead Press",
"DUMBBELL LATERAL RAISE": "Standing Dumbbell Lateral Raise",
"CABLE REVERSE FLYE": "Neutral Grip Cable Reverse Fly",
"SINGLE-ARM ROPE TRICEP EXTENSION": "Single Arm Neutral Grip Cable Triceps Pushdown",
"SINGLE-ARM CABLE CURL": "Single Arm Bayesian Curl",
"MACHINE INCLINE CHEST PRESS": "Seated Pin-Loaded Machine Incline Press",
"PEC DECK": "Neutral Grip Machine Fly",
"CABLE TRICEP KICKBACK": "Neutral Grip Cable Triceps Kickback",
"GOBLET SQUAT": "Goblet Squat",
"DUMBBELL SINGLE-LEG HIP THRUST": "Single Leg Dumbbell Hip Thrust",
"LEG PRESS": "Plate-Loaded Leg Press",
"LYING LEG CURL": "Lying Pin-Loaded Machine Hamstring Curl",
"BICYCLE CRUNCH": "Bicycle Crunch",
"LAT PULLDOWN": "Overhand Grip Cable Lat Pulldown",
"DUMBBELL ROW": "Single Arm Elbow-In Dumbbell Row",
"BARBELL BENT OVER ROW": "Bent-Over Overhand Grip Barbell Row",
"REVERSE PEC DECK": "Neutral Grip Machine Reverse Fly",
"EZ BAR CURL": "Ez Bar Biceps Curl",
"SEATED LEG CURL": "Seated Pin-Loaded Machine Hamstring Curl",
"HANGING LEG RAISE": "Hanging Straight Knee Leg Raise",
"DUMBBELL SEATED SHOULDER PRESS": "Seated Dumbbell Overhead Press",
"CABLE LATERAL RAISE": "Dual Cable Lateral Raise",
"BENT OVER REVERSE DUMBBELL FLYE": "Bent-Over Neutral Grip Dumbbell Reverse Fly",
"DUMBBELL FLOOR PRESS": "Dumbbell Floor Press",
"HAMMER CURL": "Standing Dumbbell Hammer Curl",
"SINGLE-ARM PULLDOWN": "Half-kneeling Single Arm Cable Lat Pulldown",
"CLOSE-GRIP BENCH PRESS": "Close Grip Bench Press",
"NEUTRAL-GRIP PULLDOWN": "Overhand Grip Cable Lat Pulldown",
}
def load_input(ctx, param, value):
"""Load the Excel file and store in context for other callbacks."""
xlsx = pd.ExcelFile(value)
ctx.ensure_object(dict)
ctx.obj["xlsx"] = xlsx
ctx.obj["input_file"] = value
return value
def get_sheet(ctx, param, value):
"""Extract a sole/requested sheet if it exists or prompt user to select one."""
xlsx = ctx.obj["xlsx"]
sheets = xlsx.sheet_names
if value is not None:
if value not in sheets:
raise click.BadParameter(
f"'{value}' not found. Available: {sheets}"
)
return value
# Auto-select if only one sheet
if len(sheets) == 1:
click.echo(f"Using sheet: {sheets[0]}")
return sheets[0]
# Prompt user to select
click.echo("\nAvailable programs:")
for i, sheet in enumerate(sheets, 1):
click.echo(f" {i}. {sheet}")
while True:
choice = click.prompt("\nSelect program number", type=int)
if 1 <= choice <= len(sheets):
return sheets[choice - 1]
click.echo("Invalid selection. Please enter a valid number.")
def extract_workouts(df: pd.DataFrame) -> list[dict]:
"""Extract the input dataframe workouts into a standard list of dicts."""
assert df.shape[1] == 10
week = None
workout = None
results = []
# Iterating row-wise isn't very efficient, but we aren't processing a lot of rows.
for row in df.itertuples(index=False, name=None):
# If we hit a header then just update the week
if row[2:10] == ('Workout', 'Exercise', 'Working Sets', 'Reps / Duration', 'Load (kg)', 'RPE', 'Rest', 'Notes'):
_week = str(row[1])
assert _week.startswith("Week")
week = _week
continue
# If the workout column is not nan then update our current workout
if row[2] and isinstance(row[2], str):
workout = row[2]
exercise = row[3] if row[3] and isinstance(row[3], str) else None
# Finally process exercise rows if we have a set week, workout and exercise
if week and workout and exercise:
entry = {
"week": week,
"workout": workout,
"exercise": exercise,
"sets": row[4] if row[4] and isinstance(row[4], int) else None,
"reps": row[5] if row[5] and isinstance(row[5], int) else None,
"rpe": row[7] if row[7] and isinstance(row[7], int) else None,
"rest": row[8] if row[8] and isinstance(row[8], str) else None,
"notes": row[9] if row[9] and isinstance(row[9], str) else None,
}
# Add entry to results if sets and reps exist
if entry['sets'] and entry['reps']:
results.append(entry)
return results
def parse_rest_range(rest_str: str | None) -> int | None:
"""Parse rest range string and return lowest value in seconds."""
if rest_str:
for match in (re.match(r, rest_str) for r in [r"(\d+)-(\d+)\s*min", r"(\d+)\s*min"]):
if match:
return int(match.group(1)) * 60
return None
def validate_ordering(ctx, param, value):
"""Validate the ordering string is 7 digits of 0s and 1s."""
if value and (len(value) != 7 or not all(c in "01" for c in value)):
raise click.BadParameter(
"Must be exactly 7 digits of 0s and 1s (e.g. 0101010)"
)
return value
def build_header_rows(program_name: str) -> list[list]:
"""Build the program, block, and cycle header rows."""
return [
[
f"Program: {program_name}",
"Cycles: 1",
"Deload: None",
"Color: Yellow",
"Icon: Extension",
] + [None] * 10,
["Block 1"] + [None] * 14,
[
"Cycle 1",
"Exercise",
"Notes",
"Set 1 Type",
"Set 1 Rep Range",
"Set 1 RIR",
"Set 1 Rest",
"Set 2 Type",
"Set 2 Rep Range",
"Set 2 RIR",
"Set 2 Rest",
"Set 3 Type",
"Set 3 Rep Range",
"Set 3 RIR",
"Set 3 Rest",
],
]
def build_workout_rows(
week_num: int,
workout_name: str,
exercises: list[dict],
mapping: dict[str, str],
location: str,
naming: str,
) -> list[list]:
"""Build output rows for a single workout."""
week_str = f"W{week_num}" if naming == "condensed" else f"Week {week_num}"
full_name = f"{week_str}: {workout_name} @ {location}"
rows = []
for i, ex in enumerate(exercises):
mapped_exercise = mapping.get(ex["exercise"], ex["exercise"])
rir = (10 - ex["rpe"]) if ex["rpe"] else None
rest_seconds = parse_rest_range(ex["rest"])
reps = ex["reps"]
if not reps:
rep_range = ""
elif "sec" in str(reps).lower():
rep_range = reps
else:
rep_range = f"{reps} - {reps}"
# Initialize set data with None, then fill in actual sets
set_data = [None] * 12
for n in range(min(ex["sets"] or 0, 3)):
j = n * 4
set_data[j:j + 4] = ["Standard Set", rep_range, rir, rest_seconds]
row = [full_name if i == 0 else None, mapped_exercise, None] + set_data
rows.append(row)
return rows
def build_output_data(
workouts: list[dict],
mapping: dict[str, str],
program_name: str,
ordering: str | None,
location: str,
naming: str,
) -> list[list]:
"""Build the output data structure for the Excel file."""
# Filter out exercises without mappings
exercises = set(w["exercise"] for w in workouts)
dropped = exercises.difference(mapping)
if dropped:
click.echo(
click.style(
f"\nWarning: Dropping {len(dropped)} unmapped exercises:",
fg="yellow",
)
)
for ex in sorted(dropped):
click.echo(click.style(f" - {ex}", fg="yellow"))
workouts = [w for w in workouts if w["exercise"] not in dropped]
weeks = list(dict.fromkeys(w["week"] for w in workouts)) # preserve order
rows = build_header_rows(program_name)
for week_idx, week in enumerate(weeks):
week_num = week_idx + 1
week_workouts = [w for w in workouts if w["week"] == week]
# Group exercises by workout name (preserving order)
workout_groups: dict[str, list[dict]] = {}
for w in week_workouts:
workout_groups.setdefault(w["workout"], []).append(w)
workout_names = list(workout_groups.keys())
week_ordering = ordering if ordering else "1" * len(workout_names)
workout_idx = 0
for day in week_ordering:
if day == "0":
rows.append(["Rest"] + [None] * 14)
elif workout_idx < len(workout_names):
name = workout_names[workout_idx]
rows.extend(build_workout_rows(
week_num, name, workout_groups[name], mapping, location, naming
))
workout_idx += 1
else:
rows.append(["Rest"] + [None] * 14)
return rows
def write_output(rows: list[list], output_path: Path) -> None:
"""Write the output Excel file with formatting."""
wb = Workbook()
ws = wb.active
ws.title = "Training Programs"
top_align = Alignment(vertical="top")
merge_start = None
def is_workout_name(value) -> bool:
"""Check if value is a workout name (Week X: ... or WX: ...)."""
return isinstance(value, str) and ":" in value and (
value.startswith("Week ") or value.startswith("W")
)
def merge_block(start: int, end: int) -> None:
"""Merge cells in column A from start to end row."""
if end > start:
ws.merge_cells(start_row=start, start_column=1,
end_row=end, end_column=1)
ws.cell(start, 1).alignment = top_align
for row_idx, row in enumerate(rows, 1):
# Write row values
for col_idx, value in enumerate(row, 1):
cell = ws.cell(row=row_idx, column=col_idx)
if isinstance(value, (int, float)) and not isinstance(value, bool):
cell.value = float(value)
else:
cell.value = value
# Track merge blocks for column A
col_a = row[0] if row else None
if is_workout_name(col_a):
if merge_start:
merge_block(merge_start, row_idx - 1)
merge_start = row_idx
elif merge_start and col_a is not None:
merge_block(merge_start, row_idx - 1)
merge_start = None
# Merge final block
if merge_start:
merge_block(merge_start, len(rows))
# Bold header row
bold_font = Font(bold=True)
for cell in ws[1]:
cell.font = bold_font
wb.save(output_path)
click.echo(click.style(f"\nOutput written to: {output_path}", fg="green"))
@click.command()
@click.argument(
"input_file",
type=click.Path(exists=True, path_type=Path),
callback=load_input,
is_eager=True,
)
@click.option(
"-o",
"--output",
type=click.Path(path_type=Path),
help="Output file path. Defaults to <inputdir>/<program>.xlsx (ex './Fundamentals__Full_Body.xlsx')",
)
@click.option(
"-m",
"--mapping",
type=click.Path(exists=True, path_type=Path),
help="JSON mapping file to add or override the default mapping. Contains a single object where keys = program names and values = app names.",
)
@click.option(
"-l",
"--location",
prompt="Gym/location name",
default="Gym",
help="Gym/location name for workout labels",
)
@click.option(
"-p",
"--program",
help="Name used within the output file and also the default for the output file path. (ex 'Fundamentals: Full Body')",
)
@click.option(
"-s",
"--sheet",
callback=get_sheet,
expose_value=True,
help="Sheet name to use or prompts if not provided (ex 'Full Body')",
)
@click.option(
"--ordering",
default=None,
callback=validate_ordering,
help="Weekly workout ordering where 0=rest, 1=workout. (ex 01010100 for 3 workouts/week)",
)
@click.option(
"--naming",
type=click.Choice(["full", "condensed"], case_sensitive=False),
default="full",
help="Naming convention: 'full' (Week 1: Name) or 'condensed' (W1: Name)",
)
@click.pass_context
def main(
ctx: click.Context,
input_file: Path,
output: Path | None,
mapping: Path | None,
location: str,
program: str | None,
sheet: str,
ordering: str | None,
naming: str,
) -> None:
"""
Convert Jeff Nippard workout spreadsheets to MacroFactor Workouts format.
INPUT_FILE: Path to the input Excel file (ex './Fundamentals_Hypertrophy_Program_Excel_Tracking_Sheet.xlsx')
"""
xlsx = ctx.obj["xlsx"]
# Read the selected sheet
df = pd.read_excel(xlsx, sheet_name=sheet)
# Parse workout data
click.echo(f"\nParsing workout data from '{sheet}'...")
workouts = extract_workouts(df)
weeks = list(set(w["week"] for w in workouts))
click.echo(f"Found {len(workouts)} exercises across {len(weeks)} weeks")
# Show workout types found
workout_names = list(set(w["workout"] for w in workouts))
click.echo(f"\nFound {len(workout_names)} workout types per week:")
for name in workout_names:
click.echo(f" - {name}")
# Warn if ordering doesn't have enough workout days
num_workouts_per_week = len(workout_names)
if ordering is not None:
num_workout_days = ordering.count("1")
if num_workout_days < num_workouts_per_week:
click.echo(
click.style(
f"Warning: Ordering has {num_workout_days} workout days but you have "
f"{num_workouts_per_week} workouts per week. Some workouts will be skipped.",
fg="yellow",
)
)
# Get exercise mapping (start with default, merge in overrides from file)
exercises = sorted(set(w['exercise'] for w in workouts))
exercise_mapping = DEFAULT_EXERCISE_MAPPING.copy()
if mapping:
try:
with open(mapping, 'r') as fobj:
overrides = json.load(fobj)
exercise_mapping.update(overrides)
click.echo(
f"Loaded {len(overrides)} mapping overrides from {mapping}")
except Exception as e:
click.echo(click.style(f"Error loading mapping: {e}", fg="red"))
raise SystemExit(1)
# Check for missing or empty mappings
missing = [ex for ex in exercises if not exercise_mapping.get(ex, "")]
if missing:
click.echo(
click.style(
f"\nNote: {len(missing)} exercises will be dropped (no mapping):",
fg="yellow",
)
)
for ex in missing:
click.echo(click.style(f" - {ex}", fg="yellow"))
# Get program name
default_program = sheet
if program is None:
program = click.prompt("\nProgram name", default=default_program)
click.echo(f"Using program name: {program}")
# Build output
rows = build_output_data(
workouts, exercise_mapping, program, ordering, location, naming
)
# Determine output path
if output is None:
# Create safe filename from program name
safe_name = "".join(
c if c.isalnum() or c in " -_" else "_" for c in program)
safe_name = safe_name.strip().replace(" ", "_")
default_output = input_file.parent / f"{safe_name}.xlsx"
output_str = click.prompt(
"\nOutput file path", default=str(default_output))
output = Path(output_str)
# Write output
write_output(rows, output)
click.echo(click.style("\nConversion complete!", fg="green", bold=True))
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment