A Swift package that parses workout-history exports from popular tracker apps into a vendor-neutral intermediate representation.
Currently supports:
- Strong (CSV)
- Hevy (CSV)
- FitNotes (CSV)
Not currently planned:
- Jefit — Jefit's Terms of Use contain explicit no-reverse-engineering
and no-derivative-works clauses with a "permit anyone else to" hook,
and Jefit does not expose a sanctioned user-facing CSV export. Adding
Jefit support would require either a written carve-out from Jefit, an
official export feature, or clear precedent that processing
user-extracted backup files falls outside the TOS. PRs that thread
this needle are welcome; speculative implementations against the
current TOS are not. Users wanting to migrate Jefit history can use
third-party converters like
JeFit2Hevyto convert through Hevy's format, which this package supports.
Offered as-is. This is a hobby-scale project — updates land when the maintainers feel like it, not on any schedule. Issues and pull requests may sit indefinitely. If you need an actively-maintained dependency with response-time guarantees, fork it.
- iOS 17 / macOS 14
- Swift 5.10
Add as a Swift Package Manager dependency:
.package(url: "https://github.com/gossamr/swift-workout-importer.git", from: "0.1.0")Then declare a target dependency on WorkoutImporter.
Takes an export from a tracker app, validates the schema, groups
one-row-per-set data into (session, exercise, set) graphs, and hands
back a ParsedImportPayload of plain Codable structs. Downstream
consumers map exercise names to their own catalog and persist however
they like — this package never touches storage.
- Match exercise names against a target catalog. Bring your own matcher.
- Persist anything. The output is in-memory
Codablestructs. - Convert weight units. Each
ParsedSetcarries its source unit as a string (weight: Double?, weightUnit: String?); consumers map to their own typed unit system. - Touch the network or any storage beyond reading the input file.
Most fields are plain String so consumers can map them into whatever
typed system they already have. Parsers validate their output against
documented canonical vocabularies, so consumers can rely on emitted
values without re-validating. The only typed field on the IR is
ParsedImportPayload.sourceApp since the package owns that concept.
Strong's CSV omits the weight unit, so the caller passes the assumed
unit (validated against StrongCSVImporter.acceptedWeightUnits):
import WorkoutImporter
let url: URL = ...
let payload = try StrongCSVImporter.parse(url: url, defaultWeightUnit: "lb")
for session in payload.sessions {
print("\(session.title) — \(session.exercises.count) exercises")
for exercise in session.exercises {
print(" \(exercise.sourceName) [\(exercise.equipmentHint ?? "?")]"
+ " — \(exercise.sets.count) sets")
}
}Hevy's weight_lbs column is unambiguous, so no unit argument is needed:
let payload = try HevyCSVImporter.parse(url: url)
// Every set with a non-nil weight has weightUnit == "lb".Each parser exposes a cheap header sniff for routing:
if try StrongCSVImporter.detect(url: url) {
// ...
} else if try HevyCSVImporter.detect(url: url) {
// ...
} else if try FitNotesCSVImporter.detect(url: url) {
// ...
}Parse-time issues that don't prevent the file from being imported land
in payload.warnings: [ImportWarning] rather than thrown.
ImportWarning is an enum with associated values; cases:
| Case | Carries |
|---|---|
.unparseableSessionDate |
sessionName: String?, raw: String |
.ambiguousWeightUnitsChoseKg |
(no payload — FitNotes-specific) |
.unknownDistanceUnit |
raw: String |
.unparseableTime |
raw: String |
.assumedDistanceUnit |
unit: String (Strong-specific) |
.unknownSetType |
raw: String, sessionName: String? |
.multipleLoggingPasses |
entries: [ImportWarning.MultiPassEntry] |
Callers format and localize the message; the importer doesn't produce
strings. Pattern-match with if case:
for warning in payload.warnings {
switch warning {
case let .unparseableSessionDate(name, raw):
log.warn("Skipped session \(name ?? "?"): \(raw)")
case let .multipleLoggingPasses(entries):
log.info("Repeated within session: \(entries.map(\.exerciseName))")
default:
break
}
}Each parser declares a typed error enum for unrecoverable conditions (file unreadable, no rows, required columns missing, invalid caller input). Per-row issues do not throw.
do {
let payload = try StrongCSVImporter.parse(url: url, defaultWeightUnit: "lb")
} catch StrongCSVImporterError.missingRequiredColumns(let cols) {
log.error("Bad Strong export: missing \(cols)")
} catch StrongCSVImporterError.noRows {
log.error("Empty Strong export")
} catch StrongCSVImporterError.invalidWeightUnit(let unit) {
log.error("Caller passed unsupported defaultWeightUnit: \(unit)")
}Parsers emit only values from these sets (or nil). Consumers can
exhaustively switch on these without a default-case fallback.
| Field | Vocabulary | Source |
|---|---|---|
ParsedSet.weightUnit |
"kg", "lb" |
StrongCSVImporter.acceptedWeightUnits |
ParsedExercise.equipmentHint |
"barbell", "dumbbell", "kettlebell", "cable", "machine", "smith", "bodyweight", "band", "plate" |
NameNormalizer.equipmentHints |
ParsedSet.setType |
"normal", "warmup", "drop_set", "failure" |
HevyCSVImporter.canonicalSetTypes |
ParsedImportPayload.sourceApp |
.strong, .hevy, .fitnotes (typed enum — package owns this concept) |
ImportSourceApp |
Distance is always meters and duration is always seconds — no unit field needed.
Unknown values from the source export coerce to nil and append a
deduplicated ImportWarning case to payload.warnings.
- One row per set. Exercise names are canonical with equipment in parens
(
Squat (Barbell),Bench Press (Smith Machine)). - No warmup flag, no superset, no RPE in the export —
setType,rpe, andsupersetIdare alwaysnilon Strong payloads. - Weight unit is not carried in the export — caller passes
defaultWeightUnit. Durationcolumn isHH:mmworkout-elapsed time, not a clock. The parser uses it to deriveendTime = startTime + durationwhen present.- The
Secondscolumn is set duration (planks, runs, etc.) — not rest between sets. Strong tracks rest in-app but does not export it.
- One row per set. Hevy templates the unit into the weight/distance
column header based on the user's app setting:
weight_lbs+distance_milesfor lb-mode,weight_kg+distance_kmfor kg-mode. The parser detects whichever pair is present and emits the matchingweightUnit("lb"or"kg"); distance always converts to meters at the IR boundary. set_typepopulatessetTypeverbatim when in canonical vocabulary.superset_idpopulatessupersetId(string) — exercises sharing a non-emptysupersetIdwere performed as a superset.rpeoptional, present only when the user enables RPE tracking.- Timestamps are
d MMM yyyy, HH:mmlocal time. Both unpadded (28 Mar 2025) and zero-padded (01 Jan 2025) day-of-month forms parse. description(workout-level) andexercise_notes(per-exercise) are repeated on every set row of their scope; the parser de-duplicates while preserving first-seen order.duration_secondsis per-set duration, not rest between sets. Hevy tracks rest in-app but does not export it.- When the same
exercise_titleappears in one session withset_indexresetting (e.g.0,1,2,0,1,2), the parser splits into separateParsedExerciseentries — one per monotonic run. Hevy has no native unilateral-side support; users log the exercise twice as a workaround. A single deduped.multipleLoggingPasseswarning is emitted per file.
- One row per set. Exercise names are free text — users type whatever
they like, no canonical naming convention. Most rows yield no
equipmentHint(the normalizer can still surface one when the user happens to type a Strong-style name likeSquat (Barbell)). - Two weight columns (
Weight (kg)andWeight (lbs)) are mutually exclusive in practice depending on the user's app preference. The parser reads kg first, falls back to lbs, and emitsweightUnit: "kg"or"lb"accordingly. Both empty → bodyweight (weight: nil,weightUnit: nil). Both populated → kg wins and a single.ambiguousWeightUnitsChoseKgwarning is emitted per file. Note the column header islbsbut the canonical vocabulary string is"lb". Distance Unitis one ofm,km,cm,in,ft,yd,mi— the parser converts to meters at the IR boundary. Unknown units coerce distance to nil with a deduplicated.unknownDistanceUnitwarning per unique unknown value.Timeis per-set duration inHH:MM:ss(2 colons) orMM:ss(1 colon). Unparseable values coerce to nil with a deduplicated.unparseableTimewarning; empty values are silently nil.- No warmup flag, no superset, no RPE in the export —
setType,supersetId, andrpeare alwaysnilon FitNotes payloads. - Dates are strict
yyyy-MM-dd. FitNotes carries no time-of-day signal, sostartTimeis local midnight on the row's date andendTimeis always nil. Per-rowTimeis set duration, not session length — FitNotes does not export workout-level duration. - Rest between sets is not exported by FitNotes (the app tracks it in-app only).
- Sessions are grouped by
(Date, Category)preserving first-seen order. Empty Category is its own bucket — empty rows on a given date do not merge with non-empty Category rows on that date. Session title is the Category value when non-empty, else"Session". - The
Categoryvalue is preserved intoParsedExercise.notesas a prepended"Category: <name>"line (deduped within an exercise). The free-textNotescolumn content is appended below it per row. - The
Kindcolumn encodes which fields are present ("wr","dt", ...). The parser ignores it — the data columns are authoritative.detect()usesKindas a fingerprint since neither Strong nor Hevy emits that column.
The package exposes a couple of helpers that consumers writing their own matcher or extending the parser set may find useful:
CSVReader— RFC4180 CSV parser. Handles BOM, CRLF/LF, embedded commas in quoted fields, multiline quoted fields, and""-escaped quotes. No external dependencies.NameNormalizer— exercise-name canonicalization.normalize(_:)folds case + diacritics, treats parens content as tokens (soBench Press (Barbell)→bench press barbell), and applies a synonym vocabulary (Nuzzo JSCR 2017 / 2021 phrase rewrites, compound-word collapses, singular/plural folds).tokens(_:)returns the stopword-filtered token set;extractEquipmentHint(_:)recognizes both Strong-style parens (Squat (Barbell)) and Hevy-style prefix forms (Incline Dumbbell Bench Press) plus common slang aliases (DB,EZ Bar).applySynonyms(tokens:)is exposed for callers that tokenize without going throughnormalize.
Conform to WorkoutImportSource:
public protocol WorkoutImportSource {
static var sourceApp: ImportSourceApp { get }
static var supportedContentTypes: [UTType] { get }
static func detect(url: URL) throws -> Bool
static func parse(url: URL) throws -> ParsedImportPayload
}detect(url:) should be a cheap header sniff that never throws on a
malformed-but-readable file. parse(url:) may throw on unrecoverable
errors (file unreadable, no rows, required columns missing); per-row
issues should be appended to payload.warnings as typed ImportWarning
cases instead.
When emitting setType, weightUnit, or equipmentHint, validate
against the canonical vocabulary above. Unknown source values should
coerce to nil and add a single deduplicated ImportWarning per
unique unknown value.
Add a new case to ImportSourceApp (a String-RawRepresentable
enum so Codable emits the lower-case raw value).
"Strong" is a trademark of Strong Fitness PTE. Ltd. "Hevy" is a trademark of Hevy Studios S.L. "FitNotes" is a trademark of its respective owners (Android: James Gay; iOS: Ginger Technologies Pte Ltd). All other trademarks referenced in this package or its documentation are the property of their respective owners.
This package is not affiliated with, endorsed by, or sponsored by any of the app's data it parses. It operates exclusively on files that the user has exported themselves through each app's official export feature. It does not access any vendor servers, decompile any vendor application, or reproduce any vendor's proprietary content. App names appear in type names, documentation, and identifiers solely to describe what data format each parser handles (nominative fair use).
Each parser's source file repeats this disclaimer in its header for clarity. Users of this package are responsible for ensuring they have the right to use any data they pass to it (typically their own exported training history).
MIT — see LICENSE.