trainlog/tools/import_mobile_export.py

2159 lines
72 KiB
Python
Executable file

#!/usr/bin/env python3
"""Trainlog import mobile export helper.
Owns this command-line workflow while preserving the versioned database and exchange contracts it consumes."""
import argparse
import json
import math
import os
import sqlite3
import sys
import unicodedata
import uuid
from pathlib import Path
from validate_json import TrainlogSemanticError, parse_timestamp
from exercise_names import ExerciseNameCatalogError, load_exercise_names
from trainlog_sqlite import connect_database
from causal_delete_exchange import live_revision
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",
}
V4_SESSION_KEYS = SESSION_KEYS | {"ended_at", "note"}
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"}
V4_SESSION_EXERCISE_KEYS = V3_SESSION_EXERCISE_KEYS | {"note"}
BODY_BASE_KEYS = {
"observation_id",
"observed_at",
}
V4_BODY_KEYS = BODY_BASE_KEYS | {"session_id", "note"}
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, 4):
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_note(value, label):
if value is None:
return
require_exact_keys(value, {"value", "revision_id", "parent_revision_id"},
{"value", "revision_id", "parent_revision_id"}, label)
revision = require_nonempty_string(value["revision_id"], f"{label}.revision_id")
if len(revision.encode("utf-8")) > 128:
raise ImportFailure(f"{label}.revision_id: hors bornes")
parent = value["parent_revision_id"]
if parent is not None:
require_nonempty_string(parent, f"{label}.parent_revision_id")
if len(parent.encode("utf-8")) > 128 or parent == revision:
raise ImportFailure(f"{label}.parent_revision_id: invalide")
note = value["value"]
if note is not None and (not isinstance(note, str) or len(note.encode("utf-8")) > 4096):
raise ImportFailure(f"{label}.value: texte UTF-8 hors bornes")
def validate_session_exercise(
item,
label,
known_exercise_ids,
session_type,
version,
):
is_v2 = version >= 2
keys = V4_SESSION_EXERCISE_KEYS if version == 4 else 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 version == 4:
validate_note(item["note"], f"{label}.note")
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,
)
# CONTRACT: catalogue membership proves stable identity only. Every
# completed occurrence owns its recording/tracking/data-field snapshot in
# every readable mobile version; later CURRENT profile evolution cannot
# invalidate or reinterpret historical actuals.
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}]"
session_keys = V4_SESSION_KEYS if payload["version"] == 4 else SESSION_KEYS
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 payload["version"] == 4:
validate_note(session["note"], f"{label}.note")
if session["ended_at"] is not None:
require_v3_timestamp(session["ended_at"], f"{label}.ended_at")
if parse_timestamp(session["ended_at"], f"{label}.ended_at") <= parse_timestamp(
session["started_at"], f"{label}.started_at"):
raise ImportFailure(f"{label}.ended_at: doit être postérieur au début")
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
if payload["version"] == 4:
allowed |= V4_BODY_KEYS
for index, observation in enumerate(
payload["body_observations"]
):
label = f"body_observations[{index}]"
required = BODY_BASE_KEYS | ({"session_id", "note"} if payload["version"] == 4 else set())
require_exact_keys(observation, allowed, required, 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")
if payload["version"] == 4:
validate_note(observation["note"], f"{label}.note")
if observation["session_id"] is not None:
require_nonempty_string(observation["session_id"], f"{label}.session_id")
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 range(5, 29):
raise ImportFailure(
f"base desktop schema v5 à v28 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 = {}
try:
canonical_names = load_exercise_names()
except ExerciseNameCatalogError as error:
raise ImportFailure(str(error)) from error
for supplied_exercise in payload["exercises"]:
exercise = dict(supplied_exercise)
exercise["name"] = canonical_names.get(
exercise["exercise_id"], exercise["name"]
)
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 apply_note(connection, owner_kind, owner_id, payload, table, where, arguments):
"""Apply one direct causal child without using wall-clock ordering."""
if payload is None:
retained = connection.execute(
"SELECT r.note FROM sync_note_state s JOIN sync_note_revisions r "
"USING(owner_kind,owner_id,revision_id) WHERE s.owner_kind=? AND s.owner_id=?",
(owner_kind, owner_id)).fetchone()
if retained is not None:
connection.execute(f"UPDATE {table} SET notes=? WHERE {where}",
(retained[0], *arguments))
return "unchanged"
current = connection.execute(
"SELECT revision_id FROM sync_note_state WHERE owner_kind=? AND owner_id=?",
(owner_kind, owner_id)).fetchone()
existing = connection.execute(
"SELECT parent_revision_id,note FROM sync_note_revisions "
"WHERE owner_kind=? AND owner_id=? AND revision_id=?",
(owner_kind, owner_id, payload["revision_id"])).fetchone()
if existing is not None:
if tuple(existing) != (payload["parent_revision_id"], payload["value"]):
raise ImportFailure(f"révision de note contradictoire: {owner_kind}/{owner_id}")
if current is not None and current[0] == payload["revision_id"]:
return "unchanged"
raise ImportFailure(f"révision de note obsolète: {owner_kind}/{owner_id}")
if current is not None and payload["parent_revision_id"] != current[0]:
raise ImportFailure(f"conflit causal de note: {owner_kind}/{owner_id}")
if current is None and payload["parent_revision_id"] is not None:
raise ImportFailure(f"parent de note inconnu: {owner_kind}/{owner_id}")
connection.execute(
"INSERT INTO sync_note_revisions(owner_kind,owner_id,revision_id,parent_revision_id,note) "
"VALUES(?,?,?,?,?)", (owner_kind, owner_id, payload["revision_id"],
payload["parent_revision_id"], payload["value"]))
connection.execute(
"INSERT INTO sync_note_state(owner_kind,owner_id,revision_id) VALUES(?,?,?) "
"ON CONFLICT(owner_kind,owner_id) DO UPDATE SET revision_id=excluded.revision_id",
(owner_kind, owner_id, payload["revision_id"]))
connection.execute(f"UPDATE {table} SET notes=? WHERE {where}",
(payload["value"], *arguments))
return "applied"
def note_matches(connection, owner_kind, owner_id, payload):
row = connection.execute(
"SELECT r.revision_id,r.parent_revision_id,r.note FROM sync_note_state s "
"JOIN sync_note_revisions r USING(owner_kind,owner_id,revision_id) "
"WHERE s.owner_kind=? AND s.owner_id=?", (owner_kind, owner_id)).fetchone()
if payload is None:
return row is None
return row is not None and tuple(row) == (
payload["revision_id"], payload["parent_revision_id"], payload["value"])
def v4_session_metadata_matches(connection, session_row_id, session):
header = connection.execute(
"SELECT ended_at FROM sessions WHERE id=?", (session_row_id,)).fetchone()
if header is None or header[0] != session["ended_at"] or not note_matches(
connection, "session", session["session_id"], session["note"]):
return False
return all(note_matches(connection, "occurrence", item["entry_id"], item["note"])
for item in session["exercises"])
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 ""
tracking_columns = ", tracking_mode" if schema_version >= 16 else ""
tracking_values = ", ?" if schema_version >= 16 else ""
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row]
if schema_version >= 16:
arguments.append(tracking)
arguments.extend([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""" + tracking_columns + """,
data_fields,
position,
load_mode,
rest_seconds,
target_sets,
target_reps,
target_duration_seconds,
target_weight_kg, notes""" + equipment_columns + """
) VALUES(
""" + values + """?, ?, 'sets'""" + tracking_values + """, ?, ?, ?, ?,
?, ?, ?, ?, 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"),
),
)
return session_exercise_row_id
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 ""
tracking_columns = ", tracking_mode" if schema_version >= 16 else ""
tracking_values = ", ?" if schema_version >= 16 else ""
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row]
if schema_version >= 16:
arguments.append(item["tracking_mode"])
arguments.extend([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""" + tracking_columns + """,
data_fields,
position,
load_mode,
rest_seconds,
target_sets,
target_reps,
target_duration_seconds,
target_weight_kg, notes""" + equipment_columns + """
) VALUES(
""" + values + """?, ?, 'continuous'""" + tracking_values + """, ?, ?, ?, ?,
?, ?, ?, ?, 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"),
),
)
return cursor.lastrowid
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"
)
tracking_column = ", tracking_mode" if schema_version >= 16 else ""
tracking_value = ", ?" if schema_version >= 16 else ""
arguments = [item["entry_id"], session_row_id, exercise_row,
item["recording_mode"]]
if schema_version >= 16:
arguments.append(item["tracking_mode"])
arguments.extend([item["data_fields"], position, *occurrence_plan_values(item),
item.get("equipment_id")])
cursor = connection.execute(
"""
INSERT INTO session_exercises(
entry_id, session_row_id, exercise_row_id, recording_mode,
""" + tracking_column.lstrip(", ") + (", " if tracking_column else "") + """data_fields, position, load_mode, rest_seconds,
target_sets, target_reps, target_duration_seconds,
target_weight_kg, notes, equipment_id
) VALUES(?, ?, ?, ?""" + tracking_value + """, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?);
""",
arguments,
)
connection.execute(
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) "
"VALUES(?,?);",
(cursor.lastrowid, item["max_weight_kg"]),
)
return cursor.lastrowid
def import_sessions(
connection,
payload,
exercise_mapping,
report,
):
for session in payload["sessions"]:
retained_feedback = []
retained_feedback_revisions = []
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,ended_at FROM sessions WHERE id=?;",
(session_row_id,),
).fetchone()
resumable_max = (
header is not None
and tuple(header[:2]) == (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)))
)
incoming_by_id = dict(incoming)
correction = (
payload["version"] >= 3
and header is not None
and tuple(header[:2]) == (session["started_at"], session["session_type"])
and len(incoming_by_id) == len(incoming)
and all(entry_id not in dict(current)
or dict(current)[entry_id] == exercise_id
for entry_id, exercise_id in incoming)
)
# 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 or correction
):
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,
) and (payload["version"] != 4 or v4_session_metadata_matches(
connection, session_row_id, session)):
report["sessions_skipped"] += 1
continue
if not legacy and not (resumable_max or correction):
raise ImportFailure("conflit de contenu pour " + session["session_id"])
# CONTRACT: V3 carries the complete occurrence and planning state.
# A correction may alter facts and remove explicitly confirmed
# parents, but a retained entry_id cannot be rebound to another
# exercise. Feedback follows retained session_id + entry_id.
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 14:
retained_feedback = connection.execute(
"SELECT se.entry_id,f.feedback_id,f.observed_at,f.raw_text "
"FROM exercise_feedback f JOIN session_exercises se "
"ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?;",
(session_row_id,),).fetchall()
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 15:
retained_feedback_revisions = connection.execute(
"SELECT r.feedback_id,r.revision_id,r.created_at,r.raw_text "
"FROM exercise_feedback_revisions r JOIN exercise_feedback f "
"ON f.feedback_id=r.feedback_id JOIN session_exercises se "
"ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?;",
(session_row_id,),).fetchall()
# 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,))
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 20:
existing_causal = connection.execute(
"SELECT deleted FROM sync_causal_state WHERE target_kind='session' AND target_id=?",
(session["session_id"],),).fetchone()
if existing_causal is not None and existing_causal[0]:
raise ImportFailure("session protégée causalement: " + session["session_id"])
connection.execute(
"INSERT OR REPLACE INTO sync_causal_state(target_kind,target_id,current_revision_id,deleted,operation_id) VALUES('session',?,?,0,NULL)",
(session["session_id"], "mu_" + str(uuid.uuid4())),)
report["sessions_reconciled"] += 1
else:
cursor = connection.execute(
"""
INSERT INTO sessions(
session_id,
started_at,
ended_at,
session_type,
notes
) VALUES(?, ?, ?, ?, NULL);
""",
(
session["session_id"],
session["started_at"],
session.get("ended_at"),
session["session_type"],
),
)
session_row_id = cursor.lastrowid
if payload["version"] == 4:
current_end = connection.execute(
"SELECT ended_at FROM sessions WHERE id=?", (session_row_id,)).fetchone()[0]
incoming_end = session["ended_at"]
if current_end is None and incoming_end is not None:
connection.execute("UPDATE sessions SET ended_at=? WHERE id=?",
(incoming_end, session_row_id))
elif current_end is not None and incoming_end is not None and current_end != incoming_end:
raise ImportFailure("conflit ended_at pour " + session["session_id"])
apply_note(connection, "session", session["session_id"], session["note"],
"sessions", "id=?", (session_row_id,))
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}"
)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if schema_version < 16 and (
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:
occurrence_row_id = import_max_session_exercise(
connection,
session_row_id,
position,
item,
row_id,
)
elif item["recording_mode"] == "continuous":
occurrence_row_id = import_continuous_session_exercise(
connection,
session_row_id,
position,
item,
row_id,
)
else:
occurrence_row_id = import_set_session_exercise(
connection,
session_row_id,
position,
item,
row_id,
)
if payload["version"] == 4:
apply_note(connection, "occurrence", item["entry_id"], item["note"],
"session_exercises", "id=?", (occurrence_row_id,))
retained_ids = {item["entry_id"] for item in session["exercises"]}
for entry_id, feedback_id, observed_at, raw_text in retained_feedback:
if entry_id not in retained_ids:
continue
occurrence = connection.execute(
"SELECT id FROM session_exercises WHERE session_row_id=? AND entry_id=?;",
(session_row_id, entry_id),).fetchone()
if occurrence is None:
raise ImportFailure("rattachement feedback impossible: " + entry_id)
connection.execute(
"INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) VALUES(?,?,?,?);",
(feedback_id, occurrence[0], observed_at, raw_text),)
retained_feedback_ids = {row[1] for row in retained_feedback if row[0] in retained_ids}
for feedback_id, revision_id, created_at, raw_text in retained_feedback_revisions:
if feedback_id in retained_feedback_ids:
connection.execute(
"INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) VALUES(?,?,?,?);",
(revision_id, feedback_id, created_at, raw_text),)
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
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
tracking_column = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode"
rows = connection.execute(
"SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode," + tracking_column + ","
"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"
]:
parent_row_id = None
if payload["version"] == 4 and observation["session_id"] is not None:
parent = connection.execute(
"SELECT id FROM sessions WHERE session_id=?", (observation["session_id"],)).fetchone()
if parent is None:
raise ImportFailure("parent de l'observation introuvable: " + observation["session_id"])
parent_row_id = parent[0]
existing = connection.execute(
f"SELECT id,observed_at,{','.join(metric_order)},session_row_id 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[1:1 + len(expected)]) != expected:
raise ImportFailure("conflit observation corporelle: " + observation["observation_id"])
if payload["version"] == 4:
current_parent = existing[-1]
if current_parent is None and parent_row_id is not None:
connection.execute("UPDATE body_observations SET session_row_id=? WHERE id=?",
(parent_row_id, existing[0]))
elif current_parent is not None and parent_row_id is not None and current_parent != parent_row_id:
raise ImportFailure("conflit parent observation: " + observation["observation_id"])
apply_note(connection, "observation", observation["observation_id"], observation["note"],
"body_observations", "id=?", (existing[0],))
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,
)
if payload["version"] == 4:
row_id = connection.execute(
"SELECT id FROM body_observations WHERE observation_id=?",
(observation["observation_id"],)).fetchone()[0]
if parent_row_id is not None:
connection.execute("UPDATE body_observations SET session_row_id=? WHERE id=?",
(parent_row_id, row_id))
apply_note(connection, "observation", observation["observation_id"], observation["note"],
"body_observations", "id=?", (row_id,))
report["body_imported"] += 1
def apply_payload(connection, payload, trace_exercises=False):
"""Apply a validated artifact without owning commit or rollback."""
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,
}
connection.execute("PRAGMA foreign_keys = ON;")
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v28")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
incoming = ({("session", value["session_id"]) for value in payload["sessions"]} |
{("body_observation", value["observation_id"]) for value in payload["body_observations"]} |
{("exercise", value["exercise_id"]) for value in payload["exercises"]})
collision = protected & incoming
if collision:
kind, identity = sorted(collision)[0]
raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}")
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é")
mapping = import_exercises(connection, payload, report, trace_exercises)
import_sessions(connection, payload, mapping, report)
import_body(connection, payload, report)
if schema_version >= 20:
for session in sorted(payload["sessions"], key=lambda value: value["session_id"]):
session_id = session["session_id"]
state = connection.execute(
"SELECT deleted FROM sync_causal_state "
"WHERE target_kind='session' AND target_id=?",
(session_id,),
).fetchone()
if state is not None:
continue
# WHY: peers without a previously exchanged mutable revision must
# converge on the same predecessor before either can publish a
# causal delete. CONTRACT: use the canonical live snapshot shared
# with the deletion consumer. INVARIANT: no deletion row or random
# peer-local revision is invented by an ordinary history import.
revision = live_revision(connection, "session", session_id)
if revision is None:
raise ImportFailure("révision causale de séance introuvable: " + session_id)
connection.execute(
"INSERT INTO sync_causal_state("
"target_kind,target_id,current_revision_id,deleted,operation_id) "
"VALUES('session',?,?,0,NULL)",
(session_id, revision),
)
return report
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 = connect_database(database_path)
try:
connection.execute("BEGIN IMMEDIATE;")
report = apply_payload(connection, payload, trace_exercises)
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 connect_database(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()