trainlog/tools/import_mobile_export.py

1864 lines
52 KiB
Python
Executable file

#!/usr/bin/env python3
import argparse
import json
import math
import os
import sqlite3
import sys
import unicodedata
from pathlib import Path
FORMAT = "trainlog-mobile-export"
VERSION = 1
KNOWN_DATA_FIELDS = 3
DEFAULT_EQUIPMENT_CATALOG = (
Path(__file__).resolve().parents[1]
/ "catalog"
/ "equipment-v1.json"
)
TOP_LEVEL_KEYS = {
"format",
"version",
"generated_at",
"exercises",
"sessions",
"body_observations",
}
EXERCISE_KEYS = {
"exercise_id",
"name",
"recording_mode",
"tracking_mode",
"data_fields",
}
SESSION_KEYS = {
"session_id",
"started_at",
"session_type",
"exercises",
}
SESSION_EXERCISE_KEYS = {
"exercise_id",
"name",
"recording_mode",
"tracking_mode",
"data_fields",
"load_mode",
"rest_seconds",
"sets",
"continuous",
}
V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | {
"entry_id", "position", "equipment_id", "max_weight_kg"
}
BODY_BASE_KEYS = {
"observation_id",
"observed_at",
}
BODY_METRIC_KEYS = {
"body_weight_kg",
"neck_cm",
"shoulders_cm",
"chest_cm",
"waist_cm",
"hips_cm",
"left_arm_cm",
"right_arm_cm",
"left_forearm_cm",
"right_forearm_cm",
"left_thigh_cm",
"right_thigh_cm",
"left_calf_cm",
"right_calf_cm",
}
class ImportFailure(RuntimeError):
pass
def default_database_path():
data_home = os.environ.get("XDG_DATA_HOME")
if data_home:
return Path(data_home) / "trainlog" / "trainlog.db"
return (
Path.home()
/ ".local"
/ "share"
/ "trainlog"
/ "trainlog.db"
)
def require_exact_keys(value, allowed, required, label):
if not isinstance(value, dict):
raise ImportFailure(f"{label}: objet JSON attendu")
actual = set(value.keys())
unknown = actual - allowed
missing = required - actual
if unknown:
names = ", ".join(sorted(unknown))
raise ImportFailure(
f"{label}: champ(s) inconnu(s): {names}"
)
if missing:
names = ", ".join(sorted(missing))
raise ImportFailure(
f"{label}: champ(s) manquant(s): {names}"
)
def require_nonempty_string(value, label):
if not isinstance(value, str) or not value:
raise ImportFailure(
f"{label}: chaîne non vide attendue"
)
return value
def require_int(value, minimum, maximum, label):
if isinstance(value, bool) or not isinstance(value, int):
raise ImportFailure(
f"{label}: entier attendu"
)
if value < minimum or value > maximum:
raise ImportFailure(
f"{label}: hors bornes"
)
return value
def require_positive_number(value, label):
if isinstance(value, bool) or not isinstance(
value,
(int, float),
):
raise ImportFailure(
f"{label}: nombre attendu"
)
parsed = float(value)
if not math.isfinite(parsed) or parsed <= 0.0:
raise ImportFailure(
f"{label}: nombre positif attendu"
)
return parsed
def require_nonnegative_number(value, label):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ImportFailure(f"{label}: nombre attendu")
parsed = float(value)
if not math.isfinite(parsed) or parsed < 0.0:
raise ImportFailure(f"{label}: nombre non négatif attendu")
return parsed
def normalize_name(value):
folded = unicodedata.normalize(
"NFC",
value.casefold(),
)
output = []
pending_space = False
wrote_content = False
for char in folded:
if char.isspace():
if wrote_content:
pending_space = True
continue
if pending_space:
output.append(" ")
pending_space = False
output.append(char)
wrote_content = True
normalized = "".join(output)
if not normalized:
raise ImportFailure(
"nom d'exercice vide après normalisation"
)
return normalized
def load_payload(path):
try:
with path.open(
"r",
encoding="utf-8",
) as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError) as error:
raise ImportFailure(
f"lecture JSON impossible: {error}"
) from error
require_exact_keys(
payload,
TOP_LEVEL_KEYS,
TOP_LEVEL_KEYS,
"racine",
)
if payload["format"] != FORMAT:
raise ImportFailure(
"format mobile export invalide"
)
if payload["version"] not in (1, 2):
raise ImportFailure(
"version mobile export non supportée"
)
require_nonempty_string(
payload["generated_at"],
"generated_at",
)
for key in (
"exercises",
"sessions",
"body_observations",
):
if not isinstance(payload[key], list):
raise ImportFailure(
f"{key}: tableau attendu"
)
return payload
def load_supplied_equipment_ids(path):
try:
with path.open("r", encoding="utf-8") as handle:
catalog = json.load(handle)
except (OSError, json.JSONDecodeError) as error:
raise ImportFailure(
f"lecture catalogue équipement impossible: {error}"
) from error
if (
not isinstance(catalog, dict)
or catalog.get("format") != "trainlog-equipment-catalog"
or catalog.get("version") != 1
or not isinstance(catalog.get("equipment"), list)
):
raise ImportFailure("catalogue équipement v1 invalide")
known = set()
for index, item in enumerate(catalog["equipment"]):
if not isinstance(item, dict):
raise ImportFailure(
f"catalogue équipement v1 invalide: equipment[{index}]"
)
known.add(
require_nonempty_string(
item.get("id"),
f"catalogue.equipment[{index}].id",
)
)
return known
def validate_profile(
recording_mode,
tracking_mode,
data_fields,
label,
):
if recording_mode not in (
"sets",
"continuous",
):
raise ImportFailure(
f"{label}.recording_mode invalide"
)
if tracking_mode not in (
"reps",
"duration",
):
raise ImportFailure(
f"{label}.tracking_mode invalide"
)
require_int(
data_fields,
0,
KNOWN_DATA_FIELDS,
f"{label}.data_fields",
)
if data_fields & ~KNOWN_DATA_FIELDS:
raise ImportFailure(
f"{label}.data_fields inconnu"
)
if (
recording_mode == "continuous"
and tracking_mode != "duration"
):
raise ImportFailure(
f"{label}: continuous exige duration"
)
def validate_exercises(payload):
seen_ids = set()
profiles = {}
for index, item in enumerate(
payload["exercises"]
):
label = f"exercises[{index}]"
require_exact_keys(
item,
EXERCISE_KEYS,
EXERCISE_KEYS,
label,
)
exercise_id = require_nonempty_string(
item["exercise_id"],
f"{label}.exercise_id",
)
if exercise_id in seen_ids:
raise ImportFailure(
f"{label}: exercise_id dupliqué"
)
seen_ids.add(exercise_id)
require_nonempty_string(
item["name"],
f"{label}.name",
)
validate_profile(
item["recording_mode"],
item["tracking_mode"],
item["data_fields"],
label,
)
profiles[exercise_id] = item
return profiles
def validate_set_item(
value,
tracking_mode,
label,
):
allowed_weight = {"weight_kg"}
# CONTRACT: absent actual load is omitted/SQL NULL; when present it is a
# finite nonnegative observation, including an explicit zero.
if "weight_kg" in value:
require_nonnegative_number(
value["weight_kg"],
f"{label}.weight_kg",
)
if tracking_mode == "reps":
require_exact_keys(
value,
{"reps"} | allowed_weight,
{"reps"},
label,
)
reps = require_int(
value["reps"],
0,
10000,
f"{label}.reps",
)
return ("reps", reps)
require_exact_keys(
value,
{"duration_seconds"} | allowed_weight,
{"duration_seconds"},
label,
)
duration = require_int(
value["duration_seconds"],
1,
86400,
f"{label}.duration_seconds",
)
return ("duration", duration)
def validate_session_exercise(
item,
label,
known_exercise_ids,
session_type,
):
is_v2 = "entry_id" in item or "position" in item or "equipment_id" in item
require_exact_keys(
item,
V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS,
(V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS)
- {"sets", "continuous", "max_weight_kg"},
label,
)
if is_v2:
require_nonempty_string(item["entry_id"], f"{label}.entry_id")
require_int(item["position"], 0, 100000, f"{label}.position")
if item["equipment_id"] is not None:
require_nonempty_string(item["equipment_id"], f"{label}.equipment_id")
exercise_id = require_nonempty_string(
item["exercise_id"],
f"{label}.exercise_id",
)
if exercise_id not in known_exercise_ids:
raise ImportFailure(
f"{label}: exercice absent du snapshot"
)
require_nonempty_string(
item["name"],
f"{label}.name",
)
recording_mode = item["recording_mode"]
tracking_mode = item["tracking_mode"]
validate_profile(
recording_mode,
tracking_mode,
item["data_fields"],
label,
)
catalog_profile = known_exercise_ids[exercise_id]
if (
recording_mode != catalog_profile["recording_mode"]
or tracking_mode != catalog_profile["tracking_mode"]
or item["data_fields"] & ~catalog_profile["data_fields"]
):
# CONTRACT: an occurrence snapshots the fields that actually existed
# when it was recorded. It may omit later optional catalog fields, but
# it may never claim a field absent from the catalog profile.
raise ImportFailure(
f"{label}: snapshot incompatible avec le profil catalogue"
)
if item["load_mode"] != "none":
raise ImportFailure(
f"{label}: mobile export v1 exige load_mode=none"
)
if item["rest_seconds"] != 0:
raise ImportFailure(
f"{label}: mobile export v1 exige rest_seconds=0"
)
if "max_weight_kg" in item:
if not is_v2 or session_type != "max_test":
raise ImportFailure(
f"{label}: max_weight_kg exige mobile V2 et session max_test"
)
if "sets" in item or "continuous" in item:
raise ImportFailure(
f"{label}: max_weight_kg exclut sets et continuous"
)
require_positive_number(
item["max_weight_kg"],
f"{label}.max_weight_kg",
)
return
if recording_mode == "continuous":
if "sets" in item:
raise ImportFailure(
f"{label}: continuous ne doit pas avoir sets"
)
if "continuous" not in item:
raise ImportFailure(
f"{label}: continuous manquant"
)
continuous = item["continuous"]
allowed = {
"duration_seconds",
"speed_kmh",
"distance_km",
}
require_exact_keys(
continuous,
allowed,
{"duration_seconds"},
f"{label}.continuous",
)
require_int(
continuous["duration_seconds"],
1,
86400,
f"{label}.continuous.duration_seconds",
)
wants_speed = (
item["data_fields"] & 1
) != 0
wants_distance = (
item["data_fields"] & 2
) != 0
has_speed = "speed_kmh" in continuous
has_distance = (
"distance_km" in continuous
)
if wants_speed != has_speed:
raise ImportFailure(
f"{label}: présence speed_kmh incohérente"
)
if wants_distance != has_distance:
raise ImportFailure(
f"{label}: présence distance_km incohérente"
)
if has_speed:
require_positive_number(
continuous["speed_kmh"],
f"{label}.continuous.speed_kmh",
)
if has_distance:
require_positive_number(
continuous["distance_km"],
f"{label}.continuous.distance_km",
)
return
if "continuous" in item:
raise ImportFailure(
f"{label}: sets ne doit pas avoir continuous"
)
if "sets" not in item:
raise ImportFailure(
f"{label}: sets manquant"
)
sets = item["sets"]
if not isinstance(sets, list) or not sets:
raise ImportFailure(
f"{label}.sets: tableau non vide attendu"
)
for set_index, set_item in enumerate(sets):
if not is_v2 and "weight_kg" in set_item:
raise ImportFailure(f"{label}.sets[{set_index}]: poids interdit en v1")
validate_set_item(
set_item,
tracking_mode,
f"{label}.sets[{set_index}]",
)
def validate_sessions(
payload,
known_exercise_ids,
known_equipment_ids,
):
seen_ids = set()
for index, session in enumerate(
payload["sessions"]
):
label = f"sessions[{index}]"
require_exact_keys(
session,
SESSION_KEYS,
SESSION_KEYS,
label,
)
session_id = require_nonempty_string(
session["session_id"],
f"{label}.session_id",
)
if session_id in seen_ids:
raise ImportFailure(
f"{label}: session_id dupliqué"
)
seen_ids.add(session_id)
require_nonempty_string(
session["started_at"],
f"{label}.started_at",
)
if session["session_type"] not in (
"training",
"max_test",
):
raise ImportFailure(
f"{label}.session_type invalide"
)
exercises = session["exercises"]
if (
not isinstance(exercises, list)
or not exercises
):
raise ImportFailure(
f"{label}.exercises: tableau non vide attendu"
)
seen_session_exercises = set()
seen_positions = set()
for exercise_index, exercise in enumerate(
exercises
):
exercise_label = (
f"{label}.exercises[{exercise_index}]"
)
validate_session_exercise(
exercise,
exercise_label,
known_exercise_ids,
session["session_type"],
)
equipment_id = exercise.get("equipment_id")
# CONTRACT: mobile export v2 carries only references whose supplied
# or custom definition is already known. Validate before run_import
# opens a transaction so an unknown ID cannot create partial history.
if (
payload["version"] == 2
and equipment_id is not None
and equipment_id not in known_equipment_ids
):
raise ImportFailure(
"équipement inconnu "
f"session_id={session_id} "
f"entry_id={exercise['entry_id']} "
f"equipment_id={equipment_id}"
)
exercise_id = exercise["exercise_id"]
identity = exercise.get("entry_id", exercise_id)
if identity in seen_session_exercises:
raise ImportFailure(
f"{exercise_label}: identité d'entrée dupliquée dans la séance"
)
seen_session_exercises.add(
identity
)
if "position" in exercise:
if exercise["position"] in seen_positions:
raise ImportFailure(f"{exercise_label}: position dupliquée")
seen_positions.add(exercise["position"])
def validate_body(payload):
seen_ids = set()
allowed = BODY_BASE_KEYS | BODY_METRIC_KEYS
for index, observation in enumerate(
payload["body_observations"]
):
label = f"body_observations[{index}]"
require_exact_keys(
observation,
allowed,
BODY_BASE_KEYS,
label,
)
observation_id = require_nonempty_string(
observation["observation_id"],
f"{label}.observation_id",
)
if observation_id in seen_ids:
raise ImportFailure(
f"{label}: observation_id dupliqué"
)
seen_ids.add(observation_id)
require_nonempty_string(
observation["observed_at"],
f"{label}.observed_at",
)
present_metrics = (
set(observation.keys())
& BODY_METRIC_KEYS
)
if not present_metrics:
raise ImportFailure(
f"{label}: au moins une mesure requise"
)
for metric in present_metrics:
require_positive_number(
observation[metric],
f"{label}.{metric}",
)
def validate_payload(payload, known_equipment_ids):
exercise_ids = validate_exercises(
payload
)
validate_sessions(
payload,
exercise_ids,
known_equipment_ids,
)
validate_body(payload)
def require_supported_schema(connection):
version = connection.execute(
"PRAGMA user_version;"
).fetchone()[0]
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9, 10, 11):
raise ImportFailure(
f"base desktop schema v5 à v11 attendue, version trouvée: {version}"
)
def lookup_exercise_by_id(
connection,
exercise_id,
):
return connection.execute(
"""
SELECT
id,
exercise_id,
name,
normalized_name,
tracking_mode,
recording_mode,
data_fields
FROM exercises
WHERE exercise_id = ?;
""",
(exercise_id,),
).fetchone()
def lookup_exercise_by_normalized(
connection,
normalized,
):
return connection.execute(
"""
SELECT
id,
exercise_id,
name,
normalized_name,
tracking_mode,
recording_mode,
data_fields
FROM exercises
WHERE normalized_name = ?;
""",
(normalized,),
).fetchone()
def data_fields_are_comparable(left, right):
"""Return true only when either bounded field mask contains the other."""
return (left & ~right) == 0 or (right & ~left) == 0
def trace_exercise_decision(enabled, exercise, lookup, decision):
"""Emit an opt-in structured diagnostic without changing import state."""
if not enabled:
return
print(
"EXERCISE_DECISION=" + json.dumps(
{
"exercise_id": exercise["exercise_id"],
"name": exercise["name"],
"recording_mode": exercise["recording_mode"],
"tracking_mode": exercise["tracking_mode"],
"data_fields": exercise["data_fields"],
"lookup": lookup,
"decision": decision,
},
ensure_ascii=False,
sort_keys=True,
),
file=sys.stderr,
)
def profiles_are_reconcilable(row, exercise):
"""Check the complete exercise-profile boundary carried by mobile V2.
Name equality is deliberately checked by the caller: it is a collision
precondition, never sufficient identity evidence by itself.
"""
return (
row["tracking_mode"] == exercise["tracking_mode"]
and row["recording_mode"] == exercise["recording_mode"]
and data_fields_are_comparable(
row["data_fields"], exercise["data_fields"]
)
)
def profile_conflict(row, exercise):
return (
"profil incompatible entre identités "
f"{row['exercise_id']} et {exercise['exercise_id']}: "
f"desktop={row['recording_mode']}/{row['tracking_mode']}/"
f"data_fields={row['data_fields']}, "
f"entrant={exercise['recording_mode']}/{exercise['tracking_mode']}/"
f"data_fields={exercise['data_fields']}"
)
def enrich_desktop_profile(connection, row, exercise):
"""Retain the richer compatible capability without rewriting history."""
# CONTRACT: field values are a bit mask, not an ordered enumeration. The
# prior comparability check proves that this union is one existing superset.
richer_fields = row["data_fields"] | exercise["data_fields"]
if richer_fields != row["data_fields"]:
# INVARIANT: session_exercises.data_fields remains untouched. Missing
# optional values therefore remain NULL instead of being invented.
connection.execute(
"UPDATE exercises SET data_fields=? WHERE id=?;",
(richer_fields, row["id"]),
)
return richer_fields
def merge_desktop_exercise_rows(connection, canonical, retired):
"""Move the complete current desktop FK graph before deleting a duplicate."""
# WHY: schema v11 adds a second exercise-row owner. Keeping this operation
# explicit prevents identity reconciliation from silently dropping a body-
# zone decision while occurrence IDs and all history remain unchanged.
has_body_zones = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='exercise_body_zones';"
).fetchone() is not None
if has_body_zones:
def zone_state(row_id):
rows = connection.execute(
"SELECT zone_id,role FROM exercise_body_zones "
"WHERE exercise_row_id=? ORDER BY role,zone_id;",
(row_id,),
).fetchall()
return tuple((row[0], row[1]) for row in rows)
canonical_state = zone_state(canonical["id"])
retired_state = zone_state(retired["id"])
if canonical_state and retired_state and canonical_state != retired_state:
raise ImportFailure(
"conflit zones pendant réconciliation des identités "
f"{canonical['exercise_id']} et {retired['exercise_id']}"
)
if not canonical_state and retired_state:
connection.execute(
"UPDATE exercise_body_zones SET exercise_row_id=? WHERE exercise_row_id=?;",
(canonical["id"], retired["id"]),
)
else:
connection.execute(
"DELETE FROM exercise_body_zones WHERE exercise_row_id=?;",
(retired["id"],),
)
if connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' "
"AND name='exercise_body_zone_sync';"
).fetchone() is not None:
# INVARIANT: a row merge is not a sync acknowledgement. Clearing
# both ancestors makes a later divergent companion conflict rather
# than treating one creator's stale baseline as shared truth.
connection.execute(
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);",
(canonical["id"], retired["id"]),
)
connection.execute(
"UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;",
(canonical["id"], retired["id"]),
)
remaining = connection.execute(
"SELECT COUNT(*) FROM session_exercises WHERE exercise_row_id=?;",
(retired["id"],),
).fetchone()[0]
if remaining != 0:
raise ImportFailure(
f"références résiduelles vers {retired['exercise_id']}"
)
connection.execute(
"DELETE FROM exercises WHERE id=?;",
(retired["id"],),
)
def import_exercises(
connection,
payload,
report,
trace_exercises=False,
):
mapping = {}
for exercise in payload["exercises"]:
exercise_id = exercise["exercise_id"]
normalized = normalize_name(
exercise["name"]
)
by_id = lookup_exercise_by_id(
connection,
exercise_id,
)
if by_id is not None:
if not profiles_are_reconcilable(
by_id,
exercise,
):
trace_exercise_decision(
trace_exercises,
exercise,
f"exercise_id:{by_id['exercise_id']}",
"conflict",
)
raise ImportFailure(
profile_conflict(by_id, exercise)
)
by_name = lookup_exercise_by_normalized(
connection,
normalized,
)
if (
by_name is not None
and by_name["id"] != by_id["id"]
):
if not profiles_are_reconcilable(by_name, by_id):
trace_exercise_decision(
trace_exercises,
exercise,
"exercise_id:"
f"{by_id['exercise_id']};normalized_name:"
f"{by_name['exercise_id']}",
"conflict",
)
raise ImportFailure(profile_conflict(by_name, by_id))
# CONTRACT: the row already owning the normalized desktop name
# is the deterministic canonical identity. All row references
# move transactionally; entry_id/session_id and child values do
# not change.
richer_fields = (
by_name["data_fields"]
| by_id["data_fields"]
| exercise["data_fields"]
)
connection.execute(
"UPDATE exercises SET data_fields=? WHERE id=?;",
(richer_fields, by_name["id"]),
)
merge_desktop_exercise_rows(connection, by_name, by_id)
mapping[exercise_id] = by_name["exercise_id"]
report["exercises_reconciled"] += 1
trace_exercise_decision(
trace_exercises,
exercise,
"exercise_id:"
f"{by_id['exercise_id']};normalized_name:"
f"{by_name['exercise_id']}",
"existing-reconciled",
)
continue
richer_fields = enrich_desktop_profile(
connection, by_id, exercise
)
# CONTRACT: exercise_id is the synchronization identity. A rename
# updates metadata in place, retaining every historical and draft
# foreign-key reference instead of creating a second exercise.
if (
by_id["name"] != exercise["name"]
or by_id["normalized_name"] != normalized
):
connection.execute(
"""
UPDATE exercises
SET name = ?, normalized_name = ?, data_fields = ?
WHERE id = ?;
""",
(
exercise["name"],
normalized,
richer_fields,
by_id["id"],
),
)
report["exercises_reconciled"] += 1
decision = "existing-reconciled"
elif richer_fields != by_id["data_fields"]:
report["exercises_reconciled"] += 1
decision = "existing-reconciled"
else:
report["exercises_skipped"] += 1
decision = "existing-identical"
mapping[exercise_id] = (
by_id["exercise_id"]
)
trace_exercise_decision(
trace_exercises,
exercise,
f"exercise_id:{by_id['exercise_id']}",
decision,
)
continue
by_name = lookup_exercise_by_normalized(
connection,
normalized,
)
if by_name is not None:
if not profiles_are_reconcilable(by_name, exercise):
# Name equality alone remains insufficient: incompatible modes
# or incomparable optional-field masks are an explicit conflict.
trace_exercise_decision(
trace_exercises,
exercise,
f"normalized_name:{by_name['exercise_id']}",
"conflict",
)
raise ImportFailure(profile_conflict(by_name, exercise))
richer_fields = enrich_desktop_profile(connection, by_name, exercise)
mapping[exercise_id] = by_name["exercise_id"]
if richer_fields != by_name["data_fields"]:
report["exercises_reconciled"] += 1
decision = "existing-reconciled"
else:
# INVARIANT: resolving an already-compatible creator ID to the
# persisted canonical row is idempotent lookup work, not a new
# persistent reconciliation on every snapshot replay.
report["exercises_skipped"] += 1
decision = "existing-identical"
trace_exercise_decision(
trace_exercises,
exercise,
f"normalized_name:{by_name['exercise_id']}",
decision,
)
continue
connection.execute(
"""
INSERT INTO exercises(
exercise_id,
name,
normalized_name,
tracking_mode,
recording_mode,
data_fields
) VALUES(?, ?, ?, ?, ?, ?);
""",
(
exercise_id,
exercise["name"],
normalized,
exercise["tracking_mode"],
exercise["recording_mode"],
exercise["data_fields"],
),
)
mapping[exercise_id] = exercise_id
report["exercises_imported"] += 1
trace_exercise_decision(
trace_exercises,
exercise,
"none",
"inserted",
)
return mapping
def exercise_row_id(
connection,
desktop_exercise_id,
):
row = connection.execute(
"""
SELECT id
FROM exercises
WHERE exercise_id = ?;
""",
(desktop_exercise_id,),
).fetchone()
if row is None:
raise ImportFailure(
"exercice desktop introuvable après reconciliation"
)
return row[0]
def session_exists(
connection,
session_id,
):
return (
connection.execute(
"""
SELECT 1
FROM sessions
WHERE session_id = ?;
""",
(session_id,),
).fetchone()
is not None
)
def import_set_session_exercise(
connection,
session_row_id,
position,
item,
exercise_row,
):
sets = item["sets"]
tracking = item["tracking_mode"]
entry_id = item.get("entry_id")
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if entry_id is None and schema_version >= 7:
entry_id = "sxe_v1_" + str(session_row_id) + "_" + item["exercise_id"]
columns = "entry_id, " if entry_id is not None else ""
values = "?, " if entry_id is not None else ""
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
equipment_values = ", ?" if schema_version >= 6 else ""
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position]
if schema_version >= 6:
arguments.append(item.get("equipment_id"))
cursor = connection.execute(
"""
INSERT INTO session_exercises(
""" + columns + """session_row_id,
exercise_row_id,
recording_mode,
data_fields,
position,
load_mode,
rest_seconds,
target_sets,
target_reps,
target_duration_seconds,
target_weight_kg, notes""" + equipment_columns + """
) VALUES(
""" + values + """?, ?, 'sets', ?, ?, 'none', 0,
NULL, NULL, NULL, NULL, NULL""" + equipment_values + """
);
""",
arguments,
)
session_exercise_row_id = (
cursor.lastrowid
)
for set_index, set_item in enumerate(sets):
if tracking == "reps":
reps = set_item["reps"]
duration = None
else:
reps = None
duration = (
set_item["duration_seconds"]
)
connection.execute(
"""
INSERT INTO performed_sets(
session_exercise_row_id,
position,
reps,
duration_seconds,
weight_kg
) VALUES(?, ?, ?, ?, ?);
""",
(
session_exercise_row_id,
set_index,
reps,
duration, set_item.get("weight_kg"),
),
)
def import_continuous_session_exercise(
connection,
session_row_id,
position,
item,
exercise_row,
):
entry_id = item.get("entry_id")
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if entry_id is None and schema_version >= 7:
entry_id = "sxe_v1_" + str(session_row_id) + "_" + item["exercise_id"]
columns = "entry_id, " if entry_id is not None else ""
values = "?, " if entry_id is not None else ""
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
equipment_values = ", ?" if schema_version >= 6 else ""
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position]
if schema_version >= 6:
arguments.append(item.get("equipment_id"))
cursor = connection.execute(
"""
INSERT INTO session_exercises(
""" + columns + """session_row_id,
exercise_row_id,
recording_mode,
data_fields,
position,
load_mode,
rest_seconds,
target_sets,
target_reps,
target_duration_seconds,
target_weight_kg, notes""" + equipment_columns + """
) VALUES(
""" + values + """?, ?, 'continuous', ?, ?, 'none', 0,
NULL, NULL, NULL, NULL, NULL""" + equipment_values + """
);
""",
arguments,
)
continuous = item["continuous"]
connection.execute(
"""
INSERT INTO continuous_activity(
session_exercise_row_id,
duration_seconds,
speed_kmh,
distance_km
) VALUES(?, ?, ?, ?);
""",
(
cursor.lastrowid,
continuous["duration_seconds"],
continuous.get("speed_kmh"),
continuous.get("distance_km"),
),
)
def import_max_session_exercise(
connection,
session_row_id,
position,
item,
exercise_row,
):
"""Persist a V2 max without manufacturing a performed set."""
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if schema_version < 9:
raise ImportFailure(
"max_weight_kg exige le schéma desktop v9"
)
cursor = connection.execute(
"""
INSERT INTO session_exercises(
entry_id, session_row_id, exercise_row_id, recording_mode,
data_fields, position, load_mode, rest_seconds,
target_sets, target_reps, target_duration_seconds,
target_weight_kg, notes, equipment_id
) VALUES(?, ?, ?, ?, ?, ?, 'none', 0,
NULL, NULL, NULL, NULL, NULL, ?);
""",
(
item["entry_id"],
session_row_id,
exercise_row,
item["recording_mode"],
item["data_fields"],
position,
item.get("equipment_id"),
),
)
connection.execute(
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) "
"VALUES(?,?);",
(cursor.lastrowid, item["max_weight_kg"]),
)
def import_sessions(
connection,
payload,
exercise_mapping,
report,
):
for session in payload["sessions"]:
if session_exists(
connection,
session["session_id"],
):
if payload["version"] == 1:
report["sessions_skipped"] += 1
continue
existing = connection.execute(
"SELECT s.id FROM sessions s WHERE s.session_id=?;",
(session["session_id"],)).fetchone()
session_row_id = existing[0]
incoming = [
(
item["entry_id"],
exercise_mapping.get(item["exercise_id"]),
)
for item in session["exercises"]
]
if any(exercise_id is None for _, exercise_id in incoming):
raise ImportFailure(
"mapping exercice absent pour " + session["session_id"]
)
rows = connection.execute(
"SELECT se.entry_id,e.exercise_id FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position;",
(session_row_id,)).fetchall()
current = [(row[0], row[1]) for row in rows]
legacy = all(value[0].startswith("sxe_legacy_") or value[0].startswith("sxe_v1_") for value in current)
header = connection.execute(
"SELECT started_at,session_type FROM sessions WHERE id=?;",
(session_row_id,),
).fetchone()
resumable_max = (
header is not None
and tuple(header) == (session["started_at"], "max_test")
and session["session_type"] == "max_test"
and len(incoming) >= len(current)
and all(current[index] == incoming[index]
for index in range(len(current)))
)
# A v1 history may be upgraded only when exercise/order mapping is
# unique. Any other identity disagreement is an explicit conflict.
if current != incoming and not (
(legacy and [x[1] for x in current] == [x[1] for x in incoming])
or resumable_max
):
raise ImportFailure("conflit d'identités d'entrées pour " + session["session_id"])
if not legacy and session_semantically_matches(
connection,
session_row_id,
session,
exercise_mapping,
):
report["sessions_skipped"] += 1
continue
if not legacy and not resumable_max:
raise ImportFailure("conflit de contenu pour " + session["session_id"])
# CONTRACT: a resumed max_test may edit existing max values and
# append occurrences, but cannot remove/reorder/rebind any stable
# entry. This bounded replacement makes tomorrow's continuation
# idempotent without turning arbitrary session conflicts into wins.
# Explicit child deletion makes reconciliation safe even for old
# databases which were created without enforced foreign keys.
connection.execute("DELETE FROM performed_sets WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
connection.execute("DELETE FROM continuous_activity WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 9:
connection.execute("DELETE FROM max_results WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
connection.execute("DELETE FROM session_exercises WHERE session_row_id=?;", (session_row_id,))
report["sessions_reconciled"] += 1
else:
cursor = connection.execute(
"""
INSERT INTO sessions(
session_id,
started_at,
ended_at,
session_type,
notes
) VALUES(?, ?, NULL, ?, NULL);
""",
(
session["session_id"],
session["started_at"],
session["session_type"],
),
)
session_row_id = cursor.lastrowid
for position, item in enumerate(
session["exercises"]
):
position = item.get("position", position)
mobile_id = item["exercise_id"]
desktop_id = exercise_mapping.get(
mobile_id
)
if desktop_id is None:
raise ImportFailure(
f"mapping exercice absent: {mobile_id}"
)
row = lookup_exercise_by_id(
connection,
desktop_id,
)
if row is None:
raise ImportFailure(
f"exercice desktop absent: {desktop_id}"
)
if (
row["tracking_mode"]
!= item["tracking_mode"]
or row["recording_mode"]
!= item["recording_mode"]
or item["data_fields"] & ~row["data_fields"]
):
raise ImportFailure(
"snapshot de séance incompatible avec le catalogue desktop"
)
row_id = exercise_row_id(
connection,
desktop_id,
)
if "max_weight_kg" in item:
import_max_session_exercise(
connection,
session_row_id,
position,
item,
row_id,
)
elif item["recording_mode"] == "continuous":
import_continuous_session_exercise(
connection,
session_row_id,
position,
item,
row_id,
)
else:
import_set_session_exercise(
connection,
session_row_id,
position,
item,
row_id,
)
report["sessions_imported"] += 1
def session_semantically_matches(
connection,
session_row_id,
incoming,
exercise_mapping,
):
header = connection.execute(
"SELECT started_at,session_type FROM sessions WHERE id=?", (session_row_id,)).fetchone()
if header is None or (header[0], header[1]) != (incoming["started_at"], incoming["session_type"]):
return False
rows = connection.execute(
"SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode,e.tracking_mode,"
"se.data_fields,se.equipment_id FROM session_exercises se JOIN exercises e "
"ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position", (session_row_id,)).fetchall()
items = sorted(incoming["exercises"], key=lambda item: item["position"])
if len(rows) != len(items):
return False
for row, item in zip(rows, items):
canonical_exercise_id = exercise_mapping.get(item["exercise_id"])
if tuple(row[1:8]) != (item["entry_id"], item["position"], canonical_exercise_id,
item["recording_mode"], item["tracking_mode"],
item["data_fields"], item.get("equipment_id")):
return False
if "max_weight_kg" in item:
current = connection.execute(
"SELECT max_weight_kg FROM max_results "
"WHERE session_exercise_row_id=?",
(row[0],),
).fetchone()
if current is None or current[0] != item["max_weight_kg"]:
return False
elif item["recording_mode"] == "continuous":
current = connection.execute(
"SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity "
"WHERE session_exercise_row_id=?", (row[0],)).fetchone()
expected = item["continuous"]
if current is None or tuple(current) != (expected["duration_seconds"], expected.get("speed_kmh"), expected.get("distance_km")):
return False
else:
current = connection.execute(
"SELECT reps,duration_seconds,weight_kg FROM performed_sets "
"WHERE session_exercise_row_id=? ORDER BY position", (row[0],)).fetchall()
expected = [(value.get("reps"), value.get("duration_seconds"), value.get("weight_kg"))
for value in item["sets"]]
if [tuple(value) for value in current] != expected:
return False
return True
def body_exists(
connection,
observation_id,
):
return (
connection.execute(
"""
SELECT 1
FROM body_observations
WHERE observation_id = ?;
""",
(observation_id,),
).fetchone()
is not None
)
def import_body(
connection,
payload,
report,
):
metric_order = [
"body_weight_kg",
"neck_cm",
"shoulders_cm",
"chest_cm",
"waist_cm",
"hips_cm",
"left_arm_cm",
"right_arm_cm",
"left_forearm_cm",
"right_forearm_cm",
"left_thigh_cm",
"right_thigh_cm",
"left_calf_cm",
"right_calf_cm",
]
placeholders = ", ".join(
"?" for _ in range(
2 + len(metric_order)
)
)
columns = (
"observation_id, observed_at, "
+ ", ".join(metric_order)
)
sql = (
f"INSERT INTO body_observations("
f"{columns}"
f") VALUES({placeholders});"
)
for observation in payload[
"body_observations"
]:
existing = connection.execute(
f"SELECT observed_at,{','.join(metric_order)} FROM body_observations WHERE observation_id=?",
(observation["observation_id"],)).fetchone()
if existing is not None:
expected = [observation["observed_at"]] + [observation.get(metric) for metric in metric_order]
if list(existing) != expected:
raise ImportFailure("conflit observation corporelle: " + observation["observation_id"])
report["body_skipped"] += 1
continue
values = [
observation["observation_id"],
observation["observed_at"],
]
values.extend(
observation.get(metric)
for metric in metric_order
)
connection.execute(
sql,
values,
)
report["body_imported"] += 1
def run_import(
payload,
database_path,
dry_run,
trace_exercises=False,
):
if not database_path.exists():
raise ImportFailure(
f"base desktop introuvable: {database_path}"
)
connection = sqlite3.connect(
database_path
)
connection.row_factory = sqlite3.Row
report = {
"exercises_imported": 0,
"exercises_reconciled": 0,
"exercises_skipped": 0,
"sessions_imported": 0,
"sessions_reconciled": 0,
"sessions_skipped": 0,
"body_imported": 0,
"body_skipped": 0,
}
try:
connection.execute(
"PRAGMA foreign_keys = ON;"
)
require_supported_schema(
connection
)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
has_explicit_max = any(
"max_weight_kg" in entry
for session in payload["sessions"]
for entry in session["exercises"]
)
if has_explicit_max and schema_version < 9:
raise ImportFailure("max_weight_kg exige le schéma desktop v9")
has_explicit_zero_set_weight = any(
set_item.get("weight_kg") == 0
for session in payload["sessions"]
for entry in session["exercises"]
for set_item in entry.get("sets", [])
if "weight_kg" in set_item
)
if has_explicit_zero_set_weight and schema_version < 10:
raise ImportFailure(
"weight_kg=0 exige le schéma desktop v10; import annulé"
)
connection.execute(
"BEGIN IMMEDIATE;"
)
mapping = import_exercises(
connection,
payload,
report,
trace_exercises,
)
import_sessions(
connection,
payload,
mapping,
report,
)
import_body(
connection,
payload,
report,
)
if dry_run:
connection.rollback()
else:
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
return report
def print_report(
report,
dry_run,
):
prefix = (
"MOBILE_IMPORT_DRY_RUN=PASS"
if dry_run
else "MOBILE_IMPORT=PASS"
)
print(prefix)
for key in (
"exercises_imported",
"exercises_reconciled",
"exercises_skipped",
"sessions_imported",
"sessions_reconciled",
"sessions_skipped",
"body_imported",
"body_skipped",
):
print(
f"{key}={report[key]}"
)
def main():
parser = argparse.ArgumentParser(
description=(
"Import idempotent d'un snapshot "
"Trainlog Android dans la DB desktop."
)
)
parser.add_argument(
"json_path",
type=Path,
)
parser.add_argument(
"--database",
type=Path,
default=default_database_path(),
)
parser.add_argument(
"--dry-run",
action="store_true",
)
parser.add_argument(
"--catalog",
type=Path,
default=DEFAULT_EQUIPMENT_CATALOG,
)
parser.add_argument(
"--trace-exercises",
action="store_true",
help="journaliser les lookups et décisions catalogue sur stderr",
)
args = parser.parse_args()
try:
payload = load_payload(
args.json_path
)
known_equipment_ids = load_supplied_equipment_ids(args.catalog)
if args.database.exists():
with sqlite3.connect(args.database) as connection:
if connection.execute("PRAGMA user_version").fetchone()[0] >= 8:
known_equipment_ids.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))
validate_payload(payload, known_equipment_ids)
report = run_import(
payload,
args.database,
args.dry_run,
args.trace_exercises,
)
print_report(
report,
args.dry_run,
)
except ImportFailure as error:
print(
f"MOBILE_IMPORT=FAIL: {error}",
file=sys.stderr,
)
raise SystemExit(1)
except sqlite3.Error as error:
print(
f"MOBILE_IMPORT=FAIL: SQLite: {error}",
file=sys.stderr,
)
raise SystemExit(1)
if __name__ == "__main__":
main()