CSV Import & Export
How bulk CSV import/export is implemented for exercises, users, plans, sessions, and exercise history — how the underlying pipeline works, where the two entry points (admin vs. self-service) diverge, and the exact columns each entity's CSV uses.
Shared helpers (services/csv/csvUtils.js)
Every entity-specific module builds on the same primitives:
parseCsvBuffer— wrapscsv-parse/syncwithcolumns: true, trim: true, bom: true(so a header row maps directly to object keys, and a UTF-8 BOM from Excel doesn't break the first column name).toCsv— wrapscsv-stringify/syncfor export.- List/step encoding conventions used across multiple entities: list-valued columns (like muscle
groups or tags) are
;-joined; ordered step sequences (like exercise instructions) are joined with|. parseBool— tolerant boolean parsing for columns likeis_public/is_admin/track_sides.
Two entry points per entity
Every entity type has two import/export paths that share the same underlying CSV module but differ in scope and authorization:
- Admin, full-instance (
controllers/importExportController.js, mounted under/admin/...,requireAdmin) — exports/imports every matching document in the whole instance. Import behaves as upsert-or-create depending on the entity. - Self-service, own-data-only (
plansController.js#exportMine/importMineandusersController.js#exportMyPlans/exportMySessions/exportMyExerciseHistory+ matching imports,requireAuth) — scoped to the logged-in user's own data only. These forcecreatedBy/usernameto the logged-in user regardless of what the uploaded file says, so you can't use a self-service import to create data attributed to someone else.
Per-entity modules (services/csv/)
exercisesCsv.js
Columns: name, description, type, primary_muscles, secondary_muscles, equipment, instructions, image, exercise_image. type must be reps, weight, or duration — any other value fails that
row with an error rather than defaulting silently. primary_muscles/secondary_muscles/equipment
use the ;-joined list convention; instructions uses the |-joined step convention.
usersCsv.js
Export columns: username, display_name, is_admin, last_login. Import additionally accepts a
plaintext password column (local auth only — ignored/irrelevant for LDAP-backed accounts), enforced
to be at least 8 characters by the controller before hashing. Passwords are never included in export.
plansCsv.js
The most involved format — one row per exercise slot, not one row per plan, so a multi-day,
multi-exercise plan spans many rows sharing the same plan_name.
Columns: plan_name, plan_description, difficulty, category, estimated_duration, tags, is_public, created_by, day_label, day_order, item_order, is_superset, superset_group, superset_label, exercise_name, sets, reps, duration, track_sides, rest_between_sets, rest_after_exercise, notes.
Import reconstructs the nested plan structure by grouping rows:
- By
plan_name, in order of first appearance in the file. - Within a plan, by
day_order(falling back today_label) to rebuild each day. - Within a day, by
item_order; rows that share the samesuperset_groupvalue are merged into a single superset item'ssupersetExercisesarray instead of becoming separate slots.
An exercise_name that doesn't match an existing exercise fails just that row (with an error reported
back to the importer) rather than aborting the whole import. Admin import supports
defaultCreatedBy (falls back to the row's created_by column, or the importing admin's username if
that's blank); self-service import instead uses forceCreatedBy, which always overrides the column
with the logged-in user regardless of what the file says.
historyCsv.js
Two independent column sets sharing one file/module:
- Sessions (
SESSION_COLUMNS):username, plan_name, day_label, completed_at, duration_seconds, exercise_count. Matched to an existing plan by name via a lookup map built once per import; if no plan matches, the importedWorkoutSessionis created withplan: undefinedrather than failing the row (the schema allows this — see Data Model). - Exercise history (
EXERCISE_HISTORY_COLUMNS):username, exercise_name, reps, last_reps, last_weight, last_duration, updated_at. Requires a matching exercise by name (unlike sessions, an unmatched row is skipped). For backward compatibility with older exports that predate therepsbucketing column, a missingrepsvalue is derived fromlast_reps.
Why the split between admin and self-service exists
The self-service versions exist so a user can back up and restore just their own data — useful for migrating between a hosted and self-hosted instance, or just keeping a personal backup — without needing admin access or being able to affect (or see) anyone else's plans, sessions, or history. The admin versions exist for full-instance migration, backup, and bulk seeding (e.g. loading a shared exercise library across a fresh install). Both funnel through the same per-entity CSV modules, so the column format and parsing behavior is identical either way — only the authorization and the ownership-forcing behavior on import differ.