1956 lines
57 KiB
Python
Executable file
1956 lines
57 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
|
|
|
|
from validate_json import TrainlogSemanticError, parse_timestamp
|
|
|
|
|
|
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"
|
|
}
|
|
V3_SESSION_EXERCISE_KEYS = V2_SESSION_EXERCISE_KEYS | {"target"}
|
|
|
|
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_v3_timestamp(value, label):
|
|
"""Require the exact shared Trainlog timestamp language for V3 only."""
|
|
require_nonempty_string(value, label)
|
|
try:
|
|
parse_timestamp(value, label)
|
|
except TrainlogSemanticError as error:
|
|
raise ImportFailure(f"{label}: date-heure Trainlog invalide") from error
|
|
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):
|
|
def reject_duplicate_keys(pairs):
|
|
result = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
raise ImportFailure(f"champ JSON dupliqué: {key}")
|
|
result[key] = value
|
|
return result
|
|
try:
|
|
with path.open(
|
|
"r",
|
|
encoding="utf-8",
|
|
) as handle:
|
|
payload = json.load(handle, object_pairs_hook=reject_duplicate_keys)
|
|
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, 3):
|
|
raise ImportFailure(
|
|
"version mobile export non supportée"
|
|
)
|
|
|
|
# CONTRACT: V1/V2 retain their published nonempty-string admission. V3 is
|
|
# the current analysis-bearing artifact and uses the exact Trainlog parser.
|
|
if payload["version"] == 3:
|
|
require_v3_timestamp(payload["generated_at"], "generated_at")
|
|
else:
|
|
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,
|
|
version,
|
|
):
|
|
is_v2 = version >= 2
|
|
keys = V3_SESSION_EXERCISE_KEYS if version == 3 else (
|
|
V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS)
|
|
require_exact_keys(
|
|
item,
|
|
keys,
|
|
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 version < 3:
|
|
if item["load_mode"] != "none" or item["rest_seconds"] != 0:
|
|
raise ImportFailure(f"{label}: mobile export V1/V2 exige none/0")
|
|
else:
|
|
if item["load_mode"] not in ("none", "external", "assistance"):
|
|
raise ImportFailure(f"{label}.load_mode invalide")
|
|
require_int(item["rest_seconds"], 0, 86400, f"{label}.rest_seconds")
|
|
target = item["target"]
|
|
if target is None:
|
|
if item["load_mode"] != "none" or item["rest_seconds"] != 0:
|
|
raise ImportFailure(f"{label}: target null exige none/0")
|
|
else:
|
|
if item["recording_mode"] != "sets" or "max_weight_kg" in item:
|
|
raise ImportFailure(f"{label}: cible interdite pour continuous/MAX")
|
|
metric = "reps" if item["tracking_mode"] == "reps" else "duration_seconds"
|
|
require_exact_keys(target, {"sets", metric, "weight_kg"}, {"sets", metric}, f"{label}.target")
|
|
require_int(target["sets"], 1, 64, f"{label}.target.sets")
|
|
require_int(target[metric], 1, 10000 if metric == "reps" else 86400, f"{label}.target.{metric}")
|
|
if "weight_kg" in target:
|
|
require_positive_number(target["weight_kg"], f"{label}.target.weight_kg")
|
|
if item["load_mode"] not in ("external", "assistance"):
|
|
raise ImportFailure(f"{label}: cible pondérée exige external/assistance")
|
|
elif item["load_mode"] != "none":
|
|
raise ImportFailure(f"{label}: cible sans poids exige none")
|
|
|
|
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)
|
|
|
|
if payload["version"] == 3:
|
|
# INVARIANT: accepted V3 history must remain readable by temporal
|
|
# analysis; validation completes before run_import mutates SQLite.
|
|
require_v3_timestamp(session["started_at"], f"{label}.started_at")
|
|
else:
|
|
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"],
|
|
payload["version"],
|
|
)
|
|
|
|
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)
|
|
|
|
if payload["version"] == 3:
|
|
require_v3_timestamp(observation["observed_at"], f"{label}.observed_at")
|
|
else:
|
|
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, 12):
|
|
raise ImportFailure(
|
|
f"base desktop schema v5 à v12 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,
|
|
)
|
|
|
|
# CONTRACT: a retired creator ID is identity input only. It resolves
|
|
# before normalized-name reconciliation and cannot rewrite canonical
|
|
# catalogue presentation metadata unless that canonical ID itself was
|
|
# present in the incoming catalogue.
|
|
if by_id is None and connection.execute(
|
|
"PRAGMA user_version;"
|
|
).fetchone()[0] >= 12:
|
|
alias = connection.execute(
|
|
"SELECT canonical_exercise_id FROM exercise_aliases "
|
|
"WHERE source_exercise_id=?;", (exercise_id,),
|
|
).fetchone()
|
|
if alias is not None:
|
|
canonical = lookup_exercise_by_id(connection, alias[0])
|
|
if canonical is None:
|
|
raise ImportFailure("alias exercice sans cible canonique")
|
|
if not profiles_are_reconcilable(canonical, exercise):
|
|
raise ImportFailure(profile_conflict(canonical, exercise))
|
|
mapping[exercise_id] = canonical["exercise_id"]
|
|
report["exercises_skipped"] += 1
|
|
trace_exercise_decision(trace_exercises, exercise,
|
|
f"alias:{canonical['exercise_id']}", "existing-identical")
|
|
continue
|
|
|
|
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 occurrence_plan_values(item):
|
|
"""Map validated V3 target metadata without deriving it from actual rows."""
|
|
target = item.get("target")
|
|
if target is None:
|
|
return ("none", 0, None, None, None, None)
|
|
return (
|
|
item["load_mode"], item["rest_seconds"], target["sets"],
|
|
target.get("reps"), target.get("duration_seconds"),
|
|
target.get("weight_kg"),
|
|
)
|
|
|
|
|
|
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]
|
|
arguments.extend(occurrence_plan_values(item))
|
|
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', ?, ?, ?, ?,
|
|
?, ?, ?, ?, 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]
|
|
arguments.extend(occurrence_plan_values(item))
|
|
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', ?, ?, ?, ?,
|
|
?, ?, ?, ?, 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?);
|
|
""",
|
|
(
|
|
item["entry_id"],
|
|
session_row_id,
|
|
exercise_row,
|
|
item["recording_mode"],
|
|
item["data_fields"],
|
|
position,
|
|
*occurrence_plan_values(item),
|
|
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,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,"
|
|
"se.target_duration_seconds,se.target_weight_kg 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):
|
|
if "target" not in item and tuple(row[8:14]) != ("none", 0, None, None, None, None):
|
|
# CONTRACT: legacy actual-only exchange has no authority to erase
|
|
# or silently ignore locally persisted planning metadata.
|
|
return False
|
|
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
|
|
# V3 equality includes the complete plan beside stable identity and
|
|
# actual rows; any target delta is an explicit content conflict.
|
|
if "target" in item:
|
|
target = item["target"]
|
|
expected_plan = occurrence_plan_values(item)
|
|
if tuple(row[8:14]) != expected_plan:
|
|
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()
|