feat(sync): stabilize bidirectional sync and equipment reconciliation

This commit is contained in:
fy59 2026-09-08 17:57:03 +02:00
parent 7093dd5606
commit 8066b39ce3
56 changed files with 7355 additions and 740 deletions

1
.gitignore vendored
View file

@ -24,6 +24,7 @@ local.properties
# Local user data
/data/
exports/
.local-backups/
# Python helpers
__pycache__/

View file

@ -9,6 +9,11 @@ Detailed implementation chronology remains available in Git history and
### Added
- desktop equipment v8: a supplied catalogue generated from
`catalog/equipment-v1.json`, browse/search/detail views, and local custom
equipment creation/selection; occurrence links resolve supplied or local
definitions and show unknown historic references explicitly;
- multi-occurrence session V2: stable per-occurrence `entry_id`, repeated
catalogue exercises in one session, per-set actual weights, and occurrence
equipment associations across Android, desktop, import and export;
@ -16,6 +21,23 @@ Detailed implementation chronology remains available in Git history and
Android-local custom equipment creation, and explicit rejection of unknown
equipment identities rather than silent association loss;
- `trainlog-equipment-definitions` v1, with directional Android-to-PC and
PC-to-Android filenames, strict stable definition fields, additive
reconciliation, reserved supplied-manifest IDs, and explicit divergence
conflicts;
- explicit TUI/shared-engine synchronization actions: `a` Android -> PC, `p`
PC -> Android, and `b` inbound then outbound bidirectional synchronization;
each opens one direction-specific confirmation, `Enter` runs it once, `Esc`
cancels without an operation, `r` only refreshes device status, and the
former `s` shortcut is inert;
- bounded V2 exercise reconciliation for distinct IDs with equal normalized
names: modes and represented invariants must match, field masks must be
comparable, desktop identity stays canonical, and the richer bit-mask union
is retained without changing historical occurrence snapshots;
- regression coverage for identical/different-ID profiles, compatible
subset/superset masks, incompatible collisions, the real Marche identity
pair, full Android -> PC import and stable PC ↔ Android replay;
- Android `EXERCISE_EDIT_V1`: visible catalog editing, stable-ID name rename,
explicit invalid/conflict/profile/database results, and profile locking once
completed history or an active draft references the exercise;
@ -27,7 +49,7 @@ Detailed implementation chronology remains available in Git history and
- native Kotlin/Compose Android capture client;
- Android local exercise, session, continuous-activity, and body persistence;
- C17/ncursesw desktop TUI with direct session entry and durable SQLite history;
- C17/Notcurses desktop TUI with direct session entry and durable SQLite history;
- profile-aware exercise model using recording mode, tracking mode, and
supplemental fields;
- continuous activity persistence without synthetic sets;
@ -52,6 +74,12 @@ Detailed implementation chronology remains available in Git history and
### Changed
- desktop and Android schema v8 preserve existing equipment references while
permitting custom definitions with `load_semantics = none`; Android's v7 ->
v8 migration is non-destructive;
- custom definitions reconcile before V2 mobile/association artifacts in each
direction. The V2 mobile and association shapes are unchanged;
- desktop SQLite schema v7 and Android SQLite schema v7 preserve historic rows
while adding durable occurrence identities and occurrence-level equipment;
- the active Android↔PC completed-session exchange is V2; frozen V1 artifacts
@ -60,9 +88,9 @@ Detailed implementation chronology remains available in Git history and
- synchronization invokes each local helper with the explicit XDG-resolved
desktop database path and records the concrete equipment-import failure;
- same-ID Android ↔ PC catalog reconciliation now updates display-name metadata
in place and rejects a different-ID normalized-name collision, preserving
synchronization identity and preventing renamed duplicates;
- same-ID Android ↔ PC catalog reconciliation updates display-name metadata in
place. Compatible different-ID normalized-name collisions now merge through
the explicit V2 policy; incompatible collisions still reject atomically;
- Android local SQLite v3 -> v4 additive migration for structured active drafts;
- completed-session insertion and draft clearing are atomic; drafts remain
@ -83,11 +111,24 @@ Detailed implementation chronology remains available in Git history and
### Fixed
- synchronization summaries no longer add exercise reconciliations to the
`exercices ajoutés` value. A compatible different-ID lookup that makes no
persistent change is reported as an idempotent skip, while real insertions
and real reconciliation mutations retain distinct counters. A production
PC-exporter/Android-importer regression proves zero additions and exact
business-table stability on the second and third imports;
- equipment companion import previously omitted its required `--database`
argument and blocked synchronization after a successful session import;
- PC catalogue/mobile export paths now accept schema v7 and preserve catalogue
tracking metadata; Android completed-session equipment editing now targets
the stable occurrence `entry_id`, not an ambiguous catalogue exercise ID;
- Android scoped-storage suffix selection now covers equipment associations as
well as mobile/definition artifacts; a historic V1 snapshot cannot consume a
neighboring V2 equipment companion;
- desktop-generated session occurrences now receive `sxe_<uuid-v4>` rather
than the synchronization-run `sy_` prefix; public generator capacity and C17
regression coverage include the three-character occurrence prefix;
- TUI footer help now advertises every active direct function key through F5;
- in-progress Android workout loss when leaving the foreground or recreating
the Activity/process;
@ -100,7 +141,7 @@ Detailed implementation chronology remains available in Git history and
- synchronization failures that previously surfaced only as `error=unknown`;
- Android folder-selection UX so a wrong SAF folder can be changed without
clearing application data;
- libmtp terminal output leaking into ncurses rendering.
- libmtp terminal output leaking into terminal rendering.
### Validation
@ -109,21 +150,23 @@ Current validated baseline:
```text
TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V7=PASS
DESKTOP_TESTS=25/25 PASS
DESKTOP_SCHEMA_V8=PASS
DESKTOP_TESTS=32/32 PASS
ANDROID_BUILD=PASS
ANDROID_LOCAL_WORKFLOWS=PASS
ANDROID_LOCAL_DATABASE_V7=PASS
ANDROID_HOST_TESTS=8/8 PASS
ANDROID_DEVICE_INSTRUMENTATION=5/5 PASS
ANDROID_LOCAL_DATABASE_V8=PASS
ANDROID_TEST_DEBUG_UNIT=PASS
ANDROID_SESSION_DRAFT_V1=PASS
USB_MTP_DETECTION=PASS
MTP_ROUNDTRIP=PASS
MTP_HARDWARE_ROUNDTRIP=HISTORICAL_PASS
CURRENT_SANDBOX_LIBMTP_OPEN=BLOCKED
REAL_ANDROID_ARTIFACT_COPY_ROUNDTRIP=PASS
REAL_DATABASE_APPLICATION=BLOCKED_BY_SANDBOX_WRITE_BOUNDARY
ANDROID_TO_PC_MTP=PASS
PC_TO_ANDROID_MTP_PUBLISH=PASS
ANDROID_TO_PC_MTP=HISTORICAL_PASS
PC_TO_ANDROID_MTP_PUBLISH=HISTORICAL_PASS
COMMON_SYNC_ENGINE=PASS
TRAINLOG_SYNCD=PASS
ANDROID_TRIGGERED_SYNC=PASS
@ -132,6 +175,8 @@ TUI_SYNC_LOG_SHOW=PASS
BIDIRECTIONAL_SYNC_V1=PASS
MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS
EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS
```
### Measured max v1

View file

@ -16,9 +16,9 @@ desktop.
```text
TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V7=PASS
DESKTOP_SCHEMA_V8=PASS
ANDROID_LOCAL_WORKFLOWS=PASS
ANDROID_LOCAL_DATABASE_V7=PASS
ANDROID_LOCAL_DATABASE_V8=PASS
ANDROID_SESSION_DRAFT_V1=PASS
EXERCISE_EDIT_V1=PASS
ANDROID_BANNER_PARITY_V1=PASS
@ -34,8 +34,10 @@ ANDROID_SYNC_RECEIPT=PASS
BIDIRECTIONAL_SYNC_V1=PASS
MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS
EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS
DESKTOP_TESTS=25/25 PASS
DESKTOP_TESTS=32/32 PASS
ANDROID_BUILD=PASS
```
@ -109,6 +111,14 @@ occurrence, as do its actual per-set loads. `external` records an applied or
machine-displayed load; `assistance` records assistance and is not interpreted
as increasing strength.
During V2 synchronization, a different-ID normalized-name collision is merged
only when recording/tracking modes match, every other known invariant is
compatible, and one `data_fields` mask contains the other. The desktop identity
is retained as canonical, the bit-mask union preserves the richer capability,
and historical occurrence snapshots remain unchanged; absent optional values
stay absent. Incomparable profiles remain explicit conflicts. Name equality
alone is never sufficient.
## Repository layout
```text
@ -147,9 +157,10 @@ export or desktop synchronization as completed sessions.
Schema v4 migrates additively from v3, preserving existing capture data. See
[Android behavior](docs/android.md) and [validation](docs/tests.md).
The current Android schema is v7. Its additive v4 -> v7 chain adds the shared
equipment catalogue, per-occurrence equipment links and durable occurrence
identities without recreating completed history or the active draft.
The current Android schema is v8. Its additive v4 -> v8 chain adds the shared
equipment catalogue, per-occurrence equipment links, durable occurrence
identities, and custom-equipment definition support without recreating completed
history or the active draft.
Exercises can be renamed in place from Android. The `ex_<uuid-v4>` identity is
unchanged; completed history, an active draft, and synchronization therefore
@ -272,5 +283,5 @@ BODY_ANALYTICS_V1=PASS
BODY_COMPOSITION_ESTIMATE=PASS
BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS
DESKTOP_TESTS=25/25 PASS
DESKTOP_TESTS=32/32 PASS
```

View file

@ -3,15 +3,21 @@ package com.labfytools.trainlog.ui
import android.content.Context
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToIndex
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraftForm
@ -29,6 +35,10 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SessionDraftUiInstrumentedTest {
private lateinit var marcheExerciseId: String
private lateinit var elevationExerciseId: String
private lateinit var marcheTenExerciseId: String
private val context: Context =
ApplicationProvider.getApplicationContext()
@ -159,6 +169,133 @@ class SessionDraftUiInstrumentedTest {
}
}
@Test
fun exerciseSearchFiltersPrefixIgnoringCaseAccentsAndExtraSpaces() {
resumeAndOpenExerciseSearch()
compose.onNodeWithTag("exercise-search-input")
.performTextInput("m")
compose.onNodeWithTag("exercise-search-result-$marcheExerciseId")
.performScrollTo()
compose.onNodeWithTag("exercise-search-input")
.performTextClearance()
compose.onNodeWithTag("exercise-search-input")
.performTextInput(" MAR ")
compose.onNodeWithTag("exercise-search-result-$marcheExerciseId")
.performScrollTo()
compose.onNodeWithTag("exercise-search-input")
.performTextClearance()
compose.onNodeWithTag("exercise-search-input")
.performTextInput("ele")
compose.onNodeWithTag("exercise-search-result-$elevationExerciseId")
.performScrollTo()
}
@Test
fun exerciseSearchShowsFullCatalogueAndNoResultState() {
resumeAndOpenExerciseSearch()
compose.onNodeWithTag("exercise-search-input")
.performTextInput("marche")
compose.onNodeWithTag("exercise-search-results")
.performScrollToIndex(10)
compose.onNodeWithTag("exercise-search-result-$marcheTenExerciseId")
.performScrollTo()
compose.onNodeWithTag("exercise-search-input")
.performTextClearance()
compose.onNodeWithTag("exercise-search-input")
.performTextInput("inexistant")
compose.onNodeWithText("Aucun exercice trouvé").assertIsDisplayed()
}
@Test
fun cancellingSearchKeepsTheConfirmedExerciseAndRawValues() {
resumeAndOpenExerciseSearch()
compose.onNodeWithTag("exercise-search-input")
.performTextInput("mar")
compose.onNodeWithText("Annuler la recherche").performClick()
compose.onNodeWithTag("exercise-picker-open").assertIsDisplayed()
compose.onNodeWithText("4,5,6,")
.performScrollTo()
.assertIsDisplayed()
}
@Test
fun selectingAnExerciseUsesItsIdentityAndAllowsASecondPassage() {
resumeAndOpenExerciseSearch()
selectMarche()
assertMarcheFormSelected()
compose.onNodeWithText("Ajouter à la séance")
.performScrollTo()
.performClick()
compose.onNodeWithTag("exercise-picker-open").performClick()
selectMarche()
assertMarcheFormSelected()
compose.onNodeWithText("Ajouter à la séance")
.performScrollTo()
.performClick()
TrainlogRepository(context, DRAFT_UI_TEST_DATABASE_NAME).useForTest { repository ->
val draft = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
val marches = draft.exercises.filter { it.exercise.name == "Marche" }
assertEquals(2, marches.size)
assertTrue(marches.map { it.entryId }.distinct().size == 2)
}
}
@Test
fun changingAnEditedEntryReplacesOnlyThatEntry() {
compose.onNodeWithText("Reprendre la séance en cours").performClick()
compose.onNodeWithText("Modifier Test UI")
.performScrollTo()
.performClick()
resumeAndOpenExerciseSearch(alreadyResumed = true)
selectMarche()
assertMarcheFormSelected()
compose.onNodeWithText("Ajouter à la séance")
.performScrollTo()
.performClick()
TrainlogRepository(context, DRAFT_UI_TEST_DATABASE_NAME).useForTest { repository ->
val draft = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(1, draft.exercises.size)
assertEquals("Marche", draft.exercises.single().exercise.name)
assertTrue(draft.exercises.single().entryId.startsWith("sxe_"))
}
}
private fun resumeAndOpenExerciseSearch(alreadyResumed: Boolean = false) {
if (!alreadyResumed) {
compose.onNodeWithText("Reprendre la séance en cours").performClick()
}
compose.onNodeWithTag("exercise-picker-open")
.performScrollTo()
.performClick()
}
private fun selectMarche() {
compose.onNodeWithTag("exercise-search-input")
.performTextClearance()
compose.onNodeWithTag("exercise-search-input")
.performTextInput("marche")
compose.onNodeWithTag("exercise-search-result-$marcheExerciseId")
.performScrollTo()
.performClick()
}
private fun assertMarcheFormSelected() {
compose.waitForIdle()
compose.onNodeWithText("SAISIE — Marche")
.performScrollTo()
.assertIsDisplayed()
}
private fun seedDraft() {
context.deleteDatabase(
DRAFT_UI_TEST_DATABASE_NAME
@ -178,6 +315,24 @@ class SessionDraftUiInstrumentedTest {
dataFields = 0,
)
) as CreateExerciseResult.Created
marcheExerciseId =
createExercise(repository, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
.exerciseId
elevationExerciseId =
createExercise(repository, "Élévation", RecordingMode.SETS, TrackingMode.REPS)
.exerciseId
(1..10).forEach { number ->
val exercise =
createExercise(
repository,
"Marche %02d".format(number),
RecordingMode.SETS,
TrackingMode.REPS,
)
if (number == 10) {
marcheTenExerciseId = exercise.exerciseId
}
}
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
@ -202,6 +357,25 @@ class SessionDraftUiInstrumentedTest {
)
}
}
private fun createExercise(
repository: TrainlogRepository,
name: String,
recordingMode: RecordingMode,
trackingMode: TrackingMode,
): ExerciseProfile {
val result =
repository.createExercise(
NewExerciseProfile(
name = name,
recordingMode = recordingMode,
trackingMode = trackingMode,
dataFields = 0,
),
)
assertTrue(result is CreateExerciseResult.Created)
return (result as CreateExerciseResult.Created).exercise
}
}
private inline fun TrainlogRepository.useForTest(

View file

@ -6,6 +6,7 @@ import java.text.Normalizer
import java.util.Locale
enum class EquipmentLoadSemantics {
NONE,
EXTERNAL,
ASSISTANCE,
BODYWEIGHT,

View file

@ -129,6 +129,9 @@ class SyncCatalogInbox(
it.readText()
}
val definitionsError = importPcEquipmentDefinitions(directory)
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
when (
val result =
repository
@ -183,6 +186,20 @@ class SyncCatalogInbox(
} catch (error: Exception) { error.message ?: "Import séances V2 impossible." }
}
private fun importPcEquipmentDefinitions(directory: DocumentFile): String? {
val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null
return try {
val json = appContext.contentResolver.openInputStream(file.uri)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
?: return "Lecture définitions équipement impossible."
when (val result = repository.applyPcEquipmentDefinitionsJson(json)) {
is EquipmentDefinitionImportResult.Applied -> null
is EquipmentDefinitionImportResult.Invalid -> result.message
EquipmentDefinitionImportResult.DatabaseError -> "Erreur base locale définitions équipement."
}
} catch (error: Exception) { error.message ?: "Import définitions équipement impossible." }
}
private fun importPcEquipmentAssociations(directory: DocumentFile): String? {
val file = directory.findFile("trainlog-equipment-associations-v2.json")
?: directory.findFile("trainlog-equipment-associations-v1.json")

View file

@ -39,14 +39,26 @@ class SyncExporter(
return SyncExportResult.Unsupported
}
/* CONTRACT: capture all three artifacts before publishing any of
* them. The files are separate for compatibility, but a user edit
* must not make a newly-written V2 reference a definition assembled
* from a different logical export state. */
val definitionsJson: String
val mobileJson: String
val associationsJson: String
try {
definitionsJson = repository.buildEquipmentDefinitionsJson()
/* V2 is the authoritative mobile session exchange. V1 remains
* readable by desktop for historic devices but is not published here. */
val json = repository.buildMobileExportV2Json()
val bytes =
json.toByteArray(
Charsets.UTF_8
* readable by desktop for historic devices but is not published. */
mobileJson = repository.buildMobileExportV2Json()
associationsJson = repository.buildEquipmentAssociationsJson()
} catch (error: Exception) {
return SyncExportResult.Error(
error.message ?: "Préparation de l'export impossible.",
)
}
val bytes = mobileJson.toByteArray(Charsets.UTF_8)
val resolver =
appContext.contentResolver
@ -65,6 +77,11 @@ class SyncExporter(
val displayName =
"trainlog-mobile-export-v2.json"
/* Definitions are visible before any v2 file which may reference a
* custom ID; a failed definition write aborts the bundle. */
val definitionsError = writeEquipmentDefinitions(definitionsJson)
if (definitionsError != null) return SyncExportResult.Error(definitionsError)
val existing =
findExisting(
collection,
@ -171,7 +188,7 @@ class SyncExporter(
)
}
val companionError = writeEquipmentAssociations()
val companionError = writeEquipmentAssociations(associationsJson)
if (companionError != null) {
return SyncExportResult.Error(companionError)
}
@ -201,7 +218,7 @@ class SyncExporter(
}
/** Publish the companion separately so frozen mobile-export-v1 stays byte-compatible. */
private fun writeEquipmentAssociations(): String? {
private fun writeEquipmentAssociations(json: String): String? {
val resolver = appContext.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
@ -216,7 +233,7 @@ class SyncExporter(
}) ?: return "Création de l'extension équipement impossible."
return try {
resolver.openOutputStream(uri, "wt")?.use {
it.write(repository.buildEquipmentAssociationsJson().toByteArray(Charsets.UTF_8))
it.write(json.toByteArray(Charsets.UTF_8))
it.flush()
} ?: return "Écriture de l'extension équipement impossible."
if (created) resolver.update(uri, ContentValues().apply {
@ -229,6 +246,33 @@ class SyncExporter(
}
}
private fun writeEquipmentDefinitions(json: String): String? {
val resolver = appContext.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
val name = "trainlog-mobile-equipment-definitions-v1.json"
val existing = findExisting(collection, name, relativePath)
val created = existing == null
val uri = existing ?: resolver.insert(collection, ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}) ?: return "Création des définitions équipement impossible."
return try {
resolver.openOutputStream(uri, "wt")?.use {
it.write(json.toByteArray(Charsets.UTF_8)); it.flush()
} ?: return "Écriture des définitions équipement impossible."
if (created) resolver.update(uri, ContentValues().apply {
put(MediaStore.MediaColumns.IS_PENDING, 0)
}, null, null)
null
} catch (error: Exception) {
if (created) resolver.delete(uri, null, null)
error.message ?: "Export définitions équipement impossible."
}
}
private fun findExisting(
collection: Uri,
displayName: String,

View file

@ -11,7 +11,10 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -19,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.data.ActiveDraftLoadResult
@ -38,6 +42,8 @@ import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography
import java.text.Normalizer
import java.util.Locale
@Composable
fun SessionScreen(
@ -230,6 +236,21 @@ fun SessionScreen(
)
}
ExercisePicker(
exercises = exercises,
selectedExercise = currentDraft.form.selectedExercise,
onExerciseSelected = { exercise ->
/* CONTRACT: only a tapped catalogue result changes the durable
* form identity. The transient query is never an exercise. */
persistDraft(
currentDraft.copy(
form = currentDraft.form.copy(selectedExercise = exercise),
),
null,
)
},
)
TrainlogFrame(
title = "SEANCE EN COURS"
) {
@ -317,55 +338,6 @@ fun SessionScreen(
}
}
TrainlogFrame(
title = "CATALOGUE",
active =
exercises.isNotEmpty(),
) {
if (
exercises.isEmpty()
) {
TrainlogInfo(
"Aucun exercice."
)
} else {
exercises.forEach {
exercise ->
val alreadyAdded =
currentDraft.exercises.any {
it.exercise.exerciseId ==
exercise.exerciseId
}
CatalogChoice(
exercise =
exercise,
selected =
currentDraft.form
.selectedExercise
?.exerciseId ==
exercise.exerciseId,
disabled = false,
onClick = {
if (true) {
persistDraft(
currentDraft.copy(
form =
currentDraft.form.copy(
selectedExercise =
exercise
)
),
null,
)
}
},
)
}
}
}
val editingExercise =
currentDraft.form.selectedExercise
@ -544,8 +516,8 @@ fun SessionScreen(
private fun CatalogChoice(
exercise: ExerciseProfile,
selected: Boolean,
disabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors =
LocalTrainlogColors.current
@ -558,6 +530,7 @@ private fun CatalogChoice(
Row(
modifier =
Modifier
.then(modifier)
.fillMaxWidth()
.padding(vertical = 2.dp)
.background(
@ -568,7 +541,6 @@ private fun CatalogChoice(
}
)
.clickable(
enabled = !disabled,
onClick = onClick,
)
.padding(
@ -579,9 +551,7 @@ private fun CatalogChoice(
BasicText(
text =
(
if (disabled) {
""
} else if (selected) {
if (selected) {
""
} else {
" "
@ -593,9 +563,7 @@ private fun CatalogChoice(
style =
TrainlogTypography.normal.copy(
color =
if (disabled) {
colors.muted
} else if (selected) {
if (selected) {
colors.warning
} else {
colors.text
@ -611,6 +579,110 @@ private fun CatalogChoice(
}
}
/**
* WHY: the session picker keeps query state outside [SessionDraftForm] so
* typing and cancelling are harmless to the selected exercise and raw values.
* CONTRACT: matching is presentation-only prefix matching; it never changes
* stored names or treats query text as a catalogue identity.
* INVARIANT: [exercises] is the complete, stable repository catalogue, so a
* movement already used in this session remains selectable for another entry.
*/
@Composable
private fun ExercisePicker(
exercises: List<ExerciseProfile>,
selectedExercise: ExerciseProfile?,
onExerciseSelected: (ExerciseProfile) -> Unit,
) {
val colors = LocalTrainlogColors.current
var searchOpen by remember(selectedExercise?.exerciseId) {
mutableStateOf(selectedExercise == null)
}
var query by remember(selectedExercise?.exerciseId) { mutableStateOf("") }
val results = remember(exercises, query) { exercisePrefixMatches(exercises, query) }
TrainlogFrame(title = "EXERCICE", active = exercises.isNotEmpty()) {
if (exercises.isEmpty()) {
TrainlogInfo("Aucun exercice.")
return@TrainlogFrame
}
TrainlogAction(
label = selectedExercise?.name ?: "Choisir un exercice",
description = "Exercice sélectionné. Appuyer pour afficher les suggestions.",
accent = if (selectedExercise == null) colors.muted else colors.success,
modifier = Modifier.testTag("exercise-picker-open"),
onClick = { searchOpen = true },
)
TrainlogInputField(
label = "Rechercher un exercice…",
value = query,
onValueChange = {
query = it
searchOpen = true
},
testTag = "exercise-search-input",
)
if (!searchOpen) {
return@TrainlogFrame
}
if (results.isEmpty()) {
TrainlogInfo("Aucun exercice trouvé", color = colors.muted)
} else {
/* The viewport stays keyboard-friendly while LazyColumn preserves
* access to every prefix result instead of truncating it. */
LazyColumn(
modifier = Modifier.heightIn(max = 280.dp).testTag("exercise-search-results"),
) {
items(results, key = { it.exerciseId }) { exercise ->
CatalogChoice(
exercise = exercise,
selected = selectedExercise?.exerciseId == exercise.exerciseId,
modifier = Modifier.testTag("exercise-search-result-${exercise.exerciseId}"),
onClick = {
onExerciseSelected(exercise)
query = ""
searchOpen = false
},
)
}
}
}
if (selectedExercise != null) {
TrainlogAction(
label = "Annuler la recherche",
description = "Conserver ${selectedExercise.name} et les valeurs saisies.",
accent = colors.muted,
onClick = {
query = ""
searchOpen = false
},
)
}
}
}
internal fun exercisePrefixMatches(
exercises: List<ExerciseProfile>,
query: String,
): List<ExerciseProfile> {
val normalizedQuery = normalizeExerciseSearchText(query)
return exercises.filter { exercise ->
normalizedQuery.isEmpty() ||
normalizeExerciseSearchText(exercise.name).startsWith(normalizedQuery)
}
}
private fun normalizeExerciseSearchText(value: String): String =
Normalizer.normalize(value, Normalizer.Form.NFD)
.replace("\\p{M}+".toRegex(), "")
.lowercase(Locale.ROOT)
.trim()
.replace("\\s+".toRegex(), " ")
@Composable
private fun SessionExerciseForm(
key: String,

View file

@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -225,6 +226,7 @@ fun TrainlogInputField(
onValueChange: (String) -> Unit,
keyboardOptions: KeyboardOptions =
KeyboardOptions.Default,
testTag: String? = null,
) {
val colors =
LocalTrainlogColors.current
@ -276,6 +278,13 @@ fun TrainlogInputField(
.onFocusChanged {
focused = it.isFocused
}
.then(
if (testTag == null) {
Modifier
} else {
Modifier.testTag(testTag)
}
)
.background(colors.surface)
.padding(
horizontal = 12.dp,

View file

@ -33,7 +33,7 @@ Accueil
Android local database version:
```text
7
8
```
Domain tables cover:
@ -51,7 +51,7 @@ This database is Android-local. It is not copied to the PC.
Schema v4 introduced `active_session_draft`, `draft_session_exercises`,
`draft_performed_sets` and `draft_continuous_activity`. The implemented
additive v4 -> v7 chain preserves catalog, completed sessions/actuals, body
additive v4 -> v8 chain preserves catalog, completed sessions/actuals, body
observations and the draft while adding the shared equipment catalogue,
occurrence-level equipment links and stable completed/draft `entry_id` values.
Exactly one active draft is supported; it is separate from completed history.
@ -172,9 +172,16 @@ The existing completed-session save-time timestamp behavior is unchanged.
PC catalog reconciliation preserves draft references through catalog row
ownership. If an editing selection no longer resolves, only the selection is
cleared; added exercises and raw text remain, with a specific diagnostic.
When a received or exported catalog entry has the same `exercise_id`, a changed
display name is reconciled in that same row. A different-ID normalized-name
collision is rejected, so a rename cannot become a duplicate exercise.
When a received catalog entry has the same `exercise_id`, a changed display
name is reconciled in that same row. When the incoming PC identity differs but
the normalized name matches, Android rekeys or merges only if recording and
tracking modes match, the bounded `data_fields` masks are comparable by
inclusion, and overlapping equipment metadata has the same load semantics.
The incoming PC identity is canonical and the bit-mask union retains the richer
profile. Completed and draft occurrence row IDs, `entry_id`, positions, values,
equipment references and the active selection are moved transactionally. Any
incompatible condition rejects the catalog transaction; name equality alone
never authorizes a merge.
## 7. Session history
@ -231,6 +238,14 @@ per-set weights. Android also publishes the V2 companion
`trainlog-equipment-associations-v2.json`; its `set` and `cleared` states are
targeted by `(session_id, entry_id)`.
Before either V2 artifact, Android publishes its user-created equipment
definitions as `trainlog-mobile-equipment-definitions-v1.json`. The strict
`trainlog-equipment-definitions` v1 format uses stable IDs and the fields
`equipment_id`, `display_name`, `label_name`, `equipment_type`, and
`load_semantics`. Absence never deletes a definition; equal same-ID definitions
are idempotent and divergent same-ID definitions conflict. Supplied-manifest
IDs are reserved.
The user does not need a separate manual export step before synchronization.
An active draft is never included in completed history, session detail or this
@ -277,6 +292,11 @@ The receipt is matched by `request_id`.
On success Android then applies the latest PC catalog and displays the final
result.
Before applying the PC catalog or its V2 artifacts, Android applies
`trainlog-pc-equipment-definitions-v1.json`. Thus custom definitions are known
before a received V2 association references them. Android schema v8 provides
the non-destructive v7 -> v8 migration required for `load_semantics = none`.
A receipt belonging to another request is ignored as pending rather than
misreported as the current result.
@ -314,13 +334,16 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
`local.properties` is local machine configuration and must not be committed.
Host regression suite: 8 tests. Device instrumentation: 5 tests (2 repository,
3 production-screen UI tests using an isolated database and no shared export).
The real device matrix additionally exercised production `MainActivity`,
The prior host regression suite had 8 tests and the prior device
instrumentation suite had 5 tests (2 repository, 3 production-screen UI tests
using an isolated database and no shared export). That device matrix exercised
production `MainActivity`,
including verified process exit with `am kill`, force-stop, configuration
relaunch, raw-form recovery, removal, discard and unchanged user data. Final-save
UI checks use isolated data so fictitious workouts do not enter user history.
See [tests](tests.md) for commands and the precise validation boundary.
UI checks used isolated data so fictitious workouts did not enter user history.
The schema-v8 definition change is recorded as targeted JVM validation, not a
blanket device-validation claim. See [tests](tests.md) for commands and the
precise validation boundary.
## 14. Non-goals
@ -333,9 +356,7 @@ Android is not intended to own:
- exercise-name heuristics;
- a mounted-filesystem dependency.
## 15. Test-max sessions
## 16. Equipment and multi-occurrence exchange V2
## 15. Equipment and multi-occurrence exchange V2
During exercise entry, `Machine / équipement (optionnel)` searches the shared
catalogue by display name, physical-machine label and aliases. The selected
@ -354,12 +375,14 @@ be separated with `;`. Assisted equipment is explicitly labelled
`Nouvelle machine` in that same selector creates a persistent local custom
equipment entry with a generated stable `eq_…` ID and selects it immediately.
The shared bundled catalogue is synchronized by canonical IDs; a custom ID is
not silently converted to null on the PC and is rejected until its definition
is available to the receiving catalogue.
The shared bundled catalogue uses reserved canonical IDs. User-created IDs are
exchanged first by definitions V1, so a V2 association can resolve them on the
receiving side; conflicts remain explicit and are never converted to null.
The active-session list exposes `Modifier <exercice>`; saving replaces that
entry in place, while cancelling only discards the form and preserves it.
## 16. Test-max sessions
Android session entry exposes:
```text
@ -383,3 +406,33 @@ from large repetition or duration values.
Android's current session form still records the exercise data fields it
supports. Measured-max classification on the desktop uses only actual values
that were truly captured and synchronized.
## 17. Audited limitations
Android's stored `normalized_name` currently removes diacritics, while the
frozen desktop/Python normalization contract uses NFC, Unicode whitespace
collapse and case folding without accent removal. Existing Marche/Leg press
data is unaffected, but changing this safely requires an explicit Android
schema migration that recomputes every normalized key and handles newly exposed
collisions. It is not silently changed inside schema v8.
The bundled exercise/equipment relationship metadata is seeded and preserved,
including during exercise-identity reconciliation, but the current equipment
picker searches all known supplied and custom definitions. Filtering or ranking
that picker by exercise relation remains future gym-catalog policy.
Custom equipment remains identified only by `equipment_id`: Android never
coalesces two different custom IDs merely because their labels match. This can
leave conceptually duplicated definitions created independently on two devices,
but avoids guessing across potentially different load semantics and persisted
occurrence references.
The PC-catalog V1 inbox validates required IDs, modes, names, and bounded field
masks, but unlike the newer mobile V2 and equipment-companion parsers it does
not reject every unknown root or item key. Tightening this published V1 reader
requires a compatibility decision rather than an incidental schema-v8 change.
Android requires `data_fields = 0` for `SETS`, while the desktop model/API
currently accepts known supplemental bits on either recording mode. Supplied
profiles do not exercise this difference. Supporting a future set-based
supplemental field requires an explicit shared-model decision.

View file

@ -20,7 +20,7 @@ Android capture client
shared desktop sync engine
/ \
/ \
desktop SQLite PC catalog artifact
desktop SQLite directional PC artifacts
| |
v v
desktop TUI Android
@ -120,7 +120,7 @@ Continuous work is persisted separately from performed sets.
### Desktop
Desktop SQLite schema v7 is canonical long-term history. `session_exercises`
Desktop SQLite schema v8 is canonical long-term history. `session_exercises`
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
occur more than once in one session without identity fusion.
@ -133,11 +133,12 @@ session_exercises
performed_sets
continuous_activity
body_observations
custom_equipment
```
### Android
Android has an independent local SQLite schema, currently v7.
Android has an independent local SQLite schema, currently v8.
It mirrors domain concepts needed for capture, but its schema version is not
coupled to the desktop schema.
@ -160,9 +161,12 @@ recovery preserves the raw fields and added exercises with a specific warning.
the display name and normalized form in the existing catalog row identified by
`exercise_id`; foreign-key ownership consequently preserves completed history
and active drafts. A profile edit is admitted only before that row is referenced
by either completed or active-draft data. Android and desktop same-ID catalog
reconciliation apply name metadata in place and reject a collision with a
different stable ID.
by either completed or active-draft data. Same-ID reconciliation applies name
metadata in place. A different-ID normalized-name collision may merge only for
equal recording/tracking modes, compatible represented invariants, and
`data_fields` masks comparable by inclusion. Desktop ownership wins on both
sides; the richer mask is retained and every occurrence/draft reference moves
transactionally. Otherwise synchronization reports a conflict.
Compose presentation has one `TrainlogScreen` header component for every page.
It uses the TUI's compact accent `◆ TRAINLOG ◆` plaque and muted context line;
@ -181,7 +185,10 @@ Synchronization uses separate formats:
```text
trainlog-mobile-export v1
trainlog-mobile-export v2
trainlog-pc-catalog v1
trainlog-equipment-associations v2
trainlog-equipment-definitions v1
trainlog-sync-request v1
trainlog-sync-receipt v1
```
@ -189,6 +196,15 @@ trainlog-sync-receipt v1
A new domain requirement must not be forced into frozen v1 by using notes,
synthetic sets, or data loss.
The active occurrence-aware session exchange remains
`trainlog-mobile-export` v2. The separate directional
`trainlog-equipment-definitions` v1 artifacts carry user-created equipment
definitions: `trainlog-mobile-equipment-definitions-v1.json` travels from
Android to PC and `trainlog-pc-equipment-definitions-v1.json` travels from PC
to Android. The filename identifies direction; the JSON format and version do
not change. V1 historical artifacts remain readable under their frozen
contracts.
## 6. Direct MTP transport
Linux transport:
@ -238,19 +254,45 @@ Android request
-> receipt
```
The engine performs:
The same engine owns all supported directions. Its explicit modes are:
```text
a = Android -> PC only
p = PC -> Android only
b = Android -> PC, then PC -> Android
```
The Android-to-PC direction receives the mobile equipment-definitions v1
artifact, mobile-export v2, and equipment-associations v2. The PC-to-Android
direction publishes PC equipment-definitions v1 before dependent artifacts,
then publishes the PC catalog v1, PC mobile-export v2 (including completed
sessions and body observations), and equipment-associations v2.
The engine performs the applicable direction steps:
```text
1. direct-MTP device/storage discovery
2. exchange-folder resolution
3. mobile snapshot download
4. strict transactional Android -> PC import
5. PC catalog export
6. direct-MTP PC catalog publication
3. definition artifact transfer and reconciliation before dependent V2 data
4. strict transactional Android -> PC import when selected
5. PC catalog, mobile/body, and association export when selected
6. direct-MTP publication of the selected PC -> Android artifacts
7. optional request receipt publication
8. structured run-history recording
```
Synchronization is additive and reconciliatory: artifact absence never implies
deletion of local content. Equal same-ID definitions are idempotent; divergent
same-ID definitions, unknown references, and association conflicts are reported
explicitly. The affected persisted content is preserved on conflict; the engine
does not silently overwrite it.
There is no exercise, session, body-observation, or equipment-definition
tombstone in the current formats. Omitting one of those objects from a later
snapshot is not a deletion request. The only explicit removal signal is
equipment-association V2 `state: cleared`, scoped to one
`(session_id, entry_id)` occurrence.
## 8. Synchronization concurrency
The shared engine serializes synchronization with:
@ -298,7 +340,7 @@ known errors with generic placeholders.
## 11. Layering rule
```text
ncurses / Compose rendering
Notcurses / Compose rendering
|
v
application workflow
@ -313,7 +355,7 @@ persistence / MTP transport
Rendering does not own persistence rules.
Persistence and MTP code do not depend on ncurses rendering.
Persistence and MTP code do not depend on Notcurses rendering.
## 12. Body analytics boundary
@ -345,3 +387,37 @@ with `~/.config` fallback.
This profile is not synchronized to Android and does not require a SQLite
schema change.
## 13. Audited evolution constraints
Each mutating importer has strict validation and a SQLite transaction. The V2
association companion is a strict corroboration of the equipment already
carried by the mobile snapshot and does not rewrite divergent state. However,
one definitions/mobile/associations publication has no common generation
manifest and is not one cross-artifact database transaction. A late association
conflict can therefore follow a successfully committed definitions or mobile
import; replay remains idempotent and existing conflicting content is not
overwritten. A future batch protocol must be separately versioned rather than
retrofitted into frozen formats.
Android and desktop also have different stored name-normalization behavior:
Android removes diacritics, while the frozen desktop/Python rule preserves them
through NFC plus Unicode case folding. Correcting existing Android keys requires
an explicit migration and collision policy. Finally, Android retains supplied
exercise/equipment relationship tables, but its current picker searches the
complete definition catalog; applying those relations as recommendations is
future gym-catalog behavior.
Custom-equipment reconciliation is deliberately ID-based. Different custom
IDs are not merged by equal or similar display names because their load
semantics and existing occurrence references may differ. The current PC
catalog V1 Android reader also validates its required semantic fields without
the exact-key rejection used by newer V2/companion readers; stricter V1 parsing
needs an explicit compatibility review.
The desktop model/API currently permits bounded supplemental field masks on a
`SETS` profile, while Android creation and V2 inbox validation require those
masks to be zero for `SETS`. The shipped catalog uses supplemental speed and
distance only with `CONTINUOUS`; defining cross-platform behavior for a future
set-based supplemental field is a model-contract decision, not part of this
reconciliation.

View file

@ -10,7 +10,7 @@ Avoid clever code when a straightforward implementation is easier to verify.
The TUI uses C17.
The project will initially target a strict warning profile similar to:
Meson enforces C17, warning level 3 and `werror=true`. Desktop targets also use:
```text
-Wall
@ -63,6 +63,12 @@ Comments are required for:
- error-handling decisions;
- algorithms whose intent is not immediately obvious.
For synchronization, persistence, stable identity, serialization, ownership,
ABI boundaries, concurrency, and scientific interpretation, comments identify
the relevant intent explicitly as `WHY`, `CONTRACT`, or `INVARIANT`. They belong
in the same change as the behavior and explain the non-obvious rule rather than
restating syntax.
Bad:
```c
@ -81,9 +87,9 @@ Good:
## 6. Formatting
The project will use `clang-format`.
A canonical `.clang-format` file will be added before substantial C implementation.
The repository currently has no canonical `.clang-format` file. Do not apply a
bulk formatter with tool-default behavior; introducing a format definition and
any broad normalization requires a dedicated, reviewable change.
Formatting changes should not be mixed with unrelated semantic changes when avoidable.

View file

@ -1,6 +1,6 @@
# Current implementation state
Canonical snapshot: 2026-09-07.
Canonical snapshot: 2026-09-08.
This document is the compact source of truth for the implemented Trainlog
baseline. Detailed behavior belongs in the topic-specific documents.
@ -14,8 +14,8 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V7=PASS
ANDROID_LOCAL_DATABASE_V7=PASS
DESKTOP_SCHEMA_V8=PASS
ANDROID_LOCAL_DATABASE_V8=PASS
ANDROID_SESSION_DRAFT_V1=PASS
ANDROID_DRAFT_DURABLE=PASS
ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS
@ -48,10 +48,12 @@ TUI_SYNC_LOG_SHOW=PASS
BIDIRECTIONAL_SYNC_V1=PASS
MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS
EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS
DESKTOP_TESTS=25/25 PASS
DESKTOP_TESTS=32/32 PASS
ANDROID_BUILD=PASS
HARDWARE_SYNC_VALIDATION=PASS
HARDWARE_SYNC_VALIDATION=HISTORICAL_BASELINE_ONLY
```
## Desktop
@ -59,12 +61,15 @@ HARDWARE_SYNC_VALIDATION=PASS
Implemented:
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
- SQLite schema v7, with stable ordered `session_exercises.entry_id` and
occurrence-level equipment identity;
- SQLite schema v8, with stable ordered `session_exercises.entry_id`,
occurrence-level equipment identity, and desktop-local custom-equipment
definitions;
- direct session entry;
- persisted session detail and editing;
- exercise removal from a session through transactional child replacement;
- exercise catalog;
- supplied-equipment browsing/search/detail and desktop-local custom-equipment
creation/selection;
- profile-aware set and continuous activities;
- heterogeneous repetition sets;
- multiple occurrences of one catalogue exercise in a session;
@ -73,7 +78,7 @@ Implemented:
- body graphs and normalized overlays;
- exercise performance history;
- direct USB/MTP device access;
- manual bidirectional synchronization;
- manual Android -> PC, PC -> Android, and bidirectional synchronization;
- structured synchronization history and detail.
Primary navigation:
@ -83,8 +88,9 @@ Primary navigation:
1 Séance
2 Historique
3 Exercices
4 Corps
5 Sync
4 Équipements
5 Corps
6 Sync
```
## Android
@ -92,7 +98,7 @@ Primary navigation:
Implemented:
- native Kotlin/Compose application;
- local SQLite database v7, with non-destructive v3 -> v7 migration;
- local SQLite database v8, with non-destructive v3 -> v8 migration;
- one durable active-session draft, Home resume and raw-form restoration;
- explicit confirmed discard and atomic completed-save/draft-clear;
- exercise creation;
@ -115,8 +121,12 @@ The Android catalog exposes **Modifier** for every existing exercise. A rename
updates `name` and `normalized_name` in the original row, never creates an ID,
and remains valid for completed session and active-draft references. A profile
change is only accepted while the row has neither completed-session nor draft
references. Same-ID catalog reconciliation updates display metadata in place in
both Android and desktop import directions.
references. Same-ID catalog reconciliation updates display metadata in place.
A different-ID normalized-name collision is reconciled only when recording and
tracking modes match, other known invariants are compatible, and one
`data_fields` mask contains the other. PC identity is canonical on Android;
the existing desktop identity is canonical on desktop. The richer bit-mask
union is retained without rewriting historical occurrence snapshots.
All Android screens use the shared compact `◆ TRAINLOG ◆` header: the
Notcurses accent, muted context line, and flat touch layout reproduce the TUI
@ -134,10 +144,12 @@ Artifacts:
```text
Android -> PC
trainlog-mobile-equipment-definitions-v1.json
trainlog-mobile-export-v2.json
trainlog-equipment-associations-v2.json
PC -> Android
trainlog-pc-equipment-definitions-v1.json
trainlog-pc-catalog-v1.json
trainlog-pc-mobile-export-v2.json
trainlog-equipment-associations-v2.json
@ -149,12 +161,51 @@ PC agent -> Android
trainlog-sync-receipt-v1.json
```
The desktop TUI and `trainlog-syncd` share `trainlog_sync_run()`.
The desktop TUI and `trainlog-syncd` share `trainlog_sync_run()`. Its explicit
`a`, `p`, and `b` modes perform Android -> PC only, PC -> Android only, and
inbound-then-outbound respectively. Each direction reconciles equipment
definitions V1 before V2 artifacts that reference them.
Each structured local sync-history run persists its selected `a`, `p`, or `b`
direction. `direction inconnue` is reserved for legacy history rows whose
direction was not recorded; it is never used for a current run.
On the TUI Sync page, `a`, `p`, and `b` directly open one confirmation for the
selected direction; `Enter` runs that confirmed action once, while `Esc`
cancels it without an operation (or returns when idle). `r` refreshes device
status only, and the former `s` shortcut is inert. Full and compact footers use
the same three directional actions, with compact labels only abbreviating the
endpoints.
V2 resolves equipment by `(session_id, entry_id)`, never by display name or
catalogue identity alone. V1 files remain legacy-compatible and do not gain
multi-occurrence semantics retroactively.
Supplied equipment remains generated from `catalog/equipment-v1.json`; its IDs
are reserved. User-created definitions synchronize additively through the
directional definitions V1 artifacts. Equal same-ID definitions are idempotent;
divergent definitions, unknown references, and association conflicts are
reported explicitly without silent overwrite.
Conflict summaries identify the stable session, body-observation, association,
or definition identity and the source artifact/direction. Existing persisted
content is preserved when a conflict is reported.
Synchronization addition counters describe durable inserts only. Exercise
rekeys or profile enrichments remain available through the separate
reconciliation counter; a compatible different-ID lookup that requires no
database write is an idempotent skip. This prevents a repeated Android
snapshot carrying a retired creator ID from being displayed as `+1 exercice`
when the canonical desktop catalog row and all business tables are unchanged.
The current Marche collision is safely reconciled to desktop identity
`ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d`. Its catalog profile is the compatible
union `continuous/duration/data_fields=3`; prior `data_fields=1` occurrences
retain their own snapshots and absent distance values. The Android occurrence
`sxe_f25142c8-455e-4346-9bfc-31d0989e275d` retains its duration, speed, distance
and equipment. No reference to the retired Android exercise identity remains
in the validated merged database copy.
No SQLite file is copied.
No mounted Android filesystem is required.
@ -164,35 +215,50 @@ No mounted Android filesystem is required.
Desktop:
```text
25/25 Meson tests PASS
32/32 Meson tests PASS for the current desktop schema v8 baseline
frozen JSON validator PASS
import-contract validator PASS
ASan/UBSan 32/32 Meson tests PASS
git diff --check PASS
```
Android:
```text
assembleDebug and Android unit tests are run for every Android delivery.
The prior device baseline below is hardware evidence, not a claim that every
new implementation detail was re-exercised on the device in this document.
device instrumentation 5/5 PASS
real Samsung background/process-death/force-stop/resume matrix PASS
real migration and original user-data preservation PASS
real Samsung request -> daemon -> bidirectional sync -> receipt PASS
multiple distinct request IDs consumed once each PASS
testDebugUnitTest PASS
assembleDebug PASS
```
Current real-data/environment boundary:
```text
REAL_ANDROID_ARTIFACT_COPY_ROUNDTRIP=PASS
PC_EXPORT_ANDROID_IMPORT_THREE_PASS_COPY=PASS
ANDROID_INSTALL_R_DATABASE_HASH_PRESERVED=PASS
CURRENT_SANDBOX_LIBMTP_OPEN=BLOCKED
REAL_PC_TO_ANDROID_TWO_RUN=BLOCKED_BY_SANDBOX_LIBMTP_OPEN
```
The current sandbox discovers the connected Samsung MTP interface but
`libusb_open()` cannot acquire it. It also exposes the canonical desktop DB as
read-only. The failed first write left that DB byte-for-byte logically
unchanged and integrity-clean; Android remained force-stopped and was not
modified through ADB. Therefore this checkpoint makes no new direct-device MTP
claim and does not claim that the real stores have consumed the reconciled
artifacts.
## Current implementation cursor
The Android draft correction is implemented, device-validated and reviewed.
No product-roadmap ordering changes were made.
Exercise reconciliation, definition-first V2 synchronization and the current
real-data importer/exporter copy validation are complete. No product-roadmap
ordering change was made by this corrective tranche.
```text
MEASURED_MAX_V1=PASS
WORKING_LOAD_PERCENTAGES=PASS
ANDROID_MAX_TEST_SESSION=PASS
NEXT_FEATURE=UNFROZEN
CURRENT_OPERATIONAL_CURSOR=REAL_DATA_BASELINE_V1
NEXT_FEATURE=GYM_CATALOG_V1
```
Future work must start from this validated baseline rather than from obsolete
@ -206,7 +272,7 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS
WORKING_LOAD_PERCENTAGES=PASS
ASSISTANCE_DIRECTION_AWARE=PASS
ANDROID_MAX_TEST_SESSION=PASS
DESKTOP_TESTS=25/25 PASS
DESKTOP_TESTS=32/32 PASS
```
A measured maximum is derived only from explicit `max_test` sessions. Ordinary
@ -227,7 +293,7 @@ BODY_COMPOSITION_ESTIMATE=PASS
BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS
NO_ESTIMATE_PERSISTENCE=PASS
DESKTOP_TESTS=25/25 PASS
DESKTOP_TESTS=32/32 PASS
```
Android remains capture-only for this feature.

View file

@ -3,8 +3,8 @@
## 1. Status
```text
TRAINLOG_DATABASE_SCHEMA_VERSION=7
DATABASE_SCHEMA_V7=PASS
TRAINLOG_DATABASE_SCHEMA_VERSION=8
DATABASE_SCHEMA_V8=PASS
TRAINLOG_FORMAT_V1=FROZEN
```
@ -23,7 +23,7 @@ PRAGMA user_version;
Current value:
```text
7
8
```
Supported historical databases are migrated explicitly through the implemented
@ -36,6 +36,11 @@ in a session. The v6 → v7 migration rebuilds the obsolete uniqueness
constraint while retaining rows, sets, continuous activities, weights and
equipment associations.
Version 8 adds local `custom_equipment` definitions. The v7 → v8 migration is
additive: it retains all historic occurrences and their `equipment_id` values.
Supplied definitions continue to be generated from `catalog/equipment-v1.json`;
custom definitions exist only in the desktop database.
A schema fixture must represent the real historical structure. Rewriting only
`user_version` is not an acceptable migration test.
@ -99,7 +104,7 @@ target_sets
target_reps
target_duration_seconds
target_weight_kg
equipment_id nullable canonical equipment identity
equipment_id nullable equipment identity
notes
```
@ -107,6 +112,21 @@ Current desktop history snapshots `recording_mode` and `data_fields` in the
session row. `tracking_mode` remains associated with the referenced exercise
catalog identity.
An occurrence `equipment_id` resolves to either a supplied manifest definition
or a desktop-local `custom_equipment` definition. An ID that cannot be
resolved is retained as historic data and is explicitly visible to the user;
it is never silently converted to `NULL`.
### `custom_equipment`
Desktop-local custom equipment definitions. They supplement, but do not alter
or replace, the supplied catalogue generated from `catalog/equipment-v1.json`.
They synchronize through the separate `trainlog-equipment-definitions` v1
artifact; session/mobile and association V2 artifacts retain their existing
shapes. Definition reconciliation is additive: omission does not delete, an
equal same-ID row is idempotent, a divergent same-ID row conflicts, and IDs in
the supplied manifest are reserved.
`SETS` rows support two target shapes in schema v5:
```text
@ -202,6 +222,7 @@ ex_ exercise
se_ session
bo_ body observation
sy_ synchronization run
sxe_ session-exercise occurrence
```
All use random UUIDv4 values.
@ -241,7 +262,7 @@ committing database changes.
Properties:
```text
schema-v5 aware
schema-v5-through-v8 aware
transactional
idempotent by stable IDs
profile-aware catalog reconciliation
@ -250,6 +271,15 @@ no fake target generated
continuous activity kept separate
```
For active V2 reconciliation, an identical normalized name never suffices by
itself. Different exercise identities may coalesce only when recording and
tracking modes match, other represented invariants remain compatible, and
their bounded `data_fields` masks are comparable by inclusion. The existing
desktop identity is deterministic canonical ownership. The mask union retains
the richer capability, while `session_exercises.data_fields` and all child
values remain unchanged. A missing historic optional value stays `NULL`.
Incomparable masks or modes reject the entire mobile-import transaction.
## 8. Units
Canonical desktop persistence:
@ -266,8 +296,10 @@ distance km
The Android SQLite database is independent.
Current Android-local version: **4**. The explicit v3 -> v4 migration only adds
structured draft tables; it does not rebuild or delete existing domain tables.
Current Android-local version: **8**. The explicit migration chain adds the
durable draft in v4, equipment references in v5, per-set load in v6, occurrence
identity/multi-occurrence support in v7, and the widened custom-equipment
definition graph in v8.
| Table | Ownership |
| --- | --- |
@ -275,6 +307,8 @@ structured draft tables; it does not rebuild or delete existing domain tables.
| `draft_session_exercises` | Ordered draft exercises and profile snapshots |
| `draft_performed_sets` | Ordered heterogeneous repetition or duration actuals |
| `draft_continuous_activity` | Duration and configured speed/distance without synthetic sets |
| `equipment`, `equipment_aliases` | Supplied and user-created definitions used by selectors and occurrence FKs |
| `exercise_equipment`, `catalog_exercise_equipment` | Persisted manifest relationship metadata retained across migrations and identity reconciliation |
Foreign keys remain enabled. Draft deletion cascades only through draft child
tables; it cannot delete catalog entries or completed history. The repository
@ -282,10 +316,28 @@ commits completed-session insertion and draft removal together, rolling back
both on failure. Repeating finalization after success cannot create another
completed session. Completed `started_at` semantics are unchanged by this repair.
Migration tests use a real v3-shaped fixture. The physical Samsung upgrade also
preserved every existing domain row, with successful integrity and foreign-key
checks. Device backup files are outside the repository; no SQLite files are
used as synchronization artifacts.
Migration tests cover historical v4 and v7 shapes rather than changing only
`user_version`. The prior physical Samsung migration preserved every existing
domain row, with successful integrity and foreign-key checks. Current
reconciliation validation additionally uses coherent Android and desktop v8
copies whose integrity and foreign keys are checked before and after import.
SQLite files are never synchronization artifacts.
The schemas deliberately differ where ownership differs. Desktop sessions own
`ended_at`, notes, planned targets and session-specific load semantics; the
Android capture schema does not. Desktop body observations may link to a
session and carry notes; Android body observations contain capture metrics only.
Android occurrences snapshot `tracking_mode` as well as recording mode and
fields, whereas the current desktop schema resolves tracking mode through the
catalog exercise. Desktop stores occurrence `equipment_id` as stable text so
unknown historic IDs remain visible; Android stores a foreign key to its local
equipment definition.
Audit limitation: Android persists supplied exercise/equipment relationship
tables, but the current session selector searches the complete equipment list
instead of filtering or ranking it through those relations. The tables are
retained because migration and identity reconciliation already preserve them;
making them authoritative UI policy is future catalog work, not a v8 cleanup.
Desktop and Android schema versions are not required to match.
@ -302,13 +354,15 @@ Migration-specific regression coverage includes:
```text
schema_v5_migration
schema_v7_migration
exercise_reconciliation
```
The current normal desktop suite contains 25 tests.
The current normal desktop suite contains 32 tests.
## 11. Measured-max derivation
Measured maxima require no schema change beyond the current desktop schema v7.
Measured maxima require no schema change beyond the current desktop schema v8.
The existing `sessions.session_type = max_test` classification plus actual
`performed_sets` are sufficient.
@ -341,23 +395,31 @@ is the best max-test point using the same load mode.
No extra maximum row is persisted; results are derived from canonical history.
## 12. Body analytics persistence rule
## 12. Equipment and occurrence migration
## 13. Equipment and occurrence migration
Desktop schema v6 added nullable `session_exercises.equipment_id`, which stores
a canonical manifest ID rather than a local SQLite row ID. Schema v7 adds the
non-null stable `entry_id` and removes the obsolete
Desktop schema v6 added nullable `session_exercises.equipment_id`. Schema v7
adds the non-null stable `entry_id` and removes the obsolete
`UNIQUE(session_row_id, exercise_row_id)` constraint. The v6 -> v7 rebuild
preserves primary keys, completed sessions, ordered sets, continuous activities,
per-set weights and equipment values.
Schema v8 adds local `custom_equipment` definitions through an additive v7 ->
v8 migration. Existing occurrence links remain unchanged. They resolve through
the supplied manifest or the local custom table; unknown historic references
remain explicit rather than being discarded.
Android schema v6 added nullable `weight_kg` to completed and durable draft
set rows. Schema v7 assigns stable `entry_id` values to completed and draft
occurrences. Actual per-set weights remain independent values, so heterogeneous
sets and weights survive edit, finalization, reopen and V2 exchange.
Body analytics require no schema change beyond schema v7.
Android schema v8 non-destructively migrates the v7 equipment reference graph
so custom definitions may use `load_semantics = none`. Historic completed
occurrences, draft occurrences, and their equipment references remain intact.
## 13. Body analytics persistence rule
Body analytics require no schema change beyond schema v8.
Canonical persistence continues to contain only measurements actually entered
by the user.

View file

@ -10,9 +10,9 @@ GATE_1=PASS
TRAINLOG_FORMAT_V1=FROZEN
```
This document defines the proposed final Trainlog v1 exchange contract.
The format remains `DRAFT` until Gate 1 validation and mirror review complete.
This document defines the frozen Trainlog v1 exchange contract. Gate 1
validation and review are complete; incompatible new semantics require a new,
separately versioned format.
## 2. Design goal

View file

@ -24,10 +24,14 @@ data_fields = supplemental field bit mask
Known supplemental fields:
```text
SPEED_KMH
DISTANCE_KM
SPEED_KMH bit 0, mask value 1
DISTANCE_KM bit 1, mask value 2
```
Consequently `data_fields = 1` means speed only and `data_fields = 3`
means speed plus distance. These meanings come from the shared C/Kotlin model
constants and serializers; the masks are not ordinal profile numbers.
Valid model-v1 combinations:
```text
@ -172,6 +176,11 @@ Current desktop tracking mode remains tied to the referenced exercise identity.
Any future change to that historical contract requires an explicit schema
decision rather than an implicit name-based migration.
Catalog enrichment does not rewrite an occurrence snapshot. For example, a
historic `data_fields = 1` walk remains a speed-only occurrence after its
catalog definition becomes `data_fields = 3`. Its absent distance stays
absent/`NULL`; synchronization does not infer it from duration and speed.
## 8. Android/TUI parity
Both interfaces use the same metadata axes.
@ -188,6 +197,29 @@ data_fields
Creating an exercise inline on Android or desktop follows the same model rules.
### Safe V2 identity reconciliation
A normalized-name collision between distinct `exercise_id` values is eligible
for automatic V2/catalog reconciliation only when:
- the normalized name is identical under the receiving store's current rule;
- `recording_mode` and `tracking_mode` are identical;
- all other represented business invariants, including overlapping equipment
load semantics on Android, are compatible;
- the `data_fields` masks are equal, or one is a bitwise subset of the other;
- moving references loses no occurrence, actual value or metadata.
The existing desktop identity is canonical. Android therefore adopts the PC
identity when applying a PC catalog. The catalog stores the bitwise union (the
compatible richer profile), while sessions retain their own `entry_id`, order,
profile snapshot, sets, loads, continuous values and equipment. The operation
is transactional and replay-safe. Different modes, incomparable masks, or any
other incompatible invariant produce an explicit conflict. Equal names alone
never establish identity.
This is a synchronization-V2 policy. It does not relax or redefine the frozen
Trainlog JSON v1 document rules.
## 9. Exchange boundaries
Frozen Trainlog session JSON v1 remains unchanged.

View file

@ -14,8 +14,8 @@ GATE_1=PASS
GATE_2=PASS
TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V7=PASS
ANDROID_LOCAL_DATABASE_V7=PASS
DESKTOP_SCHEMA_V8=PASS
ANDROID_LOCAL_DATABASE_V8=PASS
DIRECT_MTP_TRANSPORT=PASS
BIDIRECTIONAL_SYNC_V1=PASS
@ -26,7 +26,7 @@ BODY_ANALYTICS_V1=PASS
EXERCISE_EDIT_V1=PASS
ANDROID_BANNER_PARITY_V1=PASS
DESKTOP_TESTS=25/25 PASS
DESKTOP_TESTS=32/32 PASS
TUI_NOTCURSES_V1=PASS
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
NOTCURSES_TRUECOLOR_THEME=PASS
@ -46,6 +46,8 @@ The current product baseline includes:
- desktop body analytics;
- direct USB/MTP transport;
- bidirectional Android/PC synchronization;
- safe V2 exercise-identity reconciliation with richer compatible profiles;
- supplied and custom equipment definitions with occurrence-level links;
- shared synchronization engine and `trainlog-syncd`.
`EXERCISE_EDIT_V1` is a completed capture correction: Android permits

View file

@ -14,6 +14,8 @@ TUI_SYNC_LOG_SHOW=PASS
BIDIRECTIONAL_SYNC_V1=PASS
MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS
EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
```
@ -40,8 +42,10 @@ Framework folder grant.
| --- | --- | --- |
| Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 |
| Android -> PC | `trainlog-mobile-export-v2.json` | `trainlog-mobile-export` v2 (active) |
| Android -> PC | `trainlog-mobile-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 |
| Android -> PC | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
| PC -> Android | `trainlog-pc-catalog-v1.json` | `trainlog-pc-catalog` v1 |
| PC -> Android | `trainlog-pc-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 |
| PC -> Android | `trainlog-pc-mobile-export-v2.json` | `trainlog-mobile-export` v2 |
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
@ -49,6 +53,20 @@ Framework folder grant.
No SQLite file is transferred.
Android scoped storage can preserve a prior MTP-created object and create a
new artifact with the provider collision suffix, for example
`trainlog-mobile-export-v2 (N).json` or
`trainlog-mobile-equipment-definitions-v1 (N).json`, or
`trainlog-equipment-associations-v2 (N).json`. For Android -> PC, the engine
accepts only the canonical name and this exact suffix form, selects the newest
MTP modification time (then the greatest suffix and a deterministic object-ID
tie break), and validates that selected artifact normally. It never silently
falls back to an older candidate when the newest one is malformed. Definitions
reconcile before the V2 snapshot, so a valid custom `equipment_id` is known
before a session may reference it. This prevents an older canonical object from
being mistaken for Android's current data while preserving every file in
`Download/Trainlog`.
V2 is a separate format: every session entry has an `entry_id`, `position`,
metrics, actual loads and optional equipment identity. This permits two
occurrences of the same exercise without fusion. V1 remains readable with its
@ -56,7 +74,33 @@ frozen contract. A V1 historical session is reconciled with V2 only when the
exercise/order correspondence is unambiguous; otherwise the importer reports
a conflict rather than silently overwriting data.
## 4. Android -> PC mobile snapshot
## 4. Equipment definitions V1
User-created equipment definitions use the separate, directional
`trainlog-equipment-definitions` v1 artifact. The Android-to-PC filename is
`trainlog-mobile-equipment-definitions-v1.json`; the PC-to-Android filename is
`trainlog-pc-equipment-definitions-v1.json`. The filenames identify direction;
the JSON format and version are the same.
The strict root has exactly `format`, `version`, `generated_at`, and
`equipment`. Each `equipment` item has exactly:
```text
equipment_id
display_name
label_name
equipment_type
load_semantics none | external | assistance
```
Definitions are additive snapshots, not deletion instructions: absence never
deletes a local definition. A same-ID, field-for-field equal definition is an
idempotent skip. A same-ID divergent definition is a conflict. IDs reserved by
the supplied equipment manifest cannot appear in this artifact. These rules
preserve the definition identity that V2 equipment associations reference;
they do not change either V2 shape.
## 5. Android -> PC mobile snapshot
Header:
@ -75,6 +119,12 @@ sessions
body_observations
```
Android captures its custom-definition V1 companion, this V2 snapshot, and
the equipment-association companion before it publishes any of them. It then
publishes in that order: definitions, V2 snapshot, associations. A malformed
persisted custom definition aborts publication before a V2 file can advertise
its reference; bundled manifest equipment is never copied into definitions V1.
V2 session entries additionally carry:
```text
@ -84,10 +134,11 @@ equipment_id optional canonical equipment identity
weight_kg optional actual value on each set
```
The desktop imports sessions first, preserving `entry_id`, then applies the
equipment companion only after all referenced entries exist. Reimporting either
artifact reconciles stable identities; it neither duplicates sessions nor
regenerates occurrence IDs.
The desktop imports sessions first, preserving `entry_id` and their equipment,
then validates the equipment companion only after all referenced entries exist.
The companion corroborates explicit `set`/`cleared` state; it does not overwrite
a divergent occurrence. Reimporting either artifact reconciles stable
identities; it neither duplicates sessions nor regenerates occurrence IDs.
Exercise profile fields:
@ -137,7 +188,7 @@ catalog reconciliation preserves active draft references.
Same-ID catalog entries may update display-name metadata in their existing
catalog row; a rename never creates a second exercise identity.
## 5. Desktop mobile importer
## 6. Desktop mobile importer
Reference importer:
@ -160,7 +211,38 @@ continuous activity kept separate
The importer never invents a uniform target merely to fit desktop persistence.
## 6. PC -> Android catalog
### Exercise identity reconciliation
The active V2/catalog path may reconcile distinct `exercise_id` values sharing
one normalized name only when recording mode and tracking mode are equal, all
other represented invariants are compatible, and one bounded `data_fields`
mask contains the other. The existing desktop identity is deterministic
canonical ownership; Android adopts that PC identity when applying the outbound
catalog. The bitwise union keeps the richer compatible profile.
All references move inside the relevant SQLite transaction. Session and
occurrence IDs, position, sets, loads, continuous duration/speed/distance,
equipment and draft data remain unchanged. Historical occurrence masks remain
snapshots, so an optional field newly present in the catalog is not invented in
old work. Replaying either direction is idempotent. A different mode,
incomparable masks, conflicting overlapping equipment semantics, or any unsafe
reference condition produces an explicit conflict. A normalized-name match by
itself never authorizes a merge.
The current Marche case is the compatible `1` (speed) versus `3` (speed and
distance) subset/superset case. The canonical desktop definition is enriched
to `3`; speed-only history stays speed-only and the Android occurrence keeps
its real distance.
Exercise import reporting separates persistent insertions, persistent
reconciliation changes, and idempotent skips. Resolving a different incoming
ID to an already-compatible canonical desktop row without changing that row is
an idempotent lookup, not a new insertion or a repeated mutation. Accordingly,
the TUI/history summary's `+N exercice(s)` value is exactly the number of new
catalog rows inserted; detailed structured records retain the separate
`exercises_reconciled` and `exercises_skipped` values.
## 7. PC -> Android catalog
The desktop publishes:
@ -171,10 +253,13 @@ trainlog-pc-catalog-v1.json
It is a canonical exercise catalog snapshot containing stable profile metadata.
Android reconciles the received catalog into its local exercise catalog.
Applying the same catalog again leaves a previously re-keyed exercise on the
canonical PC identity and reports it as an identical skip. Session occurrences,
active-draft references, and equipment links keep their existing row targets.
This direction does not overload frozen Trainlog session JSON v1.
## 7. Android sync request
## 8. Android sync request
Android writes:
@ -201,7 +286,7 @@ The artifact also carries the request timestamp.
A new `request_id` represents a new synchronization request.
## 8. PC sync receipt
## 9. PC sync receipt
After processing an Android request, the PC publishes:
@ -232,7 +317,7 @@ PC -> Android catalog count
Android accepts a receipt only when its `request_id` matches the pending
request.
## 9. Shared desktop engine
## 10. Shared desktop engine
Canonical implementation:
@ -256,21 +341,29 @@ Android request
-> receipt
```
One synchronization transaction performs:
The engine has three explicit modes:
```text
mobile snapshot download
-> mobile import
-> equipment companion import by (session_id, entry_id)
-> PC catalog export
-> PC mobile V2 export
-> PC equipment companion V2 export
-> PC catalog MTP publication
-> optional receipt publication
-> structured run history
a Android -> PC: definition V1 -> mobile V2 -> association V2; no publish
p PC -> Android: definition V1 -> catalog V1 -> mobile V2 (including bodies)
-> association V2; no receive
b bidirectional: complete inbound sequence, then complete outbound sequence
```
## 10. Concurrency and request consumption
In both directions, definitions are reconciled before V2 artifacts that may
reference their IDs. A mode retains normal validation, transactions, conflict
reporting, and structured history for the work it performs.
## 11. Conflict reporting and preservation
Synchronization does not silently overwrite a session, body observation,
equipment association, or equipment definition when stable-identity content
conflicts. The diagnostic identifies the affected stable identity and its
source artifact/direction, then records a concise source summary in the run
history. The conflicting persisted value remains preserved; resolution is an
explicit correction or reconciliation, not a side effect of synchronization.
## 12. Concurrency and request consumption
Synchronization owns:
@ -285,7 +378,7 @@ The TUI manual action waits for the active synchronization lock.
After a request is completed and its receipt is published, the request ID is
recorded locally so the same request is not processed as a new request again.
## 11. Structured sync history
## 13. Structured sync history
Every real run has:
@ -293,6 +386,12 @@ Every real run has:
sy_<uuid-v4>
```
Each structured run records the selected synchronization direction as `a`,
`p`, or `b` together with its result summary. Thus every current local-history
entry has a known direction. `direction inconnue` applies only to a legacy row
whose historical representation did not record one; the direction is not
inferred.
Artifacts:
```text
@ -305,9 +404,10 @@ The TUI presents newest runs in a selectable list and opens the detail file with
`Enter`.
Legacy history rows without a `sync_id` remain readable as list entries but
cannot have structured detail.
cannot have structured detail; a legacy row also has `direction inconnue` only
when its historical representation lacks a direction.
## 12. PC user service
## 14. PC user service
Install or refresh:
@ -330,7 +430,7 @@ tail -f ~/.local/state/trainlog/syncd.log
No root privilege is required.
## 13. Transport invariants
## 15. Transport invariants
Do not regress to:
@ -343,25 +443,34 @@ fake uniform targets for heterogeneous actual sets
overloading frozen Trainlog JSON v1
```
## 14. Hardware validation
## 15. Equipment associations V2 and legacy V1
## 16. Equipment associations V2 and legacy V1
`TRAINLOG_FORMAT_V1` remains frozen. The active companion is
`trainlog-equipment-associations-v2.json`, format
`trainlog-equipment-associations`, version `2`. Each row is identified by
`(session_id, entry_id)` and contains `exercise_id` as consistency metadata,
then either `state: set` with a canonical `equipment_id`, or `state: cleared`
for an intentional removal. A missing companion conveys no equipment
information and cannot clear a previously known choice. Unknown canonical IDs,
unknown entries and ambiguous identities reject the companion transaction
explicitly; an unknown equipment reference is never silently changed to null.
for an intentional removal. In the current V2 flow, the mobile snapshot already
carries the occurrence equipment value and the companion validates it. A
missing companion conveys no equipment information and cannot clear a
previously known choice. Unknown canonical IDs, unknown entries, ambiguous
identities and divergent values reject processing explicitly; an unknown
equipment reference is never silently changed to null.
V2 association semantics are unchanged. A custom equipment ID is accepted only
after its definition V1 artifact has reconciled it on the receiving side.
Unknown IDs, unknown entries, ambiguous identities, and definition conflicts
reject processing explicitly; they are never converted to null or silently
overwritten.
The historical V1 companion remains readable only where its
`(session_id, exercise_id)` targeting is unambiguous. It cannot represent two
occurrences of the same exercise in one session and is not redefined to do so.
Validated on the physical Android device:
## 17. Hardware validation
The following is a prior hardware baseline; it does not claim device validation
of the definitions V1 or three-mode synchronization change:
```text
MTP device discovery PASS
@ -375,3 +484,52 @@ trainlog-syncd processing PASS
receipt publication/readback PASS
multiple distinct Android request IDs PASS
```
The current reconciliation checkpoint pulled the real Android v8 database and
shared-storage artifacts through ADB for read-only inspection, then ran the
production definition, V2 mobile, association, body and outbound exporters on a
coherent desktop v8 copy. The second inbound/outbound replay was stable and both
SQLite integrity checks passed. This is real-data importer/exporter evidence,
not a claim that the current libmtp transport ran inside the sandbox.
The PC-to-Android idempotence regression additionally feeds artifacts from all
four production PC exporters into the production Android repository importers.
Its first pass imports the missing fixture data; its second and third passes
report zero session, exercise, body-observation, and equipment additions and
leave exact snapshots of every Android business table unchanged. On the real
device, `install -r` of the validated APK preserved the backed-up database hash,
but the requested two live MTP runs remain unexecuted because the sandbox still
fails `libusb_open()` before opening device storage.
## 18. Audited protocol limitations
Each mutating importer validates strictly and owns a SQLite transaction. The
V2 association companion only corroborates equipment already imported in the
mobile snapshot and refuses divergent state. A complete
definitions/mobile/associations batch nevertheless has no common generation ID
or cross-file transaction. Independent “newest artifact” selection can
therefore observe a partially published generation; validation stops on a
mismatch, but an earlier artifact may already have committed. Replay is
idempotent and no conflicting local value is overwritten. A future atomic-batch
design requires a new versioned manifest rather than a semantic change to any
published format.
Snapshots carry no exercise, session, body-observation, or equipment-definition
tombstones. Omission therefore never deletes one of those objects. The only
explicit removal operation is association V2 `state: cleared`, targeted to one
`(session_id, entry_id)`.
Custom equipment is reconciled by stable `equipment_id`, never by display
name. Different custom IDs may coexist even when their names are conceptually
similar; a name-only merge could corrupt load semantics or occurrence
references and is intentionally forbidden.
The Android PC-catalog V1 reader validates the required catalog identity and
exercise-profile fields, but does not enforce an exact root/item key set as
strictly as the newer mobile V2 and equipment companions. Tightening that
published V1 reader requires a compatibility review.
The historical V1 fallback never consumes a V2 equipment companion: V1 carries
no occurrence-level equipment signal, so a neighboring V2 companion belongs to
a different generation and is ignored. This preserves rather than clears
existing equipment.

View file

@ -56,42 +56,54 @@ different ID + equivalent normalized name -> reject
new unique identity -> create
```
That command validates the frozen Trainlog JSON V1 importer contract. Active
mobile V2 synchronization has a separate, stricter safe-reconciliation policy
covered below; it does not modify the frozen V1 expectation.
## 4. Desktop Meson suite
Current normal suite:
Current registered set (Meson executes tests in parallel, so displayed order is
not contractual):
```text
1 database
2 catalog
3 equipment_catalog
4 session_detail
5 duration
6 body_metrics
7 bodyviz
8 exercise_performance
9 session_type_schema
10 session_edit
11 body_observation_edit
12 mtp
13 continuous_session
14 continuous_detail
15 exercise_profile_schema
16 usb
17 reps
18 variable_sets
19 schema_v5_migration
20 measured_max
21 body_analytics
22 terminal_input_event_type_policy
23 mobile_import_multi_occurrence
24 equipment_associations_exchange
25 mobile_import_variable_sets
database
catalog
equipment_catalog
custom_equipment
session_detail
duration
body_metrics
bodyviz
exercise_performance
session_type_schema
session_edit
body_observation_edit
usb
mtp
exercise_profile_schema
continuous_session
continuous_detail
reps
variable_sets
schema_v5_migration
schema_v7_migration
mobile_import_variable_sets
mobile_import_multi_occurrence
equipment_associations_exchange
equipment_definitions_exchange
exercise_reconciliation
sync_direction
sync_history
sync_screen_action
measured_max
body_analytics
terminal_input_event_type_policy
```
Validated checkpoint:
Validated current suite:
```text
25/25 PASS
32/32 Meson tests PASS
```
The desktop executable is additionally smoke-checked in isolated tmux PTYs at
@ -107,7 +119,7 @@ Notable regression coverage:
- profile-aware exercise constraints;
- continuous activity without fake sets;
- repetition shorthand/list/pyramid parsing;
- direct v4 -> v7 database migration;
- direct v4 -> v7 database migration and v7 -> v8 custom-equipment migration;
- heterogeneous mobile-set import;
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
RELEASE event is consumed without creating a second navigation action.
@ -120,6 +132,34 @@ Notable regression coverage:
`--database` target, followed by the corrected complete V2 export chain.
- stable-ID mobile-to-desktop rename reconciliation without duplicate catalog
rows or historical-reference replacement.
- supplied catalogue search/detail, local custom-equipment creation and
selection, resolution of supplied/local occurrence links, and explicit
visibility of an unknown historic equipment reference;
- strict custom-equipment definitions V1 import/export: reserved supplied IDs,
additive omission, equal reimport, and divergent same-ID conflict;
- three-mode shared-engine plans and their directional receive/publish bounds;
- Sync-page action dispatch: direct `a`/`p`/`b` confirmation, one `Enter` run,
`Esc` cancellation without a run, refresh-only `r`, inert retired `s`, and
equivalent full/compact footer direction labels;
- definition-first ordering before V2 artifacts, so associations can resolve
custom IDs without changing V2 shapes.
- exercise reconciliation with identical profiles, compatible subset/superset
masks, explicit incomparable-profile rejection, current Marche identities,
preservation of two historic occurrences and their `entry_id`/continuous
values, ordered custom-equipment definitions/catalog/mobile/association
replay, complete definitions/mobile/associations/body import, outbound
publication, and stable second replay;
- a production PC-exporter to Android-importer regression over definitions,
catalog, mobile V2, and association V2 artifacts: the fixture includes
Marche, Leg press, two Marche occurrences, per-set loads, body data, a
durable draft, and custom equipment; after the first import, both the second
and third imports report zero additions and an exact snapshot of every
Android business table remains unchanged;
- synchronization summary reporting where a reconciliation-only result keeps
`+0 exercice(s)` and only an actual inserted row produces `+1`;
- all fourteen body metrics through the same complete reconciliation import;
- desktop-created occurrence identities use `sxe_<uuid-v4>`, not the `sy_`
synchronization-run prefix.
## 5. Build
@ -141,7 +181,7 @@ cd android
printf 'sdk.dir=%s\n' "$HOME/Android/Sdk" > local.properties
JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
./gradlew assembleDebug
./gradlew testDebugUnitTest assembleDebug
```
Install to the connected device when hardware behavior changes:
@ -184,6 +224,15 @@ MTP_READ=PASS
MTP_ROUNDTRIP=PASS
```
The automated `sync_direction` regression also covers selection of the newest
Android V2 export, equipment-definitions V1 artifact, and equipment-association
V2 artifact when scoped storage has retained collision-suffixed sibling files.
It rejects non-versioned/
non-numeric names and uses deterministic ties; JSON validation remains part of
the normal import path. `mobile_import_multi_occurrence` exercises the real
definition importer before the V2 session importer, including idempotent
reimport and rejection of a missing custom definition.
## 8. Bidirectional synchronization validation
Validated workflow:
@ -263,10 +312,10 @@ Coverage proves:
- external working loads round to the configured increment;
- working-load percentages reject assistance.
Current normal baseline:
Validated current normal suite:
```text
25/25 PASS
32/32 Meson tests PASS
```
## 12. Body analytics regression
@ -288,17 +337,18 @@ Coverage includes:
- missing required circumference handling;
- invalid estimation-profile rejection.
Current normal baseline:
Validated current normal suite:
```text
25/25 PASS
32/32 Meson tests PASS
```
## 13. Android session draft v1
Android schema v4 introduced one durable active draft; the current additive
chain reaches schema v7 without clearing completed history or the draft. The
current host suite covers exercise
chain reaches schema v8 without clearing completed history or the draft. The
current `testDebugUnitTest` suite and `assembleDebug` pass. Host coverage
includes exercise
shapes and raw partial text, fresh repository restore, remove/discard, atomic
finalization and repeated-finalize rejection, rollback, catalog reconciliation,
missing-selection recovery, explicit DB-open failure, historical migration,
@ -314,7 +364,9 @@ adb shell am instrument -w -e package com.labfytools.trainlog \
com.labfytools.trainlog.test/androidx.test.runner.AndroidJUnitRunner
```
The device instrumentation suite has **5 tests**: two real-SQLite repository
The following device instrumentation evidence is a prior baseline, not a
blanket device-validation claim for schema-v8 definitions or three-mode sync.
That suite has **5 tests**: two real-SQLite repository
checks and three production-screen Compose UI checks. Coverage includes exact
raw form restoration through Activity recreation, cancellation and confirmation
of discard, final save with no stale Resume after recreation, and refusal of a
@ -342,4 +394,35 @@ and final state passed SQLite integrity/foreign-key checks. The pre-upgrade DB
and preferences backup is outside the repository. No uninstall, package-data
clear, desktop schema change or frozen artifact change is part of this repair.
## 14. Current real-data reconciliation validation
Before mutation, coherent desktop and Android v8 copies were created and passed
`PRAGMA integrity_check` plus `PRAGMA foreign_key_check`. The newest real
Android V2 artifacts were selected, including scoped-storage `(N)` siblings.
The production tools then ran in protocol order on the desktop copy:
```text
equipment definitions -> exercise/session/body V2 -> equipment associations
-> PC definitions/catalog/mobile/associations export -> second import
```
The result retained the desktop Marche identity with catalog
`data_fields = 3`, both older speed-only Marche occurrences, the Android
speed-and-distance occurrence, all original `entry_id`/positions, custom
equipment, set weights and body observations. The retired Android Marche ID had
no remaining desktop catalog/reference owner. The second replay changed no row
counts and both integrity checks remained clean.
This exercises real artifacts and production import/export code on copies. It
does not substitute for a final direct-MTP run when USB ownership or sandbox
permissions prevent libmtp access.
For this checkpoint, the USB probe discovered the connected Samsung interface
but libmtp failed at `libusb_open()`. The canonical desktop DB was also
read-only to the sandbox; the first attempted definitions import failed before
mutation, and a repeated dump hash plus integrity/FK checks proved it unchanged.
The Android package remained force-stopped and ADB was used only for read-only
inspection/pull. Applying the validated state to both real stores and running
the two transport directions remains an out-of-sandbox hardware validation.
Detailed retained evidence: [Android draft execution record](reviews/android_session_draft_v1_resume.md).

View file

@ -17,8 +17,9 @@ Large-layout primary navigation:
1 Séance
2 Historique
3 Exercices
4 Corps
5 Sync
4 Équipements
5 Corps
6 Sync
```
Direct shortcuts include the matching function keys where implemented.
@ -144,9 +145,24 @@ A failed replacement rolls back completely.
Removing an exercise from one session does not remove the exercise from the
catalog.
## 7. Body tracking
## 7. Equipment
`4 Corps / F4` provides:
`4 Équipements / F4` provides supplied-equipment browsing, search, detail, and
custom-equipment creation and selection. Supplied definitions are generated
from `catalog/equipment-v1.json`; user-created definitions persist in desktop
SQLite and synchronize separately through definitions V1.
Equipment can be selected while editing an exercise occurrence. Session detail,
exercise history, and historical occurrence views resolve an `equipment_id`
against the supplied manifest or a local custom definition. If an historic ID
is no longer resolvable, the view shows that unknown reference explicitly.
Session detail displays the selected occurrence's stable `entry_id` together
with its resolved equipment, so repeated appearances of one exercise remain
visibly distinct; `i` opens the resolved equipment detail.
## 8. Body tracking
`5 Corps / F5` provides:
- newest-first body observations;
- detail and correction;
@ -157,7 +173,7 @@ catalog.
Editing preserves observation identity, timestamp, and optional session link.
## 8. Dashboard
## 9. Dashboard
The dashboard includes a rolling 12-month normalized body graph.
@ -172,7 +188,7 @@ Rules include:
Detailed raw observations remain in `Corps`.
## 9. Exercise performance
## 10. Exercise performance
Exercise detail exposes recorded performance history.
@ -193,9 +209,10 @@ assistance
A best recorded set is not automatically a measured maximum.
## 10. Sync page
## 11. Sync page
`5 Sync / F5` uses the shared synchronization engine.
`6 Sync` uses the shared synchronization engine. The supported shortcut is the
numeric `6` key.
The page shows:
@ -206,10 +223,37 @@ The page shows:
Manual action:
```text
s run bidirectional synchronization
a run Android -> PC synchronization
p run PC -> Android synchronization
b run bidirectional synchronization
r refresh device status
```
`a` imports definitions V1, mobile V2, and associations V2 only. `p` publishes
definitions V1, catalog V1, mobile V2 (including body observations), and
associations V2 only. `b` completes that inbound sequence before beginning the
outbound sequence.
The direction keys are direct actions: pressing `a`, `p`, or `b` opens one
confirmation for that exact direction; there is no separate mode-selection
step. `Enter` accepts the pending action and can invoke the shared engine only
once. `Esc` cancels a pending confirmation without an operation; when no
confirmation is pending, `Esc` returns from Sync. `r` only refreshes connected
device status. The former `s` synchronization shortcut is retired and inert.
The footer states the same contract in both layouts. At 100 columns or wider it
reads `a Android→PC p PC→Android b PC↔Android r actualiser Échap retour`;
the compact footer uses `a A→PC p PC→A b A↔PC r act. Échap retour` without
changing any action or direction.
Before a confirmed run, Sync identifies the selected direction in its progress
feedback. It then shows the completed run summary on success, or a
direction-prefixed diagnostic on failure. Each structured local history run
persists its selected `a`, `p`, or `b` direction, and the history list displays
that direction label alongside its summary. `direction inconnue` is used only
for a legacy entry whose direction was never recorded, rather than being
guessed.
History behaves like a compact Git log:
```text
@ -231,7 +275,7 @@ summary
error when applicable
```
## 11. Shared sync engine
## 12. Shared sync engine
The TUI does not own a separate synchronization implementation.
@ -246,7 +290,7 @@ The Android-triggered daemon calls the same engine.
This keeps import/export, MTP publication, locking, history, and diagnostics in
one implementation.
## 12. Direct MTP
## 13. Direct MTP
Transport uses:
@ -257,9 +301,9 @@ libmtp -> storage/object operations
No filesystem mount is required.
Raw libmtp output is suppressed while ncurses owns the terminal.
Raw libmtp output is suppressed while Notcurses owns the terminal.
## 13. Error behavior
## 14. Error behavior
Input is validated before persistent mutation.
@ -268,20 +312,25 @@ Escape cancels prompts without committing partial edits.
Synchronization failure displays a useful final diagnostic and records the
structured run when a transaction actually begins.
## 14. Build and test
Conflict diagnostics identify the stable affected identity, the source
artifact/direction, and a concise source summary. They preserve existing
session, body-observation, association, and definition data rather than silently
overwriting it.
## 15. Build and test
```bash
meson compile -C build
meson test -C build --print-errorlogs
```
Current normal suite:
Validated current normal suite:
```text
19/19 PASS
32/32 Meson tests PASS
```
## 15. Measured max view
## 16. Measured max view
From `3 Exercices`, the selected exercise exposes:
@ -321,9 +370,9 @@ less assistance = better
No percentage-of-max working load is produced for assistance or no-load
performance.
## 16. Body analytics
## 17. Body analytics
`4 Corps` adds:
`5 Corps` adds:
```text
v analyse corporelle

View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Build a deterministic desktop-v8 fixture through the real PC exporters."""
import sqlite3
import subprocess
import sys
from pathlib import Path
from test_exercise_reconciliation import SCHEMA
ROOT = Path(__file__).resolve().parents[1]
DESKTOP_WALK_ID = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d"
LEG_PRESS_ID = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"
PC_ONLY_ID = "ex_7e7cf906-2214-4066-bcb7-c16382d83b3b"
CUSTOM_EQUIPMENT_ID = "eq_44444444-4444-4444-8444-444444444444"
PC_SESSION_ID = "se_11111111-1111-4111-8111-111111111111"
RICH_WALK_ENTRY_ID = "sxe_22222222-2222-4222-8222-222222222221"
LEGACY_WALK_ENTRY_ID = "sxe_22222222-2222-4222-8222-222222222222"
LEG_PRESS_ENTRY_ID = "sxe_22222222-2222-4222-8222-222222222223"
BODY_OBSERVATION_ID = "bo_33333333-3333-4333-8333-333333333333"
def run_export(tool, output, database):
result = subprocess.run(
[sys.executable, str(ROOT / "tools" / tool), str(output),
"--database", str(database)],
text=True,
capture_output=True,
)
if result.returncode != 0:
raise RuntimeError(result.stdout + result.stderr)
def main():
if len(sys.argv) != 2:
raise SystemExit("usage: create_pc_android_idempotence_fixture.py OUTPUT_DIR")
output_directory = Path(sys.argv[1])
output_directory.mkdir(parents=True, exist_ok=True)
database = output_directory / "pc-v8.db"
connection = sqlite3.connect(database)
connection.executescript(SCHEMA)
connection.executemany(
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,"
"recording_mode,data_fields) VALUES(?,?,?,?,?,?);",
(
(DESKTOP_WALK_ID, "Marche", "marche", "duration", "continuous", 3),
(LEG_PRESS_ID, "Leg press", "leg press", "reps", "sets", 0),
(PC_ONLY_ID, "Hip abduction", "hip abduction", "reps", "sets", 0),
),
)
connection.execute(
"INSERT INTO custom_equipment(equipment_id,display_name,label_name,"
"equipment_type,load_semantics) VALUES(?,?,?,?,?);",
(CUSTOM_EQUIPMENT_ID, "Machine PC custom", "Machine custom",
"custom_machine", "external"),
)
connection.execute(
"INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?);",
(PC_SESSION_ID, "2026-09-08T16:30:00+02:00", "training"),
)
session_row_id = connection.execute(
"SELECT id FROM sessions WHERE session_id=?;", (PC_SESSION_ID,)
).fetchone()[0]
def exercise_row(exercise_id):
return connection.execute(
"SELECT id FROM exercises WHERE exercise_id=?;", (exercise_id,)
).fetchone()[0]
first_walk = connection.execute(
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,"
"recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) "
"VALUES(?,?,?,'continuous',3,0,'none',0,?);",
(RICH_WALK_ENTRY_ID, session_row_id, exercise_row(DESKTOP_WALK_ID),
CUSTOM_EQUIPMENT_ID),
).lastrowid
connection.execute(
"INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds,"
"speed_kmh,distance_km) VALUES(?,?,?,?);",
(first_walk, 900, 5.5, 1.2),
)
second_walk = connection.execute(
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,"
"recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) "
"VALUES(?,?,?,'continuous',1,1,'none',0,?);",
(LEGACY_WALK_ENTRY_ID, session_row_id, exercise_row(DESKTOP_WALK_ID),
CUSTOM_EQUIPMENT_ID),
).lastrowid
connection.execute(
"INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds,"
"speed_kmh) VALUES(?,?,?);",
(second_walk, 300, 4.0),
)
leg_press = connection.execute(
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,"
"recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) "
"VALUES(?,?,?,'sets',0,2,'none',0,?);",
(LEG_PRESS_ENTRY_ID, session_row_id, exercise_row(LEG_PRESS_ID),
CUSTOM_EQUIPMENT_ID),
).lastrowid
connection.executemany(
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) "
"VALUES(?,?,?,?);",
((leg_press, 0, 9, 80.0), (leg_press, 1, 8, 85.0)),
)
connection.execute(
"INSERT INTO body_observations(observation_id,observed_at,body_weight_kg,"
"waist_cm) VALUES(?,?,?,?);",
(BODY_OBSERVATION_ID, "2026-09-08T08:00:00+02:00", 83.7, 84.4),
)
connection.commit()
connection.close()
for tool, filename in (
("export_equipment_definitions.py", "trainlog-pc-equipment-definitions-v1.json"),
("export_pc_catalog.py", "trainlog-pc-catalog-v1.json"),
("export_pc_mobile.py", "trainlog-pc-mobile-export-v2.json"),
("export_equipment_associations.py", "trainlog-equipment-associations-v2.json"),
):
run_export(tool, output_directory / filename, database)
print("PASS pc_android_idempotence_fixture")
if __name__ == "__main__":
main()

View file

@ -15,11 +15,12 @@ def make_db(path, equipment):
CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT UNIQUE);
CREATE TABLE sessions(id INTEGER PRIMARY KEY, session_id TEXT UNIQUE, started_at TEXT);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY, entry_id TEXT UNIQUE, session_row_id INTEGER, exercise_row_id INTEGER, position INTEGER, equipment_id TEXT);
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, label_name TEXT NOT NULL, equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL);
""")
db.execute("INSERT INTO exercises VALUES(1,'ex_fixture')")
db.execute("INSERT INTO sessions VALUES(1,'se_fixture','2026-01-01T00:00:00+00:00')")
db.execute("INSERT INTO session_exercises VALUES(1,'sxe_fixture',1,1,0,?)", (equipment,))
db.execute("PRAGMA user_version=7")
db.execute("PRAGMA user_version=8")
db.commit(); db.close()
@ -27,14 +28,65 @@ def main():
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
source, target, artifact = directory / 'source.db', directory / 'target.db', directory / 'equipment.json'
make_db(source, 'leg_press'); make_db(target, None)
make_db(source, 'leg_press'); make_db(target, 'leg_press')
subprocess.run([sys.executable, ROOT / 'tools/export_equipment_associations.py', artifact, '--database', source], check=True)
subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], check=True)
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press'
payload = json.loads(artifact.read_text()); payload['associations'][0] = {'session_id':'se_fixture','entry_id':'sxe_fixture','exercise_id':'ex_fixture','state':'cleared'}
artifact.write_text(json.dumps(payload))
subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], check=True)
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] is None
conflict = subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], text=True, capture_output=True)
assert conflict.returncode != 0 and 'conflit association' in conflict.stdout
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press'
# A V2 entry ID is not sufficient identity: its exercise ID must also
# agree before an otherwise equal association is treated as a replay.
payload['associations'][0] = {'session_id':'se_fixture','entry_id':'sxe_fixture','exercise_id':'ex_other','state':'set','equipment_id':'leg_press'}
artifact.write_text(json.dumps(payload))
exercise_conflict = subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], text=True, capture_output=True)
assert exercise_conflict.returncode != 0 and 'conflit exercice association' in exercise_conflict.stdout
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press'
# The importer must validate the entire artifact before applying its
# first otherwise-valid association. v2 cannot transport custom IDs.
prevalidation_artifact = directory / 'prevalidation-rejected.json'
prevalidation_payload = {
'format': 'trainlog-equipment-associations',
'version': 2,
'generated_at': '2026-01-01T00:00:00+00:00',
'associations': [
{'session_id': 'se_fixture', 'entry_id': 'sxe_fixture',
'exercise_id': 'ex_fixture', 'state': 'set', 'equipment_id': 'leg_press'},
{'session_id': 'se_later', 'entry_id': 'sxe_later',
'exercise_id': 'ex_later', 'state': 'set', 'equipment_id': 'custom_rack'},
],
}
prevalidation_artifact.write_text(json.dumps(prevalidation_payload))
rejected = subprocess.run(
[sys.executable, ROOT / 'tools/import_equipment_associations.py', prevalidation_artifact, '--database', target],
text=True,
capture_output=True,
)
assert rejected.returncode != 0
assert 'session_id=se_later' in rejected.stdout
assert 'entry_id=sxe_later' in rejected.stdout
assert 'equipment_id=custom_rack' in rejected.stdout
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press'
# Definition-v1 is ordered before unchanged association-v2, so the
# exporter admits a locally-defined custom stable ID.
custom_source, custom_target = directory / 'custom-source.db', directory / 'custom-target.db'
make_db(custom_source, 'custom_rack'); make_db(custom_target, None)
custom_db = sqlite3.connect(custom_source)
custom_db.execute("INSERT INTO custom_equipment VALUES('custom_rack','Rack','Rack','rack','none')")
custom_db.commit(); custom_db.close()
custom_artifact = directory / 'custom-equipment.json'
exported = subprocess.run(
[sys.executable, ROOT / 'tools/export_equipment_associations.py', custom_artifact, '--database', custom_source],
text=True,
capture_output=True,
)
assert exported.returncode == 0, exported.stdout + exported.stderr
assert json.loads(custom_artifact.read_text())['associations'][0]['equipment_id'] == 'custom_rack'
print('PASS equipment association exchange')

View file

@ -0,0 +1,68 @@
"""Strict additive custom-equipment definitions-v1 integration test."""
import json
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EXPORTER = ROOT / "tools/export_equipment_definitions.py"
IMPORTER = ROOT / "tools/import_equipment_definitions.py"
def database(path):
connection = sqlite3.connect(path)
connection.execute("CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL)")
connection.execute("PRAGMA user_version=8")
connection.commit()
return connection
def run(tool, artifact, db):
return subprocess.run([sys.executable, tool, artifact, "--database", db], text=True, capture_output=True)
def main():
with tempfile.TemporaryDirectory(prefix="trainlog-definitions-") as temporary:
root = Path(temporary)
source, target, artifact = root / "source.db", root / "target.db", root / "definitions.json"
connection = database(source)
connection.execute("INSERT INTO custom_equipment VALUES(?,?,?,?,?)",
("eq_custom", "Presse voyage", "Presse", "custom_machine", "external"))
connection.commit(); connection.close()
database(target).close()
result = run(EXPORTER, artifact, source)
assert result.returncode == 0, result.stdout + result.stderr
payload = json.loads(artifact.read_text())
assert payload["format"] == "trainlog-equipment-definitions" and payload["version"] == 1
assert len(payload["equipment"]) == 1
first = run(IMPORTER, artifact, target)
assert first.returncode == 0 and "definitions_imported=1" in first.stdout
second = run(IMPORTER, artifact, target)
assert second.returncode == 0 and "definitions_skipped=1" in second.stdout
# Missing from the next snapshot is not a deletion request.
payload["equipment"] = []
artifact.write_text(json.dumps(payload))
assert run(IMPORTER, artifact, target).returncode == 0
assert sqlite3.connect(target).execute("SELECT count(*) FROM custom_equipment").fetchone()[0] == 1
# Divergent same-ID content conflicts without changing the local row.
payload["equipment"] = [{"equipment_id": "eq_custom", "display_name": "Autre",
"label_name": "Presse", "equipment_type": "custom_machine", "load_semantics": "external"}]
artifact.write_text(json.dumps(payload))
conflict = run(IMPORTER, artifact, target)
assert conflict.returncode != 0 and "conflit définition" in conflict.stdout
assert sqlite3.connect(target).execute("SELECT display_name FROM custom_equipment").fetchone()[0] == "Presse voyage"
# Supplied-manifest identities cannot be overridden by the companion.
payload["equipment"][0]["equipment_id"] = "leg_press"
artifact.write_text(json.dumps(payload))
reserved = run(IMPORTER, artifact, target)
assert reserved.returncode != 0 and "réservé" in reserved.stdout
print("PASS equipment definitions exchange")
if __name__ == "__main__": main()

View file

@ -0,0 +1,490 @@
#!/usr/bin/env python3
"""Exercise-identity reconciliation and complete V2 round-trip regressions."""
import copy
import json
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
IMPORT_MOBILE = ROOT / "tools/import_mobile_export.py"
IMPORT_DEFINITIONS = ROOT / "tools/import_equipment_definitions.py"
IMPORT_ASSOCIATIONS = ROOT / "tools/import_equipment_associations.py"
EXPORT_DEFINITIONS = ROOT / "tools/export_equipment_definitions.py"
EXPORT_CATALOG = ROOT / "tools/export_pc_catalog.py"
EXPORT_MOBILE = ROOT / "tools/export_pc_mobile.py"
EXPORT_ASSOCIATIONS = ROOT / "tools/export_equipment_associations.py"
DESKTOP_WALK_ID = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d"
ANDROID_WALK_ID = "ex_23212d79-52ce-4195-914d-dd983f133936"
ANDROID_SESSION_ID = "se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a"
ANDROID_WALK_ENTRY_ID = "sxe_f25142c8-455e-4346-9bfc-31d0989e275d"
SCHEMA = """
PRAGMA foreign_keys=ON;
CREATE TABLE exercises(
id INTEGER PRIMARY KEY, exercise_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL, normalized_name TEXT NOT NULL UNIQUE,
tracking_mode TEXT NOT NULL, recording_mode TEXT NOT NULL,
data_fields INTEGER NOT NULL
);
CREATE TABLE sessions(
id INTEGER PRIMARY KEY, session_id TEXT NOT NULL UNIQUE,
started_at TEXT NOT NULL, ended_at TEXT, session_type TEXT NOT NULL, notes TEXT
);
CREATE TABLE session_exercises(
id INTEGER PRIMARY KEY, entry_id TEXT NOT NULL UNIQUE,
session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,
recording_mode TEXT NOT NULL, data_fields INTEGER NOT NULL, position INTEGER NOT NULL,
load_mode TEXT NOT NULL, rest_seconds INTEGER NOT NULL, target_sets INTEGER,
target_reps INTEGER, target_duration_seconds INTEGER, target_weight_kg REAL,
equipment_id TEXT, notes TEXT, UNIQUE(session_row_id,position)
);
CREATE TABLE performed_sets(
id INTEGER PRIMARY KEY,
session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,
position INTEGER NOT NULL, reps INTEGER, duration_seconds INTEGER, weight_kg REAL
);
CREATE TABLE continuous_activity(
id INTEGER PRIMARY KEY,
session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id) ON DELETE CASCADE,
duration_seconds INTEGER NOT NULL, speed_kmh REAL, distance_km REAL
);
CREATE TABLE body_observations(
id INTEGER PRIMARY KEY, observation_id TEXT NOT NULL UNIQUE, observed_at TEXT NOT NULL,
session_row_id INTEGER, body_weight_kg REAL, neck_cm REAL, shoulders_cm REAL,
chest_cm REAL, waist_cm REAL, hips_cm REAL, left_arm_cm REAL, right_arm_cm REAL,
left_forearm_cm REAL, right_forearm_cm REAL, left_thigh_cm REAL,
right_thigh_cm REAL, left_calf_cm REAL, right_calf_cm REAL, notes TEXT
);
CREATE TABLE custom_equipment(
equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, label_name TEXT NOT NULL,
equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL
);
PRAGMA user_version=8;
"""
def run(tool, *arguments, succeeds=True):
result = subprocess.run(
[sys.executable, str(tool), *(str(value) for value in arguments)],
text=True,
capture_output=True,
)
if succeeds:
assert result.returncode == 0, result.stdout + result.stderr
else:
assert result.returncode != 0, result.stdout + result.stderr
return result.stdout + result.stderr
def create_database(path, fields=1, with_history=False):
connection = sqlite3.connect(path)
connection.executescript(SCHEMA)
connection.execute(
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields) "
"VALUES(?,?,?,?,?,?);",
(DESKTOP_WALK_ID, "Marche", "marche", "duration", "continuous", fields),
)
if with_history:
connection.execute(
"INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?);",
("se_c0d07454-b58b-403e-8b79-744619815fc5", "2026-09-07T19:27:35+02:00", "training"),
)
session_row = connection.execute("SELECT id FROM sessions").fetchone()[0]
exercise_row = connection.execute("SELECT id FROM exercises").fetchone()[0]
for position, entry_id, duration, speed, equipment in (
(0, "sxe_draft_legacy_1", 900, 6.6, None),
(8, "sxe_093c1331-beaa-4b69-91b3-240292709be6", 300, 4.0, "treadmill"),
):
cursor = connection.execute(
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,"
"recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) "
"VALUES(?,?,?,'continuous',1,?,'none',0,?);",
(entry_id, session_row, exercise_row, position, equipment),
)
connection.execute(
"INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds,speed_kmh) "
"VALUES(?,?,?);",
(cursor.lastrowid, duration, speed),
)
connection.commit()
connection.close()
def mobile_payload(fields=3):
return {
"format": "trainlog-mobile-export",
"version": 2,
"generated_at": "2026-09-08T15:21:26+02:00",
"exercises": [
{
"exercise_id": ANDROID_WALK_ID,
"name": "Marche",
"recording_mode": "continuous",
"tracking_mode": "duration",
"data_fields": fields,
},
{
"exercise_id": "ex_android_strength",
"name": "Machine Android",
"recording_mode": "sets",
"tracking_mode": "reps",
"data_fields": 0,
},
],
"sessions": [
{
"session_id": ANDROID_SESSION_ID,
"started_at": "2026-09-08T11:25:30+02:00",
"session_type": "max_test",
"exercises": [
{
"entry_id": ANDROID_WALK_ENTRY_ID,
"position": 0,
"exercise_id": ANDROID_WALK_ID,
"name": "Marche",
"recording_mode": "continuous",
"tracking_mode": "duration",
"data_fields": fields,
"load_mode": "none",
"rest_seconds": 0,
"equipment_id": "treadmill",
"continuous": {
"duration_seconds": 900,
**({"speed_kmh": 5.5} if fields & 1 else {}),
**({"distance_km": 1.2} if fields & 2 else {}),
},
},
{
"entry_id": "sxe_android_strength",
"position": 1,
"exercise_id": "ex_android_strength",
"name": "Machine Android",
"recording_mode": "sets",
"tracking_mode": "reps",
"data_fields": 0,
"load_mode": "none",
"rest_seconds": 0,
"equipment_id": "eq_android_custom",
"sets": [{"reps": 9, "weight_kg": 42.5}],
},
],
},
],
"body_observations": [
{
"observation_id": "bo_android_reconciliation",
"observed_at": "2026-09-08T08:00:00+02:00",
"body_weight_kg": 83.7,
"neck_cm": 37.1,
"shoulders_cm": 111.2,
"chest_cm": 99.3,
"waist_cm": 84.4,
"hips_cm": 96.5,
"left_arm_cm": 31.6,
"right_arm_cm": 31.7,
"left_forearm_cm": 27.8,
"right_forearm_cm": 27.9,
"left_thigh_cm": 56.1,
"right_thigh_cm": 56.2,
"left_calf_cm": 37.3,
"right_calf_cm": 37.4,
},
],
}
def definitions_payload():
return {
"format": "trainlog-equipment-definitions",
"version": 1,
"generated_at": "2026-09-08T15:21:26+02:00",
"equipment": [
{
"equipment_id": "eq_android_custom",
"display_name": "Machine Android custom",
"label_name": "Machine custom",
"equipment_type": "custom_machine",
"load_semantics": "external",
},
],
}
def associations_payload():
return {
"format": "trainlog-equipment-associations",
"version": 2,
"generated_at": "2026-09-08T15:21:26+02:00",
"associations": [
{
"session_id": ANDROID_SESSION_ID,
"entry_id": ANDROID_WALK_ENTRY_ID,
"exercise_id": ANDROID_WALK_ID,
"state": "set",
"equipment_id": "treadmill",
},
{
"session_id": ANDROID_SESSION_ID,
"entry_id": "sxe_android_strength",
"exercise_id": "ex_android_strength",
"state": "set",
"equipment_id": "eq_android_custom",
},
],
}
def assert_no_integrity_error(connection):
assert connection.execute("PRAGMA integrity_check;").fetchone()[0] == "ok"
assert connection.execute("PRAGMA foreign_key_check;").fetchall() == []
def semantic_state(connection):
"""Capture exchanged business state without depending on local rowids."""
return (
connection.execute(
"SELECT exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields "
"FROM exercises ORDER BY exercise_id;"
).fetchall(),
connection.execute(
"SELECT session_id,started_at,ended_at,session_type,notes "
"FROM sessions ORDER BY session_id;"
).fetchall(),
connection.execute(
"SELECT s.session_id,se.entry_id,e.exercise_id,se.recording_mode,se.data_fields,"
"se.position,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,"
"se.target_duration_seconds,se.target_weight_kg,se.equipment_id,se.notes "
"FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"ORDER BY s.session_id,se.position;"
).fetchall(),
connection.execute(
"SELECT se.entry_id,ps.position,ps.reps,ps.duration_seconds,ps.weight_kg "
"FROM performed_sets ps JOIN session_exercises se "
"ON se.id=ps.session_exercise_row_id ORDER BY se.entry_id,ps.position;"
).fetchall(),
connection.execute(
"SELECT se.entry_id,ca.duration_seconds,ca.speed_kmh,ca.distance_km "
"FROM continuous_activity ca JOIN session_exercises se "
"ON se.id=ca.session_exercise_row_id ORDER BY se.entry_id;"
).fetchall(),
connection.execute(
"SELECT observation_id,observed_at,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 "
"FROM body_observations ORDER BY observation_id;"
).fetchall(),
connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment ORDER BY equipment_id;"
).fetchall(),
)
def main():
with tempfile.TemporaryDirectory(prefix="trainlog-reconcile-") as directory:
root = Path(directory)
# A: identical profiles with different IDs use the existing desktop ID.
identical_db = root / "identical.db"
create_database(identical_db, fields=1)
identical = mobile_payload(fields=1)
identical["sessions"] = []
identical["body_observations"] = []
identical["exercises"] = identical["exercises"][:1]
identical_path = root / "identical.json"
identical_path.write_text(json.dumps(identical), encoding="utf-8")
first_identical = run(
IMPORT_MOBILE, identical_path, "--database", identical_db,
"--trace-exercises",
)
assert "exercises_reconciled=0" in first_identical
assert "exercises_skipped=1" in first_identical
assert f'"exercise_id": "{ANDROID_WALK_ID}"' in first_identical
assert f'"lookup": "normalized_name:{DESKTOP_WALK_ID}"' in first_identical
assert '"decision": "existing-identical"' in first_identical
with sqlite3.connect(identical_db) as connection:
assert connection.execute(
"SELECT exercise_id,data_fields FROM exercises"
).fetchall() == [(DESKTOP_WALK_ID, 1)]
identical_before_replay = semantic_state(connection)
second_identical = run(
IMPORT_MOBILE, identical_path, "--database", identical_db,
"--trace-exercises",
)
assert "exercises_reconciled=0" in second_identical
assert "exercises_skipped=1" in second_identical
assert '"decision": "existing-identical"' in second_identical
with sqlite3.connect(identical_db) as connection:
assert semantic_state(connection) == identical_before_replay
# C/G: equal text cannot merge incomparable masks or different modes.
for label, mutate in (
("fields", lambda value: value["exercises"][0].update(data_fields=2)),
("mode", lambda value: value["exercises"][0].update(recording_mode="sets", tracking_mode="duration", data_fields=0)),
):
conflict_db = root / f"conflict-{label}.db"
create_database(conflict_db, fields=1)
conflict = copy.deepcopy(identical)
mutate(conflict)
conflict_path = root / f"conflict-{label}.json"
conflict_path.write_text(json.dumps(conflict), encoding="utf-8")
output = run(
IMPORT_MOBILE,
conflict_path,
"--database",
conflict_db,
succeeds=False,
)
assert "profil incompatible entre identités" in output
with sqlite3.connect(conflict_db) as connection:
assert connection.execute(
"SELECT exercise_id,data_fields FROM exercises"
).fetchall() == [(DESKTOP_WALK_ID, 1)]
# B/D/E: run the real ordered Android -> PC tools on a v8 copy.
complete_db = root / "complete.db"
create_database(complete_db, fields=1, with_history=True)
mobile_path = root / "trainlog-mobile-export-v2.json"
definitions_path = root / "trainlog-mobile-equipment-definitions-v1.json"
associations_path = root / "trainlog-equipment-associations-v2.json"
mobile_path.write_text(json.dumps(mobile_payload()), encoding="utf-8")
definitions_path.write_text(json.dumps(definitions_payload()), encoding="utf-8")
associations_path.write_text(json.dumps(associations_payload()), encoding="utf-8")
assert "definitions_imported=1" in run(
IMPORT_DEFINITIONS, definitions_path, "--database", complete_db
)
assert "sessions_imported=1" in run(
IMPORT_MOBILE, mobile_path, "--database", complete_db
)
assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in run(
IMPORT_ASSOCIATIONS,
associations_path,
"--database",
complete_db,
"--mobile-export",
mobile_path,
)
with sqlite3.connect(complete_db) as connection:
assert connection.execute(
"SELECT exercise_id,data_fields FROM exercises WHERE normalized_name='marche';"
).fetchall() == [(DESKTOP_WALK_ID, 3)]
assert connection.execute(
"SELECT COUNT(*) FROM exercises WHERE exercise_id=?;",
(ANDROID_WALK_ID,),
).fetchone()[0] == 0
rows = connection.execute(
"SELECT s.session_id,se.entry_id,se.position,e.exercise_id,se.data_fields,"
"ca.duration_seconds,ca.speed_kmh,ca.distance_km,se.equipment_id "
"FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id "
"WHERE e.normalized_name='marche' ORDER BY s.started_at,se.position;"
).fetchall()
assert rows == [
(
"se_c0d07454-b58b-403e-8b79-744619815fc5",
"sxe_draft_legacy_1", 0, DESKTOP_WALK_ID, 1,
900, 6.6, None, None,
),
(
"se_c0d07454-b58b-403e-8b79-744619815fc5",
"sxe_093c1331-beaa-4b69-91b3-240292709be6", 8,
DESKTOP_WALK_ID, 1, 300, 4.0, None, "treadmill",
),
(
ANDROID_SESSION_ID, ANDROID_WALK_ENTRY_ID, 0,
DESKTOP_WALK_ID, 3, 900, 5.5, 1.2, "treadmill",
),
]
assert connection.execute(
"SELECT reps,weight_kg FROM performed_sets"
).fetchall() == [(9, 42.5)]
assert connection.execute(
"SELECT 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 "
"FROM body_observations "
"WHERE observation_id='bo_android_reconciliation';"
).fetchone() == (
83.7, 37.1, 111.2, 99.3, 84.4, 96.5, 31.6, 31.7,
27.8, 27.9, 56.1, 56.2, 37.3, 37.4,
)
assert_no_integrity_error(connection)
# F: replay Android input, publish every PC companion, then re-import
# the merged PC snapshot. The semantic database state must stay fixed.
assert "definitions_skipped=1" in run(
IMPORT_DEFINITIONS, definitions_path, "--database", complete_db
)
replay_output = run(
IMPORT_MOBILE, mobile_path, "--database", complete_db
)
assert "sessions_skipped=1" in replay_output
assert "exercises_reconciled=0" in replay_output
assert "exercises_skipped=2" in replay_output
run(
IMPORT_ASSOCIATIONS,
associations_path,
"--database",
complete_db,
"--mobile-export",
mobile_path,
)
pc_definitions = root / "trainlog-pc-equipment-definitions-v1.json"
pc_catalog = root / "trainlog-pc-catalog-v1.json"
pc_mobile = root / "trainlog-pc-mobile-export-v2.json"
pc_associations = root / "trainlog-pc-equipment-associations-v2.json"
for tool, output in (
(EXPORT_DEFINITIONS, pc_definitions),
(EXPORT_CATALOG, pc_catalog),
(EXPORT_MOBILE, pc_mobile),
(EXPORT_ASSOCIATIONS, pc_associations),
):
run(tool, output, "--database", complete_db)
exported = json.loads(pc_mobile.read_text(encoding="utf-8"))
walk = next(
item for item in exported["exercises"]
if item["exercise_id"] == DESKTOP_WALK_ID
)
assert walk["data_fields"] == 3
old_walks = [
item
for session in exported["sessions"]
for item in session["exercises"]
if item["entry_id"] in {
"sxe_draft_legacy_1",
"sxe_093c1331-beaa-4b69-91b3-240292709be6",
}
]
assert len(old_walks) == 2
assert all(item["data_fields"] == 1 for item in old_walks)
assert all("distance_km" not in item["continuous"] for item in old_walks)
with sqlite3.connect(complete_db) as connection:
before = semantic_state(connection)
assert "sessions_skipped=2" in run(
IMPORT_MOBILE, pc_mobile, "--database", complete_db
)
run(IMPORT_ASSOCIATIONS, pc_associations, "--database", complete_db)
with sqlite3.connect(complete_db) as connection:
after = semantic_state(connection)
assert before == after
assert_no_integrity_error(connection)
print("PASS exercise_reconciliation")
if __name__ == "__main__":
main()

View file

@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""End-to-end V2 import regression: Walk, other movement, Walk again."""
import json
import copy
import sqlite3
import subprocess
import sys
@ -10,7 +11,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
IMPORTER = ROOT / "tools/import_mobile_export.py"
EQUIPMENT_IMPORTER = ROOT / "tools/import_equipment_associations.py"
CATALOG_EXPORTER = ROOT / "tools/export_pc_catalog.py"
DEFINITIONS_IMPORTER = ROOT / "tools/import_equipment_definitions.py"
MOBILE_EXPORTER = ROOT / "tools/export_pc_mobile.py"
EQUIPMENT_EXPORTER = ROOT / "tools/export_equipment_associations.py"
@ -21,7 +22,8 @@ CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNI
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER UNIQUE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);
PRAGMA user_version=7;
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL);
PRAGMA user_version=8;
"""
def payload():
@ -34,7 +36,7 @@ def payload():
{"entry_id":"sxe_push","position":1,"exercise_id":"ex_push","name":"Pompes","recording_mode":"sets","tracking_mode":"reps","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":"leg_press","sets":[{"reps":12,"weight_kg":20.0}]},
{"entry_id":"sxe_walk_15","position":2,"exercise_id":"ex_walk","name":"Marche","recording_mode":"continuous","tracking_mode":"duration","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":None,"continuous":{"duration_seconds":900}},
]
return {"format":"trainlog-mobile-export","version":2,"generated_at":"2026-09-07T10:00:00+02:00","exercises":catalog,"sessions":[{"session_id":"se_multi","started_at":"2026-09-07T10:00:00+02:00","session_type":"training","exercises":entries}],"body_observations":[]}
return {"format":"trainlog-mobile-export","version":2,"generated_at":"2026-09-07T10:00:00+02:00","exercises":catalog,"sessions":[{"session_id":"se_multi","started_at":"2026-09-07T10:00:00+02:00","session_type":"training","exercises":entries}],"body_observations":[{"observation_id":"bo_fixture","observed_at":"2026-03-01T08:00:00+01:00","body_weight_kg":72.5}]}
def invoke(path, db):
result = subprocess.run([sys.executable, str(IMPORTER), str(path), "--database", str(db)], text=True, capture_output=True)
@ -52,25 +54,110 @@ def invoke_equipment(path, db):
raise AssertionError(result.stdout + result.stderr)
return result.stdout
def invoke_definitions(path, db):
result = subprocess.run(
[sys.executable, str(DEFINITIONS_IMPORTER), str(path), "--database", str(db)],
text=True,
capture_output=True,
)
if result.returncode:
raise AssertionError(result.stdout + result.stderr)
return result.stdout
def main():
with tempfile.TemporaryDirectory(prefix="trainlog-multi-") as temp:
root = Path(temp); db = root / "db.sqlite"; artifact = root / "mobile-v2.json"
equipment = root / "equipment-v2.json"
con = sqlite3.connect(db); con.executescript(SCHEMA); con.close()
# Mobile v2 cannot transport custom definitions. Reject the complete
# v8 input before catalog/session mutation, with occurrence context.
unknown_db = root / "unknown.db"
con = sqlite3.connect(unknown_db); con.executescript(SCHEMA); con.close()
unknown_artifact = root / "unknown-mobile-v2.json"
unknown_payload = copy.deepcopy(payload())
unknown_payload["sessions"][0]["exercises"][1]["equipment_id"] = "custom_rack"
unknown_artifact.write_text(json.dumps(unknown_payload), encoding="utf-8")
rejected = subprocess.run(
[sys.executable, str(IMPORTER), str(unknown_artifact), "--database", str(unknown_db)],
text=True,
capture_output=True,
)
assert rejected.returncode != 0
assert "session_id=se_multi" in rejected.stderr
assert "entry_id=sxe_push" in rejected.stderr
assert "equipment_id=custom_rack" in rejected.stderr
con = sqlite3.connect(unknown_db)
assert con.execute("SELECT count(*) FROM sessions").fetchone()[0] == 0
assert con.execute("SELECT count(*) FROM exercises").fetchone()[0] == 0
con.close()
# Android -> PC ordering regression: the companion definition is
# imported through its real importer before the V2 snapshot validates
# the entry reference. A replay retains both stable identities and
# never creates a duplicate custom machine or session occurrence.
defined_db = root / "defined.db"
con = sqlite3.connect(defined_db); con.executescript(SCHEMA); con.close()
definitions = root / "mobile-definitions-v1.json"
definitions.write_text(json.dumps({
"format": "trainlog-equipment-definitions",
"version": 1,
"generated_at": "2026-09-07T10:00:00+02:00",
"equipment": [{
"equipment_id": "custom_rack",
"display_name": "Rack Android",
"label_name": "Rack",
"equipment_type": "custom_machine",
"load_semantics": "external",
}],
}), encoding="utf-8")
assert "definitions_imported=1" in invoke_definitions(definitions, defined_db)
defined_payload = copy.deepcopy(payload())
defined_payload["sessions"][0]["exercises"][1]["equipment_id"] = "custom_rack"
defined_artifact = root / "defined-mobile-v2.json"
defined_artifact.write_text(json.dumps(defined_payload), encoding="utf-8")
assert "sessions_imported=1" in invoke(defined_artifact, defined_db)
con = sqlite3.connect(defined_db)
assert con.execute(
"SELECT display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment WHERE equipment_id='custom_rack'"
).fetchone() == ("Rack Android", "Rack", "custom_machine", "external")
assert con.execute(
"SELECT equipment_id FROM session_exercises WHERE entry_id='sxe_push'"
).fetchone() == ("custom_rack",)
con.close()
assert "definitions_skipped=1" in invoke_definitions(definitions, defined_db)
assert "sessions_skipped=1" in invoke(defined_artifact, defined_db)
artifact.write_text(json.dumps(payload()), encoding="utf-8")
assert "sessions_imported=1" in invoke(artifact, db)
con = sqlite3.connect(db)
rows = con.execute("SELECT e.exercise_id,se.entry_id,se.position,ca.duration_seconds FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id ORDER BY se.position").fetchall()
assert rows == [("ex_walk","sxe_walk_10",0,600),("ex_push","sxe_push",1,None),("ex_walk","sxe_walk_15",2,900)], rows
assert con.execute("SELECT weight_kg FROM performed_sets").fetchone()[0] == 20.0
assert "sessions_skipped=1" in invoke(artifact, db)
con.close()
divergent = copy.deepcopy(payload())
divergent["sessions"][0]["exercises"][1]["sets"][0]["reps"] = 13
artifact.write_text(json.dumps(divergent), encoding="utf-8")
rejected = subprocess.run([sys.executable, str(IMPORTER), str(artifact), "--database", str(db)], text=True, capture_output=True)
assert rejected.returncode != 0 and "conflit de contenu" in rejected.stderr
assert sqlite3.connect(db).execute("SELECT reps FROM performed_sets").fetchone()[0] == 12
divergent = copy.deepcopy(payload())
divergent["body_observations"][0]["body_weight_kg"] = 73.0
artifact.write_text(json.dumps(divergent), encoding="utf-8")
rejected = subprocess.run([sys.executable, str(IMPORTER), str(artifact), "--database", str(db)], text=True, capture_output=True)
assert rejected.returncode != 0 and "conflit observation corporelle" in rejected.stderr
artifact.write_text(json.dumps(payload()), encoding="utf-8")
equipment.write_text(json.dumps({
"format": "trainlog-equipment-associations",
"version": 2,
"generated_at": "2026-09-07T10:00:00+02:00",
"associations": [
{"session_id": "se_multi", "entry_id": "sxe_walk_10", "exercise_id": "ex_walk", "state": "set", "equipment_id": "treadmill"},
{"session_id": "se_multi", "entry_id": "sxe_walk_10", "exercise_id": "ex_walk", "state": "cleared"},
{"session_id": "se_multi", "entry_id": "sxe_push", "exercise_id": "ex_push", "state": "set", "equipment_id": "leg_press"},
{"session_id": "se_multi", "entry_id": "sxe_walk_15", "exercise_id": "ex_walk", "state": "cleared"},
],
@ -92,13 +179,12 @@ def main():
"SELECT entry_id,equipment_id FROM session_exercises ORDER BY position"
).fetchall()
assert rows == [
("sxe_walk_10", "treadmill"),
("sxe_walk_10", None),
("sxe_push", "leg_press"),
("sxe_walk_15", None),
], rows
con.close()
assert "sessions_reconciled=1" in invoke(artifact, db)
assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in invoke_equipment(equipment, db)
con = sqlite3.connect(db)
assert con.execute("SELECT count(*) FROM session_exercises").fetchone()[0] == 3
@ -107,11 +193,9 @@ def main():
).fetchall() == rows
con.close()
catalog_output = root / "pc-catalog.json"
mobile_output = root / "pc-mobile-v2.json"
equipment_output = root / "pc-equipment-v2.json"
for tool, output, marker in (
(CATALOG_EXPORTER, catalog_output, "PC_CATALOG_EXPORT=PASS"),
(MOBILE_EXPORTER, mobile_output, "PC_MOBILE_EXPORT=PASS"),
(EQUIPMENT_EXPORTER, equipment_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS"),
):
@ -122,6 +206,26 @@ def main():
)
assert result.returncode == 0, result.stdout + result.stderr
assert marker in result.stdout
assert json.loads(mobile_output.read_text())["body_observations"][0]["body_weight_kg"] == 72.5
# The desktop serializer is also accepted as an idempotent V2 replay,
# which is the PC endpoint of the Android importer/exporter round trip.
assert "body_skipped=1" in invoke(mobile_output, db)
# Definitions travel in their companion; unchanged v2 references may
# therefore contain a locally-known custom stable ID.
con = sqlite3.connect(db)
con.execute("INSERT INTO custom_equipment VALUES('custom_rack','Rack','Rack','rack','none')")
con.execute("UPDATE session_exercises SET equipment_id='custom_rack' WHERE entry_id='sxe_push'")
con.commit()
con.close()
custom_output = root / "custom-mobile-v2.json"
exported = subprocess.run(
[sys.executable, str(MOBILE_EXPORTER), str(custom_output), "--database", str(db)],
text=True,
capture_output=True,
)
assert exported.returncode == 0, exported.stdout + exported.stderr
assert json.loads(custom_output.read_text())["sessions"][0]["exercises"][1]["equipment_id"] == "custom_rack"
print("PASS mobile_import_multi_occurrence")
if __name__ == "__main__": main()

View file

@ -8,16 +8,30 @@ from datetime import datetime
from pathlib import Path
DEFAULT_CATALOG = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
def load_supplied_equipment_ids(path):
catalog = json.loads(path.read_text(encoding="utf-8"))
if catalog.get("format") != "trainlog-equipment-catalog" or catalog.get("version") != 1:
raise ValueError("catalogue équipement v1 invalide")
return {item["id"] for item in catalog["equipment"]}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path,
default=Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db")
parser.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
args = parser.parse_args()
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] != 7:
raise ValueError("schema desktop v7 requis")
if connection.execute("PRAGMA user_version;").fetchone()[0] != 8:
raise ValueError("schema desktop v8 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))
rows = connection.execute(
"SELECT s.session_id,se.entry_id,e.exercise_id,se.equipment_id FROM session_exercises se "
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
@ -25,6 +39,14 @@ def main():
payload = {"format": "trainlog-equipment-associations", "version": 2,
"generated_at": datetime.now().astimezone().isoformat(), "associations": []}
for session_id, entry_id, exercise_id, equipment_id in rows:
# CONTRACT: definitions-v1 is published before this unchanged v2
# reference artifact, so every referenced custom ID must exist.
if equipment_id is not None and equipment_id not in known_equipment:
raise ValueError(
"équipement non transportable "
f"session_id={session_id} entry_id={entry_id} "
f"equipment_id={equipment_id}"
)
item = {"session_id": session_id, "entry_id": entry_id, "exercise_id": exercise_id,
"state": "set" if equipment_id is not None else "cleared"}
if equipment_id is not None:

View file

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Export user-created equipment definitions without changing association v2."""
import argparse
import json
import os
import sqlite3
from datetime import datetime
from pathlib import Path
FORMAT = "trainlog-equipment-definitions"
VERSION = 1
def default_database():
return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, default=default_database())
args = parser.parse_args()
connection = sqlite3.connect(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] != 8:
raise ValueError("schema desktop v8 requis")
equipment = [dict(row) for row in connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment ORDER BY equipment_id")]
payload = {"format": FORMAT, "version": VERSION,
"generated_at": datetime.now().astimezone().isoformat(),
"equipment": equipment}
args.output.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
print("EQUIPMENT_DEFINITIONS_EXPORT=PASS")
print(f"definitions={len(equipment)}")
finally:
connection.close()
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"EQUIPMENT_DEFINITIONS_EXPORT=FAIL {error}")
raise SystemExit(1)

View file

@ -61,7 +61,10 @@ def main():
"PRAGMA user_version;"
).fetchone()[0]
if version != 7:
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version != 8:
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -8,10 +8,20 @@ from datetime import datetime
from pathlib import Path
CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
def default_database():
return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db"
def supplied_equipment_ids():
catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
if catalog.get("format") != "trainlog-equipment-catalog" or catalog.get("version") != 1:
raise ValueError("catalog/equipment-v1.json invalide")
return {item["id"] for item in catalog["equipment"]}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
@ -20,8 +30,11 @@ def main():
con = sqlite3.connect(args.database)
con.row_factory = sqlite3.Row
try:
if con.execute("PRAGMA user_version").fetchone()[0] != 7:
raise ValueError("schema desktop v7 requis")
if con.execute("PRAGMA user_version").fetchone()[0] != 8:
raise ValueError("schema desktop v8 requis")
known_equipment = supplied_equipment_ids()
known_equipment.update(row[0] for row in con.execute(
"SELECT equipment_id FROM custom_equipment"))
root = {"format": "trainlog-mobile-export", "version": 2,
"generated_at": datetime.now().astimezone().isoformat(),
"exercises": [], "sessions": [], "body_observations": []}
@ -34,6 +47,14 @@ def main():
# retain their stable entry_id but do not duplicate that field.
sql = "SELECT se.id,se.entry_id,se.position,se.recording_mode,e.tracking_mode,se.data_fields,se.equipment_id,e.exercise_id,e.name FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position"
for entry in con.execute(sql, (session["id"],)):
# CONTRACT: references remain in mobile-export v2 unchanged;
# definitions-v1 travels first and makes custom IDs resolvable.
if entry["equipment_id"] is not None and entry["equipment_id"] not in known_equipment:
raise ValueError(
"équipement non transportable "
f"session_id={session['session_id']} "
f"entry_id={entry['entry_id']}: {entry['equipment_id']}"
)
item = {"entry_id": entry["entry_id"], "position": entry["position"],
"exercise_id": entry["exercise_id"], "name": entry["name"],
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
@ -52,6 +73,15 @@ def main():
item["sets"].append(set_value)
payload["exercises"].append(item)
root["sessions"].append(payload)
metric_names = ("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")
columns = ",".join(("observation_id", "observed_at") + metric_names)
for row in con.execute(f"SELECT {columns} FROM body_observations ORDER BY observed_at,id"):
item = {"observation_id": row["observation_id"], "observed_at": row["observed_at"]}
item.update({name: row[name] for name in metric_names if row[name] is not None})
root["body_observations"].append(item)
args.output.write_text(json.dumps(root, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
print("PC_MOBILE_EXPORT=PASS")
print("sessions=" + str(len(root["sessions"])))

View file

@ -20,54 +20,139 @@ def load_catalog(path):
return {item["id"] for item in root["equipment"]}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
parser.add_argument("--database", type=Path, required=True)
parser.add_argument("--catalog", type=Path,
default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json")
args = parser.parse_args()
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
if payload.get("format") != FORMAT or payload.get("version") != VERSION:
fail("extension équipement non supportée")
if set(payload) != {"format", "version", "generated_at", "associations"}:
fail("clés extension équipement invalides")
known = load_catalog(args.catalog)
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] != 7:
fail("schema desktop v7 requis")
def validate_associations(associations, known):
"""Validate the complete v2 payload before any database mutation."""
validated = []
seen = set()
with connection:
for item in payload["associations"]:
for item in associations:
if not isinstance(item, dict):
fail("association invalide")
session_id, entry_id, exercise_id, state = (item.get(k) for k in ("session_id", "entry_id", "exercise_id", "state"))
session_id, entry_id, exercise_id, state = (
item.get(key) for key in ("session_id", "entry_id", "exercise_id", "state"))
key = (session_id, entry_id)
if not isinstance(session_id, str) or not session_id or not isinstance(entry_id, str) or not entry_id or not isinstance(exercise_id, str) or not exercise_id or key in seen:
if (not isinstance(session_id, str) or not session_id or
not isinstance(entry_id, str) or not entry_id or
not isinstance(exercise_id, str) or not exercise_id or key in seen):
fail("identité association invalide ou dupliquée")
seen.add(key)
if state == "set":
if set(item) != {"session_id", "entry_id", "exercise_id", "state", "equipment_id"}:
fail("association set invalide")
equipment_id = item["equipment_id"]
# CONTRACT: the definitions-v1 companion is imported first, so a
# custom ID is as valid here as a supplied catalog ID.
if not isinstance(equipment_id, str) or equipment_id not in known:
fail(f"équipement inconnu: {equipment_id}")
fail("équipement inconnu: "
f"session_id={session_id} entry_id={entry_id} equipment_id={equipment_id}")
elif state == "cleared":
if set(item) != {"session_id", "entry_id", "exercise_id", "state"}:
fail("association cleared invalide")
equipment_id = None
else:
fail("état association invalide")
cursor = connection.execute(
"UPDATE session_exercises SET equipment_id=? WHERE id=("
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id "
"WHERE s.session_id=? AND se.entry_id=?)",
(equipment_id, session_id, entry_id))
if cursor.rowcount != 1:
validated.append((session_id, entry_id, exercise_id, equipment_id))
return validated
def load_mobile_occurrences(path):
"""Return the source V2 identity claimed for every stable occurrence."""
payload = json.loads(path.read_text(encoding="utf-8"))
if (
payload.get("format") != "trainlog-mobile-export"
or payload.get("version") != 2
or not isinstance(payload.get("sessions"), list)
):
fail("snapshot mobile V2 de preuve invalide")
occurrences = {}
for session in payload["sessions"]:
if not isinstance(session, dict) or not isinstance(
session.get("exercises"), list
):
fail("snapshot mobile V2 de preuve invalide")
session_id = session.get("session_id")
for item in session["exercises"]:
if not isinstance(item, dict):
fail("snapshot mobile V2 de preuve invalide")
entry_id = item.get("entry_id")
exercise_id = item.get("exercise_id")
key = (session_id, entry_id)
if (
not isinstance(session_id, str)
or not session_id
or not isinstance(entry_id, str)
or not entry_id
or not isinstance(exercise_id, str)
or not exercise_id
or key in occurrences
):
fail("identité du snapshot mobile V2 de preuve invalide")
occurrences[key] = exercise_id
return occurrences
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
parser.add_argument("--database", type=Path, required=True)
parser.add_argument("--catalog", type=Path,
default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json")
parser.add_argument(
"--mobile-export",
type=Path,
help=(
"snapshot mobile V2 importé juste avant ce companion; requis "
"pour prouver un exercise_id réconcilié"
),
)
args = parser.parse_args()
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
if payload.get("format") != FORMAT or payload.get("version") != VERSION:
fail("extension équipement non supportée")
if set(payload) != {"format", "version", "generated_at", "associations"}:
fail("clés extension équipement invalides")
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] != 8:
fail("schema desktop v8 requis")
known = load_catalog(args.catalog)
known.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))
associations = validate_associations(payload["associations"], known)
proof_path = args.mobile_export
if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
if candidate.exists():
proof_path = candidate
mobile_occurrences = (
load_mobile_occurrences(proof_path)
if proof_path is not None
else {}
)
for session_id, entry_id, exercise_id, equipment_id in associations:
exists = connection.execute(
"SELECT e.exercise_id,se.equipment_id FROM session_exercises se "
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
"WHERE s.session_id=? AND se.entry_id=?",
(session_id, entry_id)).fetchone()
if exists is None:
fail(f"entrée séance inconnue: {session_id}/{entry_id}")
if exists[0] != exercise_id:
proof = mobile_occurrences.get((session_id, entry_id))
incoming_still_exists = connection.execute(
"SELECT 1 FROM exercises WHERE exercise_id=?;",
(exercise_id,),
).fetchone() is not None
if proof != exercise_id or incoming_still_exists:
fail(f"conflit exercice association: {session_id}/{entry_id}")
# WHY: import_mobile_export may have replaced a safe duplicate
# creator ID with the canonical desktop ID. entry_id is the V2
# occurrence identity; the just-validated source snapshot proves
# that this stale exercise_id belongs to that same occurrence.
if exists[1] != equipment_id:
fail(f"conflit association équipement: {session_id}/{entry_id}")
print("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS")
print(f"associations={len(seen)}")
print(f"associations={len(associations)}")
finally:
connection.close()

View file

@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Add/reconcile a strict custom-equipment definitions-v1 snapshot."""
import argparse
import json
import sqlite3
from pathlib import Path
FORMAT = "trainlog-equipment-definitions"
VERSION = 1
ROOT_KEYS = {"format", "version", "generated_at", "equipment"}
ITEM_KEYS = {"equipment_id", "display_name", "label_name", "equipment_type", "load_semantics"}
SEMANTICS = {"none", "external", "assistance"}
def fail(message):
raise ValueError(message)
def supplied_ids(path):
root = json.loads(path.read_text(encoding="utf-8"))
if root.get("format") != "trainlog-equipment-catalog" or root.get("version") != 1:
fail("catalogue équipement fourni non supporté")
return {item["id"] for item in root["equipment"]}
def validate(payload, reserved):
if not isinstance(payload, dict) or set(payload) != ROOT_KEYS:
fail("clés racine définitions équipement invalides")
if payload["format"] != FORMAT or payload["version"] != VERSION:
fail("définitions équipement non supportées")
if not isinstance(payload["generated_at"], str) or not payload["generated_at"]:
fail("generated_at invalide")
if not isinstance(payload["equipment"], list):
fail("equipment doit être un tableau")
output, seen = [], set()
for index, item in enumerate(payload["equipment"]):
if not isinstance(item, dict) or set(item) != ITEM_KEYS:
fail(f"equipment[{index}]: clés invalides")
values = tuple(item[key] for key in
("equipment_id", "display_name", "label_name", "equipment_type", "load_semantics"))
if any(not isinstance(value, str) for value in values):
fail(f"equipment[{index}]: chaînes attendues")
equipment_id, display_name, label_name, equipment_type, load_semantics = values
if (not equipment_id or equipment_id in seen or equipment_id in reserved or
not display_name.strip() or len(display_name) > 120 or
not equipment_type or load_semantics not in SEMANTICS):
fail(f"equipment[{index}]: définition invalide ou ID fourni réservé: {equipment_id}")
seen.add(equipment_id)
output.append(values)
return output
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
parser.add_argument("--database", type=Path, required=True)
parser.add_argument("--catalog", type=Path,
default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json")
args = parser.parse_args()
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version").fetchone()[0] != 8:
fail("schema desktop v8 requis")
imported = skipped = 0
# Validate every same-ID row before inserting any definition.
for definition in definitions:
row = connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment WHERE equipment_id=?", (definition[0],)).fetchone()
if row is not None and tuple(row) != definition:
fail(f"conflit définition équipement: {definition[0]}")
with connection:
for definition in definitions:
cursor = connection.execute(
"INSERT OR IGNORE INTO custom_equipment"
"(equipment_id,display_name,label_name,equipment_type,load_semantics) VALUES(?,?,?,?,?)",
definition)
if cursor.rowcount == 1:
imported += 1
else:
skipped += 1
print("EQUIPMENT_DEFINITIONS_IMPORT=PASS")
print(f"definitions_imported={imported}")
print(f"definitions_skipped={skipped}")
finally:
connection.close()
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"EQUIPMENT_DEFINITIONS_IMPORT=FAIL {error}")
raise SystemExit(1)

View file

@ -11,6 +11,11 @@ from pathlib import Path
FORMAT = "trainlog-mobile-export"
VERSION = 1
KNOWN_DATA_FIELDS = 3
DEFAULT_EQUIPMENT_CATALOG = (
Path(__file__).resolve().parents[1]
/ "catalog"
/ "equipment-v1.json"
)
TOP_LEVEL_KEYS = {
"format",
@ -236,6 +241,38 @@ def load_payload(path):
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,
@ -281,6 +318,7 @@ def validate_profile(
def validate_exercises(payload):
seen_ids = set()
profiles = {}
for index, item in enumerate(
payload["exercises"]
@ -318,7 +356,9 @@ def validate_exercises(payload):
label,
)
return seen_ids
profiles[exercise_id] = item
return profiles
def validate_set_item(
@ -406,6 +446,19 @@ def validate_session_exercise(
label,
)
catalog_profile = known_exercise_ids[exercise_id]
if (
recording_mode != catalog_profile["recording_mode"]
or tracking_mode != catalog_profile["tracking_mode"]
or item["data_fields"] & ~catalog_profile["data_fields"]
):
# CONTRACT: an occurrence snapshots the fields that actually existed
# when it was recorded. It may omit later optional catalog fields, but
# it may never claim a field absent from the catalog profile.
raise ImportFailure(
f"{label}: snapshot incompatible avec le profil catalogue"
)
if item["load_mode"] != "none":
raise ImportFailure(
f"{label}: mobile export v1 exige load_mode=none"
@ -516,6 +569,7 @@ def validate_session_exercise(
def validate_sessions(
payload,
known_exercise_ids,
known_equipment_ids,
):
seen_ids = set()
@ -582,6 +636,22 @@ def validate_sessions(
known_exercise_ids,
)
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)
@ -649,7 +719,7 @@ def validate_body(payload):
)
def validate_payload(payload):
def validate_payload(payload, known_equipment_ids):
exercise_ids = validate_exercises(
payload
)
@ -657,19 +727,22 @@ def validate_payload(payload):
validate_sessions(
payload,
exercise_ids,
known_equipment_ids,
)
validate_body(payload)
def require_schema_v5(connection):
def require_supported_schema(connection):
version = connection.execute(
"PRAGMA user_version;"
).fetchone()[0]
if version not in (5, 6, 7):
# CONTRACT: desktop startup migrates canonical databases through v8; the
# mobile-v2 tables used below retain their v7 shape in v8.
if version not in (5, 6, 7, 8):
raise ImportFailure(
f"base desktop schema v5, v6 ou v7 attendue, version trouvée: {version}"
f"base desktop schema v5, v6, v7 ou v8 attendue, version trouvée: {version}"
)
@ -715,17 +788,94 @@ def lookup_exercise_by_normalized(
).fetchone()
def profile_matches(
row,
exercise,
):
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 row["data_fields"]
== exercise["data_fields"]
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: the current v8 desktop schema has exactly one exercise-row FK owner.
# Keeping this operation explicit makes a future schema addition fail its
# reconciliation tests instead of silently leaving a dangling identity.
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"],),
)
@ -733,6 +883,7 @@ def import_exercises(
connection,
payload,
report,
trace_exercises=False,
):
mapping = {}
@ -748,12 +899,18 @@ def import_exercises(
)
if by_id is not None:
if not profile_matches(
if not profiles_are_reconcilable(
by_id,
exercise,
):
trace_exercise_decision(
trace_exercises,
exercise,
f"exercise_id:{by_id['exercise_id']}",
"conflict",
)
raise ImportFailure(
f"profil incompatible pour {exercise_id}"
profile_conflict(by_id, exercise)
)
by_name = lookup_exercise_by_normalized(
@ -764,9 +921,45 @@ def import_exercises(
by_name is not None
and by_name["id"] != by_id["id"]
):
raise ImportFailure(
"conflit de nom pour l'identité "
+ exercise_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
@ -779,22 +972,34 @@ def import_exercises(
connection.execute(
"""
UPDATE exercises
SET name = ?, normalized_name = ?
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(
@ -803,20 +1008,34 @@ def import_exercises(
)
if by_name is not None:
if not profile_matches(
by_name,
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,
):
raise ImportFailure(
"conflit de profil pour le nom "
+ exercise["name"]
)
mapping[exercise_id] = (
by_name["exercise_id"]
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(
@ -842,6 +1061,12 @@ def import_exercises(
mapping[exercise_id] = exercise_id
report["exercises_imported"] += 1
trace_exercise_decision(
trace_exercises,
exercise,
"none",
"inserted",
)
return mapping
@ -1037,7 +1262,17 @@ def import_sessions(
"SELECT s.id FROM sessions s WHERE s.session_id=?;",
(session["session_id"],)).fetchone()
session_row_id = existing[0]
incoming = [(x["entry_id"], x["exercise_id"]) for x in session["exercises"]]
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()
@ -1047,6 +1282,16 @@ def import_sessions(
# 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]):
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:
raise ImportFailure("conflit de contenu pour " + session["session_id"])
# 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,))
@ -1103,8 +1348,7 @@ def import_sessions(
!= item["tracking_mode"]
or row["recording_mode"]
!= item["recording_mode"]
or row["data_fields"]
!= item["data_fields"]
or item["data_fields"] & ~row["data_fields"]
):
raise ImportFailure(
"snapshot de séance incompatible avec le catalogue desktop"
@ -1135,6 +1379,47 @@ def import_sessions(
report["sessions_imported"] += 1
def session_semantically_matches(
connection,
session_row_id,
incoming,
exercise_mapping,
):
header = connection.execute(
"SELECT started_at,session_type FROM sessions WHERE id=?", (session_row_id,)).fetchone()
if header is None or (header[0], header[1]) != (incoming["started_at"], incoming["session_type"]):
return False
rows = connection.execute(
"SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode,e.tracking_mode,"
"se.data_fields,se.equipment_id FROM session_exercises se JOIN exercises e "
"ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position", (session_row_id,)).fetchall()
items = sorted(incoming["exercises"], key=lambda item: item["position"])
if len(rows) != len(items):
return False
for row, item in zip(rows, items):
canonical_exercise_id = exercise_mapping.get(item["exercise_id"])
if tuple(row[1:8]) != (item["entry_id"], item["position"], canonical_exercise_id,
item["recording_mode"], item["tracking_mode"],
item["data_fields"], item.get("equipment_id")):
return False
if 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,
@ -1194,10 +1479,13 @@ def import_body(
for observation in payload[
"body_observations"
]:
if body_exists(
connection,
observation["observation_id"],
):
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
@ -1223,6 +1511,7 @@ def run_import(
payload,
database_path,
dry_run,
trace_exercises=False,
):
if not database_path.exists():
raise ImportFailure(
@ -1251,7 +1540,7 @@ def run_import(
"PRAGMA foreign_keys = ON;"
)
require_schema_v5(
require_supported_schema(
connection
)
@ -1263,6 +1552,7 @@ def run_import(
connection,
payload,
report,
trace_exercises,
)
import_sessions(
@ -1342,6 +1632,18 @@ def main():
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:
@ -1349,14 +1651,20 @@ def main():
args.json_path
)
validate_payload(
payload
)
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(

View file

@ -11,10 +11,60 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 7
#define TRAINLOG_DATABASE_SCHEMA_VERSION 8
typedef struct TrainlogDatabase TrainlogDatabase;
/* WHY: session occurrences retain an equipment ID, while custom definitions
* need durable presentation metadata. A reference alone is never a definition. */
typedef struct TrainlogCustomEquipment {
char equipment_id[TRAINLOG_ID_MAX + 1U];
char display_name[TRAINLOG_NAME_MAX + 1U];
char label_name[TRAINLOG_NAME_MAX + 1U];
char equipment_type[TRAINLOG_NAME_MAX + 1U];
char load_semantics[32];
} TrainlogCustomEquipment;
typedef enum TrainlogEquipmentOrigin {
TRAINLOG_EQUIPMENT_SUPPLIED = 0,
TRAINLOG_EQUIPMENT_CUSTOM,
TRAINLOG_EQUIPMENT_UNKNOWN
} TrainlogEquipmentOrigin;
typedef struct TrainlogResolvedEquipment {
char equipment_id[TRAINLOG_ID_MAX + 1U];
char display_name[TRAINLOG_NAME_MAX + 1U];
char label_name[TRAINLOG_NAME_MAX + 1U];
char equipment_type[TRAINLOG_NAME_MAX + 1U];
char load_semantics[32];
TrainlogEquipmentOrigin origin;
} TrainlogResolvedEquipment;
TrainlogStatus trainlog_database_create_custom_equipment(
TrainlogDatabase *database,
const TrainlogCustomEquipment *equipment
);
TrainlogStatus trainlog_database_list_custom_equipment(
TrainlogDatabase *database,
TrainlogCustomEquipment *output,
size_t capacity,
size_t *output_count
);
/* CONTRACT: an occurrence ID always resolves to a visible value. Unknown IDs
* are returned verbatim with TRAINLOG_EQUIPMENT_UNKNOWN, never hidden. */
TrainlogStatus trainlog_database_resolve_equipment(
TrainlogDatabase *database,
const char *equipment_id,
TrainlogResolvedEquipment *output
);
TrainlogStatus trainlog_database_list_exercise_equipment(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogResolvedEquipment *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_open(
const char *path,
TrainlogDatabase **output_database

View file

@ -14,33 +14,40 @@
#define TRAINLOG_UUID_TEXT_LENGTH 36U
/**
* Longest official v1 generated identifier:
* two-character type prefix + '_' + UUID text + terminating NUL.
* Longest official generated identifier:
* three-character type prefix + '_' + UUID text + terminating NUL.
*
* CONTRACT: occurrence identities use the longer `sxe` prefix while the
* frozen v1 object identities retain their existing two-character prefixes.
*/
#define TRAINLOG_GENERATED_ID_CAPACITY \
(2U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U)
(3U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U)
/**
* @brief Generate an official Trainlog v1 identifier using UUID version 4.
* @brief Generate an official Trainlog identifier using UUID version 4.
*
* The frozen v1 contract requires official creators to generate identifiers
* in the form `<prefix>_<uuid-v4>`. The function accepts only the currently
* reserved two-character prefixes:
* Official creators generate identifiers in the form
* `<prefix>_<uuid-v4>`. The function accepts only the currently reserved
* prefixes:
*
* - `ex` for exercises;
* - `se` for sessions;
* - `bo` for body observations.
* - `sy` for synchronization runs;
* - `sxe` for persisted session-exercise occurrences.
*
* Imported v1 documents may contain other schema-valid opaque identifiers;
* this API defines creation policy, not import validation.
*
* @param prefix Two-character Trainlog object prefix.
* @param prefix Reserved Trainlog object prefix.
* @param output Caller-owned output buffer.
* @param output_size Size of @p output in bytes.
*
* @return TRAINLOG_STATUS_OK on success.
* @return TRAINLOG_STATUS_INVALID_ARGUMENT for invalid pointers, prefix, or a
* buffer smaller than TRAINLOG_GENERATED_ID_CAPACITY.
* buffer smaller than that prefix's exact identifier representation.
* Existing two-character prefixes still require 40 bytes; `sxe`
* requires TRAINLOG_GENERATED_ID_CAPACITY bytes.
*/
TrainlogStatus trainlog_id_generate(
const char *prefix,

View file

@ -74,6 +74,7 @@ typedef struct TrainlogMtpEntry {
uint32_t parent_id;
uint32_t storage_id;
uint64_t size_bytes;
uint64_t modification_unix_seconds;
bool folder;
char name[TRAINLOG_MTP_ENTRY_NAME_MAX + 1U];
} TrainlogMtpEntry;

View file

@ -23,6 +23,17 @@ typedef enum TrainlogSyncTrigger {
TRAINLOG_SYNC_TRIGGER_DAEMON
} TrainlogSyncTrigger;
typedef enum TrainlogSyncDirection {
TRAINLOG_SYNC_ANDROID_TO_PC = 0,
TRAINLOG_SYNC_PC_TO_ANDROID,
TRAINLOG_SYNC_BIDIRECTIONAL
} TrainlogSyncDirection;
typedef struct TrainlogSyncDirectionPlan {
bool receive_android;
bool publish_android;
} TrainlogSyncDirectionPlan;
typedef struct TrainlogSyncDeviceInfo {
bool connected;
bool storage_ready;
@ -33,6 +44,7 @@ typedef struct TrainlogSyncDeviceInfo {
typedef struct TrainlogSyncReport {
bool success;
bool request_present;
TrainlogSyncDirection direction;
char sync_id[
TRAINLOG_ID_MAX + 1U
@ -56,6 +68,9 @@ typedef struct TrainlogSyncReport {
size_t body_imported;
size_t body_skipped;
size_t equipment_definitions_imported;
size_t equipment_definitions_skipped;
size_t catalog_published;
char summary[
@ -67,6 +82,59 @@ typedef struct TrainlogSyncReport {
];
} TrainlogSyncReport;
TrainlogSyncDirectionPlan trainlog_sync_direction_plan(
TrainlogSyncDirection direction
);
/**
* @brief Build the user-visible mutation summary for an initialized report.
*
* CONTRACT: the `+N exercice(s)` value counts only newly inserted persistent
* exercise rows. Reconciliation and idempotent lookup remain available in
* their dedicated report fields and must never be presented as additions.
* The function borrows `report`, mutates only its `summary` array, performs no
* allocation or persistence, and is deterministic for equal report fields.
* A NULL report is accepted as a no-op; output is always bounded and NUL
* terminated by the fixed Trainlog report storage.
*/
void trainlog_sync_build_summary(
TrainlogSyncReport *report
);
/**
* @brief Select the current Android mobile-export V2 entry from one folder.
*
* Android's scoped-storage provider can retain an existing immutable object
* and create a collision-suffixed sibling such as
* `trainlog-mobile-export-v2 (2).json`. Both that form and the canonical
* filename are valid V2 publications. The newest usable MTP modification
* time wins; deterministic suffix and item-ID tie breaks prevent arbitrary
* selection when a device reports equal or unavailable times. This routine
* only selects a candidate. The caller must still validate its JSON before
* import and must never fall back to an older candidate on validation error.
*/
bool trainlog_sync_select_mobile_export(
const TrainlogMtpEntry *entries,
size_t count,
size_t *output_index
);
/**
* @brief Select the latest Android artifact for one canonical JSON filename.
*
* The canonical name and Android's scoped-storage collision form
* `<stem> (N).json` are accepted. @p canonical_name is borrowed and must be
* a nonempty `.json` filename. False reports invalid arguments or no valid
* candidate. This selection is deterministic, but callers must validate the
* selected artifact before making any persistent import decision.
*/
bool trainlog_sync_select_android_artifact(
const TrainlogMtpEntry *entries,
size_t count,
const char *canonical_name,
size_t *output_index
);
/**
* @brief Probe one currently connected MTP device without mutating it.
*/
@ -75,16 +143,17 @@ TrainlogStatus trainlog_sync_probe(
);
/**
* @brief Run one complete bidirectional synchronization transaction.
* @brief Run one synchronization transaction in the selected direction.
*
* When require_request is true, the transaction runs only if Android has
* published a new trainlog-sync-request-v1 request ID.
*
* The engine is shared by the ncurses TUI and trainlog-syncd.
* The engine is shared by the Notcurses TUI and trainlog-syncd.
*/
TrainlogStatus trainlog_sync_run(
TrainlogSyncTrigger trigger,
bool require_request,
TrainlogSyncDirection direction,
TrainlogSyncReport *output
);

View file

@ -0,0 +1,51 @@
#ifndef TRAINLOG_SYNC_HISTORY_H
#define TRAINLOG_SYNC_HISTORY_H
/**
* @file sync_history.h
* @brief Local, backward-compatible synchronization history records.
*
* This format is local diagnostic state. It is not a synchronization
* exchange artifact and has no effect on TRAINLOG_FORMAT_V1.
*/
#include <stdbool.h>
#include "trainlog/sync.h"
typedef struct TrainlogSyncHistoryEntry {
char sync_id[TRAINLOG_ID_MAX + 1U];
char timestamp[17];
bool success;
bool direction_known;
TrainlogSyncDirection direction;
char summary[TRAINLOG_SYNC_SUMMARY_MAX + 1U];
} TrainlogSyncHistoryEntry;
/** Parse one complete history line. New records require an a/p/b field. */
bool trainlog_sync_history_parse_line(
const char *line,
TrainlogSyncHistoryEntry *output
);
/** Return the stable one-character local-history encoding for a direction. */
const char *trainlog_sync_history_direction_code(
TrainlogSyncDirection direction
);
/** Return the user-facing label, including unknown for legacy records. */
const char *trainlog_sync_history_direction_label(
const TrainlogSyncHistoryEntry *entry
);
/**
* Persist a completed local run record, structured JSON, and text detail.
* Exposed for deterministic local-history regression coverage; no MTP work is
* performed by this function.
*/
bool trainlog_sync_record_local_run(
TrainlogSyncTrigger trigger,
const TrainlogSyncReport *report
);
#endif

View file

@ -0,0 +1,68 @@
#ifndef TRAINLOG_SYNC_SCREEN_ACTION_H
#define TRAINLOG_SYNC_SCREEN_ACTION_H
/**
* @file sync_screen_action.h
* @brief TUI-owned sync-screen keyboard actions and confirmation state.
*/
#include <stdbool.h>
#include <stddef.h>
#include "trainlog/status.h"
#include "trainlog/sync.h"
typedef enum TrainlogSyncScreenEffect {
TRAINLOG_SYNC_SCREEN_NONE = 0,
TRAINLOG_SYNC_SCREEN_CONFIRM,
TRAINLOG_SYNC_SCREEN_RUN,
TRAINLOG_SYNC_SCREEN_CANCEL,
TRAINLOG_SYNC_SCREEN_REFRESH,
TRAINLOG_SYNC_SCREEN_EXIT
} TrainlogSyncScreenEffect;
typedef struct TrainlogSyncScreenState {
bool confirming;
TrainlogSyncDirection direction;
} TrainlogSyncScreenState;
typedef struct TrainlogSyncScreenAction {
TrainlogSyncScreenEffect effect;
TrainlogSyncDirection direction;
} TrainlogSyncScreenAction;
typedef TrainlogStatus (*TrainlogSyncScreenRunCallback)(
TrainlogSyncDirection direction,
TrainlogSyncReport *report,
void *context
);
void trainlog_sync_screen_state_init(
TrainlogSyncScreenState *state
);
TrainlogSyncScreenAction trainlog_sync_screen_dispatch(
TrainlogSyncScreenState *state,
int key
);
TrainlogStatus trainlog_sync_screen_execute(
const TrainlogSyncScreenAction *action,
TrainlogSyncScreenRunCallback callback,
void *context,
TrainlogSyncReport *report
);
const char *trainlog_sync_screen_direction_label(
TrainlogSyncDirection direction
);
const char *trainlog_sync_screen_confirmation(
TrainlogSyncDirection direction
);
const char *trainlog_sync_screen_footer(
int terminal_columns
);
#endif

View file

@ -43,6 +43,7 @@ trainlog_core_sources = files(
'src/reps.c',
'src/measured_max.c',
'src/sync.c',
'src/sync_history.c',
)
trainlog_core_sources += equipment_catalog_generated
@ -76,6 +77,7 @@ trainlog_core_dep = declare_dependency(
trainlog_tui_sources = files(
'src/main.c',
'src/sync_screen_action.c',
'src/terminal.c',
'src/theme.c',
'src/tui.c',
@ -124,6 +126,14 @@ test_equipment_catalog = executable(
)
test('equipment_catalog', test_equipment_catalog)
test_custom_equipment = executable(
'test_custom_equipment',
'tests/test_custom_equipment.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('custom_equipment', test_custom_equipment)
test_session_detail = executable(
'test_session_detail',
'tests/test_session_detail.c',
@ -341,6 +351,18 @@ test(
test_schema_v5_migration,
)
test_schema_v7_migration = executable(
'test_schema_v7_migration',
'tests/test_schema_v7_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test(
'schema_v7_migration',
test_schema_v7_migration,
)
python3_trainlog_tests = find_program('python3')
test(
@ -363,6 +385,46 @@ test(
args: [meson.project_source_root() / 'tests/test_equipment_associations_exchange.py'],
)
test(
'equipment_definitions_exchange',
python3_trainlog_tests,
args: [meson.project_source_root() / 'tests/test_equipment_definitions_exchange.py'],
)
test(
'exercise_reconciliation',
python3_trainlog_tests,
args: [meson.project_source_root() / 'tests/test_exercise_reconciliation.py'],
)
test_sync_direction = executable(
'test_sync_direction',
'tests/test_sync_direction.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('sync_direction', test_sync_direction)
test_sync_history = executable(
'test_sync_history',
'tests/test_sync_history.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('sync_history', test_sync_history)
test_sync_screen_action = executable(
'test_sync_screen_action',
'tests/test_sync_screen_action.c',
'src/sync_screen_action.c',
dependencies: [trainlog_core_dep, notcurses_dep],
c_args: strict_c_args,
)
test('sync_screen_action', test_sync_screen_action)
trainlog_sync_once = executable(
'trainlog-sync-once',
'tools/sync_once.c',

View file

@ -19,6 +19,21 @@ struct TrainlogDatabase {
sqlite3 *connection;
};
static bool custom_equipment_exists(TrainlogDatabase *database, const char *equipment_id)
{
sqlite3_stmt *statement = NULL;
int rc;
bool found = false;
if (database == NULL || equipment_id == NULL) return false;
rc = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM custom_equipment WHERE equipment_id=?1;", -1, &statement, NULL);
if (rc != SQLITE_OK) return false;
(void)sqlite3_bind_text(statement, 1, equipment_id, -1, SQLITE_TRANSIENT);
found = sqlite3_step(statement) == SQLITE_ROW;
(void)sqlite3_finalize(statement);
return found;
}
static void set_open_diagnostic(
char *output,
size_t capacity,
@ -28,15 +43,42 @@ static void set_open_diagnostic(
)
{
const char *message;
int extended_status = sqlite_status;
if (output == NULL || capacity == 0U) {
return;
}
message = connection != NULL
? sqlite3_errmsg(connection)
: sqlite3_errstr(sqlite_status);
(void)snprintf(output, capacity, "%s: %s", operation, message);
if (connection != NULL) {
message = sqlite3_errmsg(connection);
sqlite_status = sqlite3_errcode(connection);
extended_status = sqlite3_extended_errcode(connection);
} else {
message = sqlite3_errstr(sqlite_status);
}
/* WHY: preserve the actionable SQLite result while the owning connection
* is still open; rollback/close may replace its diagnostic state. */
(void)snprintf(
output,
capacity,
"%s: SQLite rc=%d extended_rc=%d: %s",
operation,
sqlite_status,
extended_status,
message
);
}
static void set_application_diagnostic(
char *output,
size_t capacity,
const char *message
)
{
if (output == NULL || capacity == 0U) {
return;
}
(void)snprintf(output, capacity, "%s", message);
}
static const char *const SCHEMA_V7_SQL_A =
@ -210,6 +252,17 @@ static const char *const MIGRATE_V6_TO_V7_SQL =
"DROP TABLE performed_sets_v6;DROP TABLE continuous_activity_v6;DROP TABLE session_exercises_v6;"
"PRAGMA user_version = 7;COMMIT;PRAGMA foreign_keys = ON;";
/* CONTRACT: v8 stores only user-created equipment definitions. Canonical
* equipment remains generated from the frozen manifest; historic IDs are not
* backfilled because an occurrence reference has no trustworthy metadata. */
static const char *const MIGRATE_V7_TO_V8_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE custom_equipment ("
"equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,"
"label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,"
"load_semantics TEXT NOT NULL CHECK(load_semantics IN ('none','external','assistance'))"
");PRAGMA user_version = 8;COMMIT;";
static const char *const MIGRATE_V1_TO_V3_SQL =
"BEGIN IMMEDIATE;"
"ALTER TABLE sessions "
@ -576,13 +629,13 @@ static TrainlogStatus initialize_or_validate_schema(
version >
TRAINLOG_DATABASE_SCHEMA_VERSION
) {
char message[128];
(void)snprintf(
output_diagnostic,
output_diagnostic_capacity,
message, sizeof(message),
"schema version %d is newer than supported version %d",
version,
TRAINLOG_DATABASE_SCHEMA_VERSION
);
version, TRAINLOG_DATABASE_SCHEMA_VERSION);
set_application_diagnostic(
output_diagnostic, output_diagnostic_capacity, message);
return
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
}
@ -611,6 +664,13 @@ static TrainlogStatus initialize_or_validate_schema(
SCHEMA_V7_SQL_B
);
}
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V7_TO_V8_SQL);
}
} else if (version == 7) {
/* CONTRACT: v7 is the immediate historic schema and must open through
* its lossless custom-equipment-table migration. */
status = execute_sql(database, MIGRATE_V7_TO_V8_SQL);
} else {
if (version == 1) {
status =
@ -693,6 +753,13 @@ static TrainlogStatus initialize_or_validate_schema(
} else if (version == 6) {
status = TRAINLOG_STATUS_OK;
} else {
char message[128];
(void)snprintf(
message, sizeof(message),
"schema version %d is unsupported (supported through version %d)",
version, TRAINLOG_DATABASE_SCHEMA_VERSION);
set_application_diagnostic(
output_diagnostic, output_diagnostic_capacity, message);
return
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
}
@ -725,6 +792,9 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V6_TO_V7_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V7_TO_V8_SQL);
}
}
if (
@ -734,7 +804,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v7" : "migrate database to schema v7",
version == 0 ? "create schema v8" : "migrate database to schema v8",
database->connection,
SQLITE_ERROR
);
@ -896,6 +966,141 @@ TrainlogStatus trainlog_database_foreign_keys_enabled(
);
}
TrainlogStatus trainlog_database_create_custom_equipment(
TrainlogDatabase *database,
const TrainlogCustomEquipment *equipment
)
{
sqlite3_stmt *statement = NULL;
int rc;
/* CONTRACT: custom IDs must not shadow frozen manifest identities. */
if (database == NULL || equipment == NULL || equipment->equipment_id[0] == '\0' ||
equipment->display_name[0] == '\0' || equipment->equipment_type[0] == '\0' ||
trainlog_equipment_catalog_lookup(equipment->equipment_id) != NULL ||
(strcmp(equipment->load_semantics, "none") != 0 &&
strcmp(equipment->load_semantics, "external") != 0 &&
strcmp(equipment->load_semantics, "assistance") != 0)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
rc = sqlite3_prepare_v2(database->connection,
"INSERT INTO custom_equipment(equipment_id,display_name,label_name,equipment_type,load_semantics) VALUES(?1,?2,?3,?4,?5);",
-1, &statement, NULL);
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
(void)sqlite3_bind_text(statement, 1, equipment->equipment_id, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_text(statement, 2, equipment->display_name, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_text(statement, 3, equipment->label_name, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_text(statement, 4, equipment->equipment_type, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_text(statement, 5, equipment->load_semantics, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(statement);
(void)sqlite3_finalize(statement);
return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_database_list_custom_equipment(
TrainlogDatabase *database, TrainlogCustomEquipment *output,
size_t capacity, size_t *output_count
)
{
sqlite3_stmt *statement = NULL;
size_t count = 0U;
int rc;
if (database == NULL || output_count == NULL || (capacity > 0U && output == NULL))
return TRAINLOG_STATUS_INVALID_ARGUMENT;
*output_count = 0U;
rc = sqlite3_prepare_v2(database->connection,
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment ORDER BY display_name COLLATE NOCASE,equipment_id;",
-1, &statement, NULL);
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
if (count < capacity) {
TrainlogCustomEquipment *item = &output[count];
(void)memset(item, 0, sizeof(*item));
(void)snprintf(item->equipment_id, sizeof(item->equipment_id), "%s", sqlite3_column_text(statement, 0));
(void)snprintf(item->display_name, sizeof(item->display_name), "%s", sqlite3_column_text(statement, 1));
(void)snprintf(item->label_name, sizeof(item->label_name), "%s", sqlite3_column_text(statement, 2));
(void)snprintf(item->equipment_type, sizeof(item->equipment_type), "%s", sqlite3_column_text(statement, 3));
(void)snprintf(item->load_semantics, sizeof(item->load_semantics), "%s", sqlite3_column_text(statement, 4));
}
++count;
}
(void)sqlite3_finalize(statement);
*output_count = count;
return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_database_resolve_equipment(
TrainlogDatabase *database, const char *equipment_id,
TrainlogResolvedEquipment *output
)
{
const TrainlogEquipment *supplied;
sqlite3_stmt *statement = NULL;
int rc;
if (database == NULL || equipment_id == NULL || equipment_id[0] == '\0' ||
output == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT;
(void)memset(output, 0, sizeof(*output));
(void)snprintf(output->equipment_id, sizeof(output->equipment_id), "%s", equipment_id);
supplied = trainlog_equipment_catalog_lookup(equipment_id);
if (supplied != NULL) {
(void)snprintf(output->display_name, sizeof(output->display_name), "%s", supplied->display_name);
(void)snprintf(output->label_name, sizeof(output->label_name), "%s", supplied->label_name);
(void)snprintf(output->equipment_type, sizeof(output->equipment_type), "%s", supplied->equipment_type);
(void)snprintf(output->load_semantics, sizeof(output->load_semantics), "%s", supplied->load_semantics);
output->origin = TRAINLOG_EQUIPMENT_SUPPLIED;
return TRAINLOG_STATUS_OK;
}
rc = sqlite3_prepare_v2(database->connection,
"SELECT display_name,label_name,equipment_type,load_semantics FROM custom_equipment WHERE equipment_id=?1;",
-1, &statement, NULL);
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
(void)sqlite3_bind_text(statement, 1, equipment_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(statement);
if (rc == SQLITE_ROW) {
(void)snprintf(output->display_name, sizeof(output->display_name), "%s", sqlite3_column_text(statement, 0));
(void)snprintf(output->label_name, sizeof(output->label_name), "%s", sqlite3_column_text(statement, 1));
(void)snprintf(output->equipment_type, sizeof(output->equipment_type), "%s", sqlite3_column_text(statement, 2));
(void)snprintf(output->load_semantics, sizeof(output->load_semantics), "%s", sqlite3_column_text(statement, 3));
output->origin = TRAINLOG_EQUIPMENT_CUSTOM;
} else if (rc == SQLITE_DONE) {
/* INVARIANT: historic references survive catalogue changes visibly. */
(void)snprintf(output->display_name, sizeof(output->display_name), "Inconnu (%s)", equipment_id);
output->origin = TRAINLOG_EQUIPMENT_UNKNOWN;
rc = SQLITE_ROW;
}
(void)sqlite3_finalize(statement);
return rc == SQLITE_ROW ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_database_list_exercise_equipment(
TrainlogDatabase *database, const char *exercise_id,
TrainlogResolvedEquipment *output, size_t capacity, size_t *output_count
)
{
sqlite3_stmt *statement = NULL;
size_t count = 0U;
int rc;
if (database == NULL || exercise_id == NULL || output_count == NULL ||
(capacity > 0U && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
*output_count = 0U;
rc = sqlite3_prepare_v2(database->connection,
"SELECT DISTINCT se.equipment_id FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE e.exercise_id=?1 AND se.equipment_id IS NOT NULL AND se.equipment_id<>'' ORDER BY se.equipment_id;",
-1, &statement, NULL);
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
(void)sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
const char *id = (const char *)sqlite3_column_text(statement, 0);
if (count < capacity && trainlog_database_resolve_equipment(database, id, &output[count]) != TRAINLOG_STATUS_OK) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
++count;
}
(void)sqlite3_finalize(statement);
*output_count = count;
return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_database_begin(TrainlogDatabase *database)
{
return execute_sql(database, "BEGIN IMMEDIATE;");
@ -1574,15 +1779,17 @@ static TrainlogStatus insert_session_exercise(
}
if (input->equipment_id[0] != '\0' &&
trainlog_equipment_catalog_lookup(input->equipment_id) == NULL) {
/* INVARIANT: a persisted occurrence only names a manifest identity. */
trainlog_equipment_catalog_lookup(input->equipment_id) == NULL &&
!custom_equipment_exists(database, input->equipment_id)) {
/* INVARIANT: a persisted occurrence names a known canonical or custom definition. */
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
/* Local TUI creation has no transport-supplied identity; allocate it once
/* WHY: entry_id, not position or exercise_id, is the durable identity of
* one occurrence. Local TUI creation therefore allocates it exactly once
* at persistence, while imported V2 identities pass through unchanged. */
if (input->entry_id[0] == '\0') {
status = trainlog_id_generate("sy", generated_entry_id,
status = trainlog_id_generate("sxe", generated_entry_id,
sizeof(generated_entry_id));
if (status != TRAINLOG_STATUS_OK) {
return status;

View file

@ -21,7 +21,8 @@ static bool prefix_is_supported(const char *prefix)
return strcmp(prefix, "ex") == 0 ||
strcmp(prefix, "se") == 0 ||
strcmp(prefix, "bo") == 0 ||
strcmp(prefix, "sy") == 0;
strcmp(prefix, "sy") == 0 ||
strcmp(prefix, "sxe") == 0;
}
TrainlogStatus trainlog_id_generate(
@ -39,7 +40,7 @@ TrainlogStatus trainlog_id_generate(
}
if (!prefix_is_supported(prefix) ||
output_size < TRAINLOG_GENERATED_ID_CAPACITY) {
output_size < strlen(prefix) + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}

View file

@ -682,6 +682,16 @@ TrainlogStatus trainlog_mtp_list_folder(
entry->size_bytes =
item->filesize;
/* MTP exposes this value with file metadata. Keep the transport
* timestamp separate from the filename: callers use it only to
* choose the newest immutable exchange artifact, never as a
* domain timestamp. A zero value means the device did not supply
* a usable modification time. */
if (item->modificationdate > 0) {
entry->modification_unix_seconds =
(uint64_t)item->modificationdate;
}
entry->folder =
item->filetype ==
LIBMTP_FILETYPE_FOLDER;

View file

@ -19,6 +19,7 @@
#include <unistd.h>
#include "trainlog/id.h"
#include "trainlog/sync_history.h"
#include "trainlog/timeutil.h"
#define SYNC_DEVICE_CAPACITY 8U
@ -42,6 +43,12 @@ static const char *const PC_CATALOG_NAME =
static const char *const EQUIPMENT_ASSOCIATIONS_NAME =
"trainlog-equipment-associations-v2.json";
static const char *const MOBILE_EQUIPMENT_DEFINITIONS_NAME =
"trainlog-mobile-equipment-definitions-v1.json";
static const char *const PC_EQUIPMENT_DEFINITIONS_NAME =
"trainlog-pc-equipment-definitions-v1.json";
static const char *const SYNC_REQUEST_NAME =
"trainlog-sync-request-v1.json";
@ -60,6 +67,15 @@ static const char *const PC_CATALOG_LOCAL =
static const char *const EQUIPMENT_ASSOCIATIONS_LOCAL =
"/tmp/trainlog-equipment-associations-v2.json";
static const char *const MOBILE_EQUIPMENT_DEFINITIONS_LOCAL =
"/tmp/trainlog-mobile-equipment-definitions-v1.json";
static const char *const PC_EQUIPMENT_DEFINITIONS_LOCAL =
"/tmp/trainlog-pc-equipment-definitions-v1.json";
static const char *const EQUIPMENT_DEFINITIONS_RESULT =
"/tmp/trainlog-equipment-definitions-result.txt";
static const char *const SYNC_REQUEST_LOCAL =
"/tmp/trainlog-sync-request-v1.json";
@ -81,6 +97,22 @@ typedef struct SyncSilence {
int null_fd;
} SyncSilence;
TrainlogSyncDirectionPlan trainlog_sync_direction_plan(
TrainlogSyncDirection direction
)
{
TrainlogSyncDirectionPlan plan = {false, false};
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC ||
direction == TRAINLOG_SYNC_BIDIRECTIONAL) {
plan.receive_android = true;
}
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID ||
direction == TRAINLOG_SYNC_BIDIRECTIONAL) {
plan.publish_android = true;
}
return plan;
}
static bool ensure_directory(
const char *path
)
@ -673,6 +705,137 @@ static TrainlogStatus sync_find_child(
return TRAINLOG_STATUS_NOT_FOUND;
}
static bool sync_android_artifact_ordinal(
const char *name,
const char *canonical_name,
uint32_t *output_ordinal
)
{
static const char suffix[] = ").json";
static const char extension[] = ".json";
size_t suffix_length;
size_t extension_length;
size_t canonical_length;
size_t stem_length;
size_t name_length;
size_t index;
uint32_t ordinal = 0U;
if (name == NULL || canonical_name == NULL || output_ordinal == NULL) {
return false;
}
if (strcmp(name, canonical_name) == 0) {
*output_ordinal = 0U;
return true;
}
suffix_length = sizeof(suffix) - 1U;
extension_length = sizeof(extension) - 1U;
canonical_length = strlen(canonical_name);
if (canonical_length <= extension_length ||
strcmp(canonical_name + canonical_length - extension_length,
extension) != 0) {
return false;
}
stem_length = canonical_length - extension_length;
name_length = strlen(name);
if (name_length < stem_length + 3U + suffix_length ||
strncmp(name, canonical_name, stem_length) != 0 ||
name[stem_length] != ' ' ||
name[stem_length + 1U] != '(' ||
strcmp(name + name_length - suffix_length, suffix) != 0) {
return false;
}
for (index = stem_length + 2U;
index < name_length - suffix_length;
++index) {
unsigned int digit;
if (name[index] < '0' || name[index] > '9') {
return false;
}
digit = (unsigned int)(name[index] - '0');
if (ordinal > (UINT32_MAX - digit) / 10U) {
return false;
}
ordinal = ordinal * 10U + digit;
}
if (ordinal == 0U) {
return false;
}
*output_ordinal = ordinal;
return true;
}
bool trainlog_sync_select_android_artifact(
const TrainlogMtpEntry *entries,
size_t count,
const char *canonical_name,
size_t *output_index
)
{
bool found = false;
uint64_t best_modified = 0U;
uint32_t best_ordinal = 0U;
uint32_t best_item_id = 0U;
size_t best_index = 0U;
size_t index;
if (entries == NULL || count == 0U || canonical_name == NULL ||
canonical_name[0] == '\0' || output_index == NULL) {
return false;
}
for (index = 0U; index < count; ++index) {
uint32_t ordinal;
const TrainlogMtpEntry *entry = &entries[index];
if (entry->folder ||
!sync_android_artifact_ordinal(entry->name, canonical_name,
&ordinal)) {
continue;
}
if (!found ||
entry->modification_unix_seconds > best_modified ||
(entry->modification_unix_seconds == best_modified &&
ordinal > best_ordinal) ||
(entry->modification_unix_seconds == best_modified &&
ordinal == best_ordinal && entry->item_id < best_item_id)) {
found = true;
best_modified = entry->modification_unix_seconds;
best_ordinal = ordinal;
best_item_id = entry->item_id;
best_index = index;
}
}
if (!found) {
return false;
}
*output_index = best_index;
return true;
}
bool trainlog_sync_select_mobile_export(
const TrainlogMtpEntry *entries,
size_t count,
size_t *output_index
)
{
return trainlog_sync_select_android_artifact(entries, count,
MOBILE_EXPORT_NAME,
output_index);
}
static TrainlogStatus sync_find_exchange_folder(
const TrainlogSyncDeviceInfo *device,
uint32_t *output_folder_id
@ -774,6 +937,54 @@ static TrainlogStatus sync_receive_named(
return status;
}
static TrainlogStatus sync_receive_current_android_artifact(
const TrainlogSyncDeviceInfo *device,
uint32_t folder_id,
const char *canonical_name,
const char *local_path,
uint64_t *output_size
)
{
TrainlogMtpEntry entries[SYNC_ENTRY_CAPACITY];
size_t count = 0U;
size_t selected_index;
TrainlogStatus status;
if (device == NULL || canonical_name == NULL || local_path == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = trainlog_mtp_list_folder(
device->device.bus_number,
device->device.device_number,
device->storage.storage_id,
folder_id,
entries,
SYNC_ENTRY_CAPACITY,
&count
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
if (!trainlog_sync_select_android_artifact(entries, count, canonical_name,
&selected_index)) {
return TRAINLOG_STATUS_NOT_FOUND;
}
status = trainlog_mtp_receive_file(
device->device.bus_number,
device->device.device_number,
entries[selected_index].item_id,
local_path
);
if (status == TRAINLOG_STATUS_OK && output_size != NULL) {
*output_size = entries[selected_index].size_bytes;
}
return status;
}
static TrainlogStatus sync_publish_named(
const TrainlogSyncDeviceInfo *device,
uint32_t folder_id,
@ -1521,6 +1732,25 @@ static const char *sync_trigger_text(
}
}
static const char *sync_direction_text(
TrainlogSyncDirection direction
)
{
switch (direction) {
case TRAINLOG_SYNC_ANDROID_TO_PC:
return "android_to_pc";
case TRAINLOG_SYNC_PC_TO_ANDROID:
return "pc_to_android";
case TRAINLOG_SYNC_BIDIRECTIONAL:
return "bidirectional";
default:
return NULL;
}
}
static bool sync_local_timestamp(
char output[17]
)
@ -1654,6 +1884,37 @@ static bool sync_json_write_escaped(
) != EOF;
}
static bool sync_history_write_summary(
FILE *file,
const char *summary
)
{
size_t index;
if (file == NULL || summary == NULL) {
return false;
}
for (
index = 0U;
index < TRAINLOG_SYNC_SUMMARY_MAX && summary[index] != '\0';
++index
) {
const char value =
summary[index] == '\t' ||
summary[index] == '\r' ||
summary[index] == '\n'
? ' '
: summary[index];
if (fputc((unsigned char)value, file) == EOF) {
return false;
}
}
return true;
}
static bool sync_write_receipt(
const TrainlogSyncReport *report
)
@ -1769,7 +2030,7 @@ static bool sync_write_receipt(
fclose(file) == 0;
}
static bool sync_record_run(
bool trainlog_sync_record_local_run(
TrainlogSyncTrigger trigger,
const TrainlogSyncReport *report
)
@ -1796,9 +2057,45 @@ static bool sync_record_run(
FILE *detail_file;
FILE *history_file;
int written;
const char *direction_code;
const char *direction_text;
const char *direction_label;
TrainlogSyncHistoryEntry direction_entry;
(void)memset(
&direction_entry,
0,
sizeof(direction_entry)
);
if (report != NULL) {
direction_entry.direction_known = true;
direction_entry.direction = report->direction;
}
direction_code =
report == NULL
? NULL
: trainlog_sync_history_direction_code(
report->direction
);
direction_text =
report == NULL
? NULL
: sync_direction_text(
report->direction
);
direction_label =
report == NULL
? NULL
: trainlog_sync_history_direction_label(
&direction_entry
);
if (
report == NULL ||
direction_code == NULL ||
direction_text == NULL ||
direction_label == NULL ||
report->sync_id[0] == '\0' ||
!sync_runs_directory(
directory,
@ -1910,6 +2207,21 @@ static bool sync_record_run(
return false;
}
(void)fputs(
",\n \"direction\":",
json_file
);
if (
!sync_json_write_escaped(
json_file,
direction_text
)
) {
(void)fclose(json_file);
return false;
}
(void)fputs(
",\n \"status\":",
json_file
@ -2003,12 +2315,14 @@ static bool sync_record_run(
detail_file,
"SYNC %s\n\n"
"Déclencheur : %s\n"
"Direction : %s\n"
"Début : %s\n"
"État : %s\n",
report->sync_id,
sync_trigger_text(
trigger
),
direction_label,
local_timestamp,
report->success
? "succès"
@ -2078,25 +2392,38 @@ static bool sync_record_run(
return false;
}
(void)fprintf(
/* CONTRACT: direction is a dedicated field because failure summaries may
* be neutral or mention a different phase than the selected operation. */
written = fprintf(
history_file,
"%s\t%s\t%d\t%.*s\n",
"%s\t%s\t%d\t%s\t",
report->sync_id,
local_timestamp,
report->success
? 1
: 0,
(int)TRAINLOG_SYNC_SUMMARY_MAX,
report->summary
direction_code
);
if (
written < 0 ||
!sync_history_write_summary(
history_file,
report->summary
) ||
fputc('\n', history_file) == EOF
) {
(void)fclose(history_file);
return false;
}
return
fclose(
history_file
) == 0;
}
static void sync_build_summary(
void trainlog_sync_build_summary(
TrainlogSyncReport *report
)
{
@ -2105,16 +2432,24 @@ static void sync_build_summary(
}
if (report->success) {
(void)snprintf(
report->summary,
sizeof(report->summary),
"Android→PC +%zu séance(s), +%zu exercice(s), +%zu mesure(s) · PC→Android catalogue %zu exercice(s)",
if (report->direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
(void)snprintf(report->summary, sizeof(report->summary),
"Android→PC +%zu séance(s), +%zu exercice(s), +%zu mesure(s), +%zu équipement(s)",
report->sessions_imported,
report->exercises_imported +
report->exercises_reconciled,
report->body_imported,
report->catalog_published
);
report->exercises_imported,
report->body_imported, report->equipment_definitions_imported);
} else if (report->direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
(void)snprintf(report->summary, sizeof(report->summary),
"PC→Android catalogue %zu exercice(s), définitions/séances/mesures publiées",
report->catalog_published);
} else {
(void)snprintf(report->summary, sizeof(report->summary),
"PC↔Android import +%zu séance(s), +%zu exercice(s), +%zu mesure(s), +%zu équipement(s) · catalogue %zu publié(s)",
report->sessions_imported,
report->exercises_imported,
report->body_imported, report->equipment_definitions_imported,
report->catalog_published);
}
} else {
(void)snprintf(
report->summary,
@ -2209,6 +2544,7 @@ static void sync_parse_import_report(
TrainlogStatus trainlog_sync_run(
TrainlogSyncTrigger trigger,
bool require_request,
TrainlogSyncDirection direction,
TrainlogSyncReport *output
)
{
@ -2236,8 +2572,11 @@ TrainlogStatus trainlog_sync_run(
bool silence_active = false;
bool run_started = false;
bool receipt_published = false;
bool mobile_export_is_v2 = false;
if (output == NULL) {
if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC ||
direction > TRAINLOG_SYNC_BIDIRECTIONAL ||
(require_request && direction != TRAINLOG_SYNC_BIDIRECTIONAL)) {
return
TRAINLOG_STATUS_INVALID_ARGUMENT;
}
@ -2247,6 +2586,7 @@ TrainlogStatus trainlog_sync_run(
0,
sizeof(*output)
);
output->direction = direction;
lock_fd =
sync_lock_open(
@ -2448,14 +2788,54 @@ TrainlogStatus trainlog_sync_run(
goto finalize;
}
status =
sync_receive_named(
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
goto outbound;
}
/* Definitions must reconcile before either v2 reference artifact. A
* missing file is accepted only for historic snapshots with no custom ID. */
status = sync_receive_current_android_artifact(
&device,
folder_id,
MOBILE_EQUIPMENT_DEFINITIONS_NAME,
MOBILE_EQUIPMENT_DEFINITIONS_LOCAL,
&ignored_size
);
if (status == TRAINLOG_STATUS_OK) {
status = sync_run_python_tool("import_equipment_definitions.py",
MOBILE_EQUIPMENT_DEFINITIONS_LOCAL,
database_path,
EQUIPMENT_DEFINITIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EQUIPMENT_DEFINITIONS_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
(void)snprintf(output->error, sizeof(output->error),
"Android→PC : définitions équipement : %s",
useful[0] != '\0' ? useful : "import échoué");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
output->equipment_definitions_imported =
sync_report_value(tool_output, "definitions_imported");
output->equipment_definitions_skipped =
sync_report_value(tool_output, "definitions_skipped");
} else if (status != TRAINLOG_STATUS_NOT_FOUND) {
(void)snprintf(output->error, sizeof(output->error),
"Android→PC : lecture définitions équipement échouée.");
final_status = status;
goto finalize;
}
status = sync_receive_current_android_artifact(
&device,
folder_id,
MOBILE_EXPORT_NAME,
MOBILE_EXPORT_LOCAL,
&ignored_size
);
mobile_export_is_v2 = status == TRAINLOG_STATUS_OK;
if (
status !=
@ -2525,10 +2905,20 @@ TrainlogStatus trainlog_sync_run(
output
);
/* V1 exports carry no equipment signal. A missing companion therefore
* preserves existing desktop associations rather than clearing them. */
status = sync_receive_named(&device, folder_id, EQUIPMENT_ASSOCIATIONS_NAME,
EQUIPMENT_ASSOCIATIONS_LOCAL, &ignored_size);
/* V1 exports carry no equipment signal. A V2 companion beside a historic
* V1 snapshot belongs to another generation and must not be applied. Its
* absence therefore preserves existing associations rather than clearing
* them. Scoped storage may suffix every V2 Android publication, so the V2
* path selects its newest candidate instead of an older canonical object. */
status = mobile_export_is_v2
? sync_receive_current_android_artifact(
&device,
folder_id,
EQUIPMENT_ASSOCIATIONS_NAME,
EQUIPMENT_ASSOCIATIONS_LOCAL,
&ignored_size
)
: TRAINLOG_STATUS_NOT_FOUND;
if (status == TRAINLOG_STATUS_OK) {
char useful[
TRAINLOG_SYNC_ERROR_MAX + 1U
@ -2557,6 +2947,35 @@ TrainlogStatus trainlog_sync_run(
goto finalize;
}
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
output->success = true;
final_status = TRAINLOG_STATUS_OK;
goto finalize;
}
outbound:
status = sync_run_python_tool("export_equipment_definitions.py",
PC_EQUIPMENT_DEFINITIONS_LOCAL,
database_path,
EQUIPMENT_DEFINITIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EQUIPMENT_DEFINITIONS_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : export définitions équipement échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id,
PC_EQUIPMENT_DEFINITIONS_LOCAL,
PC_EQUIPMENT_DEFINITIONS_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication définitions équipement échouée.");
final_status = status;
goto finalize;
}
status =
sync_run_python_tool(
"export_pc_catalog.py",
@ -2605,6 +3024,14 @@ TrainlogStatus trainlog_sync_run(
"exercises"
);
status = sync_publish_named(&device, folder_id, PC_CATALOG_LOCAL, PC_CATALOG_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication MTP du catalogue échouée.");
final_status = status;
goto finalize;
}
status = sync_run_python_tool("export_pc_mobile.py", PC_MOBILE_EXPORT_LOCAL,
database_path,
PC_CATALOG_RESULT, tool_output, sizeof(tool_output));
@ -2620,29 +3047,6 @@ TrainlogStatus trainlog_sync_run(
goto finalize;
}
status =
sync_publish_named(
&device,
folder_id,
PC_CATALOG_LOCAL,
PC_CATALOG_NAME
);
if (
status !=
TRAINLOG_STATUS_OK
) {
(void)snprintf(
output->error,
sizeof(output->error),
"%s",
"PC→Android : publication MTP du catalogue échouée."
);
final_status = status;
goto finalize;
}
status = sync_run_python_tool("export_equipment_associations.py",
EQUIPMENT_ASSOCIATIONS_LOCAL,
database_path,
@ -2670,7 +3074,7 @@ TrainlogStatus trainlog_sync_run(
TRAINLOG_STATUS_OK;
finalize:
sync_build_summary(
trainlog_sync_build_summary(
output
);
@ -2711,13 +3115,13 @@ finalize:
final_status =
TRAINLOG_STATUS_SYSTEM_ERROR;
sync_build_summary(
trainlog_sync_build_summary(
output
);
}
}
(void)sync_record_run(
(void)trainlog_sync_record_local_run(
trigger,
output
);

181
tui/src/sync_history.c Normal file
View file

@ -0,0 +1,181 @@
/**
* @file sync_history.c
* @brief Strict codec for local synchronization history lines.
*/
#include "trainlog/sync_history.h"
#include <ctype.h>
#include <stdio.h>
#include <string.h>
#define SYNC_HISTORY_LINE_MAX 767U
static bool sync_history_timestamp_valid(const char *timestamp)
{
size_t index;
if (timestamp == NULL || strlen(timestamp) != 16U) {
return false;
}
for (index = 0U; index < 16U; ++index) {
char expected = '\0';
if (index == 2U || index == 5U) {
expected = '/';
} else if (index == 10U) {
expected = ' ';
} else if (index == 13U) {
expected = ':';
}
if (
(expected != '\0' && timestamp[index] != expected) ||
(expected == '\0' && !isdigit((unsigned char)timestamp[index]))
) {
return false;
}
}
return true;
}
const char *trainlog_sync_history_direction_code(
TrainlogSyncDirection direction
)
{
switch (direction) {
case TRAINLOG_SYNC_ANDROID_TO_PC:
return "a";
case TRAINLOG_SYNC_PC_TO_ANDROID:
return "p";
case TRAINLOG_SYNC_BIDIRECTIONAL:
return "b";
default:
return NULL;
}
}
static bool sync_history_direction_parse(
const char *text,
TrainlogSyncDirection *output
)
{
if (text == NULL || output == NULL || text[1] != '\0') {
return false;
}
if (text[0] == 'a') {
*output = TRAINLOG_SYNC_ANDROID_TO_PC;
} else if (text[0] == 'p') {
*output = TRAINLOG_SYNC_PC_TO_ANDROID;
} else if (text[0] == 'b') {
*output = TRAINLOG_SYNC_BIDIRECTIONAL;
} else {
return false;
}
return true;
}
const char *trainlog_sync_history_direction_label(
const TrainlogSyncHistoryEntry *entry
)
{
if (entry == NULL || !entry->direction_known) {
/* Legacy records predate the direction field; summary text is not a
* trustworthy substitute, especially for failure diagnostics. */
return "direction inconnue";
}
switch (entry->direction) {
case TRAINLOG_SYNC_ANDROID_TO_PC:
return "Android→PC";
case TRAINLOG_SYNC_PC_TO_ANDROID:
return "PC→Android";
case TRAINLOG_SYNC_BIDIRECTIONAL:
return "PC↔Android";
default:
return "direction inconnue";
}
}
bool trainlog_sync_history_parse_line(
const char *line,
TrainlogSyncHistoryEntry *output
)
{
char copy[SYNC_HISTORY_LINE_MAX + 1U];
char *fields[5];
char *cursor;
size_t field_count = 1U;
size_t length;
bool has_sync_id;
bool has_direction;
const char *sync_id;
const char *timestamp;
const char *status;
const char *summary;
if (line == NULL || output == NULL) {
return false;
}
length = strlen(line);
while (length > 0U && (line[length - 1U] == '\n' || line[length - 1U] == '\r')) {
--length;
}
if (length == 0U || length > SYNC_HISTORY_LINE_MAX) {
return false;
}
(void)memcpy(copy, line, length);
copy[length] = '\0';
fields[0] = copy;
for (cursor = copy; *cursor != '\0'; ++cursor) {
if (*cursor == '\t') {
if (field_count == sizeof(fields) / sizeof(fields[0])) {
return false;
}
*cursor = '\0';
fields[field_count] = cursor + 1;
++field_count;
}
}
has_sync_id = field_count >= 4U;
has_direction = field_count == 5U;
if (field_count != 3U && field_count != 4U && field_count != 5U) {
return false;
}
sync_id = has_sync_id ? fields[0] : "";
timestamp = has_sync_id ? fields[1] : fields[0];
status = has_sync_id ? fields[2] : fields[1];
summary = fields[field_count - 1U];
if (
(has_sync_id && (sync_id[0] == '\0' || strlen(sync_id) > TRAINLOG_ID_MAX)) ||
!sync_history_timestamp_valid(timestamp) ||
(strcmp(status, "0") != 0 && strcmp(status, "1") != 0) ||
summary[0] == '\0' || strlen(summary) > TRAINLOG_SYNC_SUMMARY_MAX
) {
return false;
}
(void)memset(output, 0, sizeof(*output));
(void)snprintf(output->sync_id, sizeof(output->sync_id), "%s", sync_id);
(void)snprintf(output->timestamp, sizeof(output->timestamp), "%s", timestamp);
output->success = strcmp(status, "1") == 0;
(void)snprintf(output->summary, sizeof(output->summary), "%s", summary);
if (has_direction) {
if (!sync_history_direction_parse(fields[3], &output->direction)) {
return false;
}
output->direction_known = true;
}
return true;
}

View file

@ -0,0 +1,167 @@
/**
* @file sync_screen_action.c
* @brief Backend-independent sync-screen keyboard action dispatcher.
*/
#include "trainlog/sync_screen_action.h"
#include "trainlog/terminal.h"
static TrainlogSyncScreenAction sync_screen_action(
TrainlogSyncScreenEffect effect,
TrainlogSyncDirection direction
)
{
TrainlogSyncScreenAction action;
action.effect = effect;
action.direction = direction;
return action;
}
void trainlog_sync_screen_state_init(
TrainlogSyncScreenState *state
)
{
if (state == NULL) {
return;
}
state->confirming = false;
state->direction = TRAINLOG_SYNC_BIDIRECTIONAL;
}
TrainlogSyncScreenAction trainlog_sync_screen_dispatch(
TrainlogSyncScreenState *state,
int key
)
{
TrainlogSyncDirection direction;
if (state == NULL) {
return sync_screen_action(
TRAINLOG_SYNC_SCREEN_NONE,
TRAINLOG_SYNC_BIDIRECTIONAL
);
}
direction = state->direction;
if (key == 'a' || key == 'A') {
direction = TRAINLOG_SYNC_ANDROID_TO_PC;
} else if (key == 'p' || key == 'P') {
direction = TRAINLOG_SYNC_PC_TO_ANDROID;
} else if (key == 'b' || key == 'B') {
direction = TRAINLOG_SYNC_BIDIRECTIONAL;
} else if (state->confirming) {
if (key == '\n' || key == TRAINLOG_KEY_ENTER) {
/* CONTRACT: consuming confirmation before execution guarantees
* that one Enter event can yield at most one sync-engine call. */
state->confirming = false;
return sync_screen_action(
TRAINLOG_SYNC_SCREEN_RUN,
state->direction
);
}
if (key == 27) {
state->confirming = false;
return sync_screen_action(
TRAINLOG_SYNC_SCREEN_CANCEL,
state->direction
);
}
return sync_screen_action(
TRAINLOG_SYNC_SCREEN_NONE,
state->direction
);
} else if (key == 'r' || key == 'R') {
return sync_screen_action(
TRAINLOG_SYNC_SCREEN_REFRESH,
state->direction
);
} else if (key == 27) {
return sync_screen_action(
TRAINLOG_SYNC_SCREEN_EXIT,
state->direction
);
} else {
/* The former s shortcut is deliberately not an action. */
return sync_screen_action(
TRAINLOG_SYNC_SCREEN_NONE,
state->direction
);
}
state->direction = direction;
state->confirming = true;
return sync_screen_action(
TRAINLOG_SYNC_SCREEN_CONFIRM,
direction
);
}
TrainlogStatus trainlog_sync_screen_execute(
const TrainlogSyncScreenAction *action,
TrainlogSyncScreenRunCallback callback,
void *context,
TrainlogSyncReport *report
)
{
if (
action == NULL ||
action->effect != TRAINLOG_SYNC_SCREEN_RUN ||
callback == NULL ||
report == NULL
) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
return callback(
action->direction,
report,
context
);
}
const char *trainlog_sync_screen_direction_label(
TrainlogSyncDirection direction
)
{
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
return "Android→PC";
}
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
return "PC→Android";
}
return "PC↔Android";
}
const char *trainlog_sync_screen_confirmation(
TrainlogSyncDirection direction
)
{
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
return "Importer Android vers le PC maintenant ?";
}
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
return "Publier le catalogue du PC vers Android maintenant ?";
}
return "Synchroniser Android et PC dans les deux sens maintenant ?";
}
const char *trainlog_sync_screen_footer(
int terminal_columns
)
{
if (terminal_columns >= 100) {
return "a Android→PC p PC→Android b PC↔Android r actualiser Échap retour";
}
return "a A→PC p PC→A b A↔PC r act. Échap retour";
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,121 @@
/**
* @file test_custom_equipment.c
* @brief Schema-v8 custom equipment persistence and occurrence tests.
*/
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sqlite3.h>
#include "trainlog/database.h"
#define CHECK(condition) do { if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
return false; } } while (0)
static const char *const V7_SQL =
"CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,recording_mode TEXT NOT NULL DEFAULT 'sets',data_fields INTEGER NOT NULL DEFAULT 0);"
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE,started_at TEXT NOT NULL,ended_at TEXT,session_type TEXT NOT NULL DEFAULT 'training',notes TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id),exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),recording_mode TEXT NOT NULL DEFAULT 'sets',data_fields INTEGER NOT NULL DEFAULT 0,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id),position INTEGER NOT NULL,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);"
"CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id),duration_seconds INTEGER NOT NULL,speed_kmh REAL,distance_km REAL);"
"CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT NOT NULL UNIQUE,observed_at TEXT NOT NULL,session_row_id INTEGER REFERENCES sessions(id),body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);"
"PRAGMA user_version=7;";
static bool test_custom_equipment_round_trip(void)
{
char path[] = "/tmp/trainlog-custom-equipment-XXXXXX";
TrainlogDatabase *database = NULL;
TrainlogCustomEquipment custom;
TrainlogCustomEquipment listed[2];
TrainlogResolvedEquipment resolved;
TrainlogResolvedEquipment used[2];
TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session;
TrainlogSetInput set;
TrainlogSessionSummary summary;
TrainlogPersistedExerciseDetail detail[2];
sqlite3 *raw = NULL;
size_t count = 0U;
int version = 0;
int fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, V7_SQL, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 8);
(void)memset(&custom, 0, sizeof(custom));
(void)snprintf(custom.equipment_id, sizeof(custom.equipment_id),
"%s", "eq_123e4567-e89b-42d3-a456-426614174000");
(void)snprintf(custom.display_name, sizeof(custom.display_name), "%s", "Poulie maison");
(void)snprintf(custom.label_name, sizeof(custom.label_name), "%s", "POULIE A");
(void)snprintf(custom.equipment_type, sizeof(custom.equipment_type), "%s", "cable_machine");
(void)snprintf(custom.load_semantics, sizeof(custom.load_semantics), "%s", "external");
CHECK(trainlog_database_create_custom_equipment(database, &custom) == TRAINLOG_STATUS_OK);
trainlog_database_close(database);
database = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_list_custom_equipment(database, listed, 2U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U);
CHECK(strcmp(listed[0].equipment_id, custom.equipment_id) == 0);
CHECK(trainlog_database_insert_exercise(database, "ex_custom_equipment",
"Tirage custom", "tirage custom", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
(void)memset(&set, 0, sizeof(set));
set.reps = 8;
set.has_weight = true;
set.weight_kg = 20.0;
(void)memset(&exercise, 0, sizeof(exercise));
(void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s", "ex_custom_equipment");
(void)snprintf(exercise.entry_id, sizeof(exercise.entry_id), "%s", "sxe_custom_equipment");
(void)snprintf(exercise.equipment_id, sizeof(exercise.equipment_id), "%s", custom.equipment_id);
exercise.load_mode = TRAINLOG_LOAD_EXTERNAL;
exercise.rest_seconds = 60;
exercise.target_sets = 1;
exercise.target_reps = 8;
exercise.target_has_weight = true;
exercise.target_weight_kg = 20.0;
exercise.sets = &set;
exercise.set_count = 1U;
(void)memset(&session, 0, sizeof(session));
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_custom_equipment");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", "2026-09-08T10:00:00+02:00");
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-08T10:30:00+02:00");
session.exercises = &exercise;
session.exercise_count = 1U;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
trainlog_database_close(database);
database = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_get_session_details(database, session.session_id,
&summary, detail, 2U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U);
CHECK(strcmp(detail[0].entry_id, exercise.entry_id) == 0);
CHECK(strcmp(detail[0].equipment_id, custom.equipment_id) == 0);
CHECK(trainlog_database_list_exercise_equipment(database, exercise.exercise_id,
used, 2U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U);
CHECK(used[0].origin == TRAINLOG_EQUIPMENT_CUSTOM);
CHECK(strcmp(used[0].display_name, custom.display_name) == 0);
CHECK(trainlog_database_resolve_equipment(database, "retired_machine", &resolved) == TRAINLOG_STATUS_OK);
CHECK(resolved.origin == TRAINLOG_EQUIPMENT_UNKNOWN);
CHECK(strstr(resolved.display_name, "retired_machine") != NULL);
trainlog_database_close(database);
CHECK(unlink(path) == 0);
return true;
}
int main(void)
{
CHECK(test_custom_equipment_round_trip());
(void)printf("PASS custom_equipment\n");
return 0;
}

View file

@ -75,6 +75,8 @@ static bool test_generated_ids(void)
char first[TRAINLOG_GENERATED_ID_CAPACITY];
char second[TRAINLOG_GENERATED_ID_CAPACITY];
char sync_id[TRAINLOG_GENERATED_ID_CAPACITY];
char entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
char v1_exact_capacity[2U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U];
CHECK(
trainlog_id_generate("ex", first, sizeof(first)) ==
@ -85,9 +87,18 @@ static bool test_generated_ids(void)
TRAINLOG_STATUS_OK
);
CHECK(strncmp(first, "ex_", 3U) == 0);
CHECK(strlen(first) == TRAINLOG_GENERATED_ID_CAPACITY - 1U);
CHECK(strlen(first) == 2U + 1U + TRAINLOG_UUID_TEXT_LENGTH);
CHECK(strcmp(first, second) != 0);
CHECK(first[3U + 14U] == '4');
/* ABI behavior: the new longer occurrence prefix must not invalidate the
* exact buffer size historically sufficient for two-character prefixes. */
CHECK(
trainlog_id_generate(
"se",
v1_exact_capacity,
sizeof(v1_exact_capacity)
) == TRAINLOG_STATUS_OK
);
/* TRAINLOG_SYNC_ID_PREFIX_TEST */
CHECK(
@ -111,6 +122,17 @@ static bool test_generated_ids(void)
'4'
);
CHECK(
trainlog_id_generate(
"sxe",
entry_id,
sizeof(entry_id)
) == TRAINLOG_STATUS_OK
);
CHECK(strncmp(entry_id, "sxe_", 4U) == 0);
CHECK(strlen(entry_id) == TRAINLOG_GENERATED_ID_CAPACITY - 1U);
CHECK(entry_id[4U + 14U] == '4');
return true;
}
@ -131,6 +153,8 @@ static bool test_session_insert(void)
TrainlogSetInput sets[2];
TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session;
TrainlogSessionSummary persisted_session;
TrainlogPersistedExerciseDetail persisted_exercises[1];
size_t count = 0U;
CHECK(
@ -196,6 +220,19 @@ static bool test_session_insert(void)
TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
CHECK(
trainlog_database_get_session_details(
database,
"se_test",
&persisted_session,
persisted_exercises,
1U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
/* Regression: local occurrences are `sxe`, never synchronization runs. */
CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0);
trainlog_database_close(database);
return true;

View file

@ -1,6 +1,6 @@
/**
* @file test_schema_v5_migration.c
* @brief Direct v4 -> v7 migration regression test.
* @brief Direct v4 -> current migration regression test.
*/
#include <stdbool.h>
@ -27,10 +27,10 @@
} \
} while (0)
static bool test_v4_to_v7_preserves_session(void)
static bool test_v4_to_current_preserves_session(void)
{
char path[] =
"/tmp/trainlog-schema-v4-v5-XXXXXX";
"/tmp/trainlog-schema-v4-current-XXXXXX";
static const char *const V4_SQL =
"CREATE TABLE exercises ("
@ -187,9 +187,7 @@ static bool test_v4_to_v7_preserves_session(void)
TRAINLOG_DATABASE_SCHEMA_VERSION
);
CHECK(
version == 7
);
CHECK(version == 8);
CHECK(
trainlog_database_get_session_details(
@ -228,7 +226,7 @@ static bool test_v4_to_v7_preserves_session(void)
int main(void)
{
CHECK(
test_v4_to_v7_preserves_session()
test_v4_to_current_preserves_session()
);
(void)printf(

View file

@ -0,0 +1,198 @@
/**
* @file test_schema_v7_migration.c
* @brief Synthetic v7 -> v8 migration and reopen regression coverage.
*/
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sqlite3.h>
#include "trainlog/database.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #condition); \
return false; \
} \
} while (0)
static const char *const V7_FIXTURE_SQL =
"PRAGMA foreign_keys=ON;"
"CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE,started_at TEXT NOT NULL,ended_at TEXT,session_type TEXT NOT NULL,notes TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id),exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id),position INTEGER NOT NULL,reps INTEGER,duration_seconds INTEGER,weight_kg REAL,UNIQUE(session_exercise_row_id,position));"
"CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id),duration_seconds INTEGER NOT NULL,speed_kmh REAL,distance_km REAL);"
"CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT NOT NULL UNIQUE,observed_at TEXT NOT NULL,session_row_id INTEGER UNIQUE REFERENCES sessions(id),body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);"
"INSERT INTO exercises VALUES(11,'ex_fixture_sets','Squat','squat','reps','sets',0);"
"INSERT INTO exercises VALUES(12,'ex_fixture_run','Run','run','duration','continuous',3);"
"INSERT INTO sessions VALUES(21,'se_fixture','2026-09-08T06:00:00Z','2026-09-08T07:00:00Z','training','fixture session');"
"INSERT INTO session_exercises VALUES(31,'sxe_fixture_sets',21,11,'sets',0,0,'external',90,2,5,NULL,82.5,'eq_fixture_rack','sets note');"
"INSERT INTO session_exercises VALUES(32,'sxe_fixture_run',21,12,'continuous',3,1,'none',0,NULL,NULL,NULL,NULL,NULL,'run note');"
"INSERT INTO performed_sets VALUES(41,31,0,5,NULL,80.25);"
"INSERT INTO performed_sets VALUES(42,31,1,4,NULL,82.5);"
"INSERT INTO continuous_activity VALUES(51,32,1234,10.75,3.67);"
"INSERT INTO body_observations(id,observation_id,observed_at,session_row_id,body_weight_kg,waist_cm,notes) VALUES(61,'bo_fixture','2026-09-08T07:05:00Z',21,74.25,81.5,'body note');"
"PRAGMA user_version=7;";
static bool verify_preserved_values(const char *path)
{
sqlite3 *connection = NULL;
sqlite3_stmt *statement = NULL;
CHECK(sqlite3_open_v2(path, &connection, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(connection,
"SELECT s.session_id,se.entry_id,se.position,se.equipment_id,ps.position,ps.reps,ps.weight_kg "
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id "
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id ORDER BY ps.position;",
-1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 0), "se_fixture") == 0);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 1), "sxe_fixture_sets") == 0);
CHECK(sqlite3_column_int(statement, 2) == 0);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 3), "eq_fixture_rack") == 0);
CHECK(sqlite3_column_int(statement, 4) == 0);
CHECK(sqlite3_column_int(statement, 5) == 5);
CHECK(sqlite3_column_double(statement, 6) == 80.25);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 4) == 1);
CHECK(sqlite3_column_int(statement, 5) == 4);
CHECK(sqlite3_column_double(statement, 6) == 82.5);
CHECK(sqlite3_step(statement) == SQLITE_DONE);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(connection,
"SELECT se.entry_id,se.position,ca.duration_seconds,ca.speed_kmh,ca.distance_km,bo.observation_id,bo.body_weight_kg,bo.waist_cm,bo.notes "
"FROM session_exercises se JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id "
"JOIN sessions s ON s.id=se.session_row_id JOIN body_observations bo ON bo.session_row_id=s.id;",
-1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 0), "sxe_fixture_run") == 0);
CHECK(sqlite3_column_int(statement, 1) == 1);
CHECK(sqlite3_column_int(statement, 2) == 1234);
CHECK(sqlite3_column_double(statement, 3) == 10.75);
CHECK(sqlite3_column_double(statement, 4) == 3.67);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 5), "bo_fixture") == 0);
CHECK(sqlite3_column_double(statement, 6) == 74.25);
CHECK(sqlite3_column_double(statement, 7) == 81.5);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 8), "body note") == 0);
CHECK(sqlite3_step(statement) == SQLITE_DONE);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_close(connection) == SQLITE_OK);
return true;
}
static bool test_v7_migrates_and_v8_reopens(void)
{
char path[] = "/tmp/trainlog-schema-v7-XXXXXX";
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
int version = 0;
int fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, V7_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 8);
trainlog_database_close(database);
CHECK(verify_preserved_values(path));
database = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
trainlog_database_close(database);
CHECK(verify_preserved_values(path));
CHECK(unlink(path) == 0);
return true;
}
static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
{
char path[] = "/tmp/trainlog-schema-v7-failure-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
int fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, V7_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK);
/* Synthetic collision: only the v8 migration target is pre-created. */
CHECK(sqlite3_exec(raw, "CREATE TABLE custom_equipment (equipment_id TEXT PRIMARY KEY);",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema v8: SQLite rc=",
strlen("migrate database to schema v8: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
CHECK(unlink(path) == 0);
return true;
}
static bool test_newer_schema_has_application_diagnostic(void)
{
char path[] = "/tmp/trainlog-schema-v9-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
int fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=9;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 9 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;
}
static bool test_unrecognized_historic_schema_has_application_diagnostic(void)
{
char path[] = "/tmp/trainlog-schema-invalid-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
int fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=-1;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version -1 is unsupported") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;
}
int main(void)
{
CHECK(test_v7_migrates_and_v8_reopens());
CHECK(test_v7_migration_sqlite_failure_has_diagnostic());
CHECK(test_newer_schema_has_application_diagnostic());
CHECK(test_unrecognized_historic_schema_has_application_diagnostic());
(void)printf("PASS schema_v7_migration\n");
return 0;
}

View file

@ -0,0 +1,97 @@
#include <stdio.h>
#include <string.h>
#include "trainlog/sync.h"
#define CHECK(value) do { if (!(value)) { fprintf(stderr, "FAIL line %d\n", __LINE__); return 1; } } while (0)
int main(void)
{
TrainlogSyncDirectionPlan plan;
TrainlogSyncReport report = {0};
TrainlogMtpEntry entries[5] = {0};
size_t selected = 0U;
plan = trainlog_sync_direction_plan(TRAINLOG_SYNC_ANDROID_TO_PC);
CHECK(plan.receive_android && !plan.publish_android);
plan = trainlog_sync_direction_plan(TRAINLOG_SYNC_PC_TO_ANDROID);
CHECK(!plan.receive_android && plan.publish_android);
plan = trainlog_sync_direction_plan(TRAINLOG_SYNC_BIDIRECTIONAL);
CHECK(plan.receive_android && plan.publish_android);
(void)snprintf(entries[0].name, sizeof(entries[0].name), "%s",
"trainlog-mobile-export-v2.json");
entries[0].item_id = 20U;
entries[0].modification_unix_seconds = 100U;
(void)snprintf(entries[1].name, sizeof(entries[1].name), "%s",
"trainlog-mobile-export-v2 (25).json");
entries[1].item_id = 30U;
entries[1].modification_unix_seconds = 200U;
(void)snprintf(entries[2].name, sizeof(entries[2].name), "%s",
"trainlog-mobile-export-v2 (26).json");
entries[2].item_id = 10U;
entries[2].modification_unix_seconds = 200U;
(void)snprintf(entries[3].name, sizeof(entries[3].name), "%s",
"trainlog-mobile-export-v2 (oops).json");
entries[3].item_id = 1U;
entries[3].modification_unix_seconds = 999U;
(void)snprintf(entries[4].name, sizeof(entries[4].name), "%s",
"trainlog-mobile-export-v1.json");
entries[4].item_id = 2U;
entries[4].modification_unix_seconds = 999U;
CHECK(trainlog_sync_select_mobile_export(entries, 5U, &selected));
CHECK(selected == 2U);
entries[0].modification_unix_seconds = 0U;
entries[1].modification_unix_seconds = 0U;
entries[2].modification_unix_seconds = 0U;
CHECK(trainlog_sync_select_mobile_export(entries, 5U, &selected));
CHECK(selected == 2U);
CHECK(!trainlog_sync_select_mobile_export(NULL, 0U, &selected));
(void)snprintf(entries[0].name, sizeof(entries[0].name), "%s",
"trainlog-mobile-equipment-definitions-v1.json");
entries[0].item_id = 40U;
entries[0].modification_unix_seconds = 10U;
(void)snprintf(entries[1].name, sizeof(entries[1].name), "%s",
"trainlog-mobile-equipment-definitions-v1 (4).json");
entries[1].item_id = 41U;
entries[1].modification_unix_seconds = 20U;
CHECK(trainlog_sync_select_android_artifact(
entries,
2U,
"trainlog-mobile-equipment-definitions-v1.json",
&selected));
CHECK(selected == 1U);
CHECK(!trainlog_sync_select_android_artifact(entries, 2U,
"not-json", &selected));
(void)snprintf(entries[0].name, sizeof(entries[0].name), "%s",
"trainlog-equipment-associations-v1.json");
entries[0].item_id = 50U;
entries[0].modification_unix_seconds = 10U;
(void)snprintf(entries[1].name, sizeof(entries[1].name), "%s",
"trainlog-equipment-associations-v1 (31).json");
entries[1].item_id = 51U;
entries[1].modification_unix_seconds = 30U;
CHECK(trainlog_sync_select_android_artifact(
entries,
2U,
"trainlog-equipment-associations-v1.json",
&selected));
CHECK(selected == 1U);
report.success = true;
report.direction = TRAINLOG_SYNC_BIDIRECTIONAL;
report.exercises_reconciled = 1U;
report.exercises_skipped = 8U;
report.sessions_skipped = 1U;
report.catalog_published = 15U;
trainlog_sync_build_summary(&report);
CHECK(strstr(report.summary, "+0 exercice(s)") != NULL);
CHECK(strstr(report.summary, "+1 exercice(s)") == NULL);
report.exercises_imported = 1U;
trainlog_sync_build_summary(&report);
CHECK(strstr(report.summary, "+1 exercice(s)") != NULL);
puts("PASS sync direction plan");
return 0;
}

View file

@ -0,0 +1,172 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "trainlog/sync_history.h"
#define CHECK(value) do { if (!(value)) { fprintf(stderr, "FAIL line %d\n", __LINE__); return 1; } } while (0)
static bool read_text(
const char *path,
char *output,
size_t capacity
)
{
FILE *file;
size_t used;
file = fopen(path, "rb");
if (file == NULL) {
return false;
}
used = fread(output, 1U, capacity - 1U, file);
output[used] = '\0';
return fclose(file) == 0;
}
static void initialize_failure(
TrainlogSyncReport *report,
const char *sync_id,
TrainlogSyncDirection direction,
const char *error
)
{
(void)memset(report, 0, sizeof(*report));
report->direction = direction;
(void)snprintf(report->sync_id, sizeof(report->sync_id), "%s", sync_id);
(void)snprintf(
report->started_at,
sizeof(report->started_at),
"%s",
"2026-01-02T03:04:05+00:00"
);
(void)snprintf(report->error, sizeof(report->error), "%s", error);
(void)snprintf(report->summary, sizeof(report->summary), "%s", error);
}
int main(void)
{
char temporary[] = "/tmp/trainlog-sync-history-XXXXXX";
char history_path[512];
char run_path[512];
char text[4096];
char line[768];
const TrainlogSyncDirection directions[] = {
TRAINLOG_SYNC_ANDROID_TO_PC,
TRAINLOG_SYNC_PC_TO_ANDROID,
TRAINLOG_SYNC_BIDIRECTIONAL
};
const char *const ids[] = {
"sy_00000000-0000-4000-8000-000000000001",
"sy_00000000-0000-4000-8000-000000000002",
"sy_00000000-0000-4000-8000-000000000003"
};
const char *const errors[] = {
"Échec neutre sans indication de sens.",
"Échec neutre sans indication de sens.",
"Android→PC indisponible; PC→Android non tenté."
};
const char *const json_directions[] = {
"\"direction\":\"android_to_pc\"",
"\"direction\":\"pc_to_android\"",
"\"direction\":\"bidirectional\""
};
const char *const detail_directions[] = {
"Direction : Android→PC",
"Direction : PC→Android",
"Direction : PC↔Android"
};
FILE *history;
TrainlogSyncHistoryEntry entry;
TrainlogSyncReport report;
size_t index;
CHECK(trainlog_sync_history_parse_line(
"sy_legacy\t02/01/2026 03:04\t0\tPC→Android visible seulement dans le résumé\n",
&entry
));
CHECK(!entry.direction_known);
CHECK(strcmp(trainlog_sync_history_direction_label(&entry), "direction inconnue") == 0);
CHECK(trainlog_sync_history_parse_line(
"02/01/2026 03:04\t1\tAndroid→PC ancien\n",
&entry
));
CHECK(!entry.direction_known && entry.sync_id[0] == '\0');
CHECK(!trainlog_sync_history_parse_line(
"sy_bad\t02/01/2026 03:04\t0\tx\téchec\n",
&entry
));
CHECK(!trainlog_sync_history_parse_line(
"sy_bad\t02/01/2026 03:04\t0\ta\téchec\textra\n",
&entry
));
CHECK(mkdtemp(temporary) != NULL);
CHECK(setenv("XDG_DATA_HOME", temporary, 1) == 0);
for (index = 0U; index < 3U; ++index) {
initialize_failure(&report, ids[index], directions[index], errors[index]);
CHECK(trainlog_sync_record_local_run(
TRAINLOG_SYNC_TRIGGER_TUI,
&report
));
CHECK(snprintf(
run_path,
sizeof(run_path),
"%s/trainlog/sync_runs/%s.json",
temporary,
ids[index]
) > 0);
CHECK(read_text(run_path, text, sizeof(text)));
CHECK(strstr(text, json_directions[index]) != NULL);
CHECK(strstr(text, errors[index]) != NULL);
CHECK(snprintf(
run_path,
sizeof(run_path),
"%s/trainlog/sync_runs/%s.txt",
temporary,
ids[index]
) > 0);
CHECK(read_text(run_path, text, sizeof(text)));
CHECK(strstr(text, detail_directions[index]) != NULL);
CHECK(strstr(text, errors[index]) != NULL);
}
CHECK(snprintf(
history_path,
sizeof(history_path),
"%s/trainlog/sync_history.log",
temporary
) > 0);
history = fopen(history_path, "rb");
CHECK(history != NULL);
for (index = 0U; index < 3U; ++index) {
CHECK(fgets(line, sizeof(line), history) != NULL);
CHECK(trainlog_sync_history_parse_line(line, &entry));
CHECK(entry.direction_known);
CHECK(entry.direction == directions[index]);
CHECK(strcmp(entry.summary, errors[index]) == 0);
}
CHECK(fgets(line, sizeof(line), history) == NULL);
CHECK(fclose(history) == 0);
for (index = 0U; index < 3U; ++index) {
(void)snprintf(run_path, sizeof(run_path), "%s/trainlog/sync_runs/%s.json", temporary, ids[index]);
CHECK(unlink(run_path) == 0);
(void)snprintf(run_path, sizeof(run_path), "%s/trainlog/sync_runs/%s.txt", temporary, ids[index]);
CHECK(unlink(run_path) == 0);
}
CHECK(unlink(history_path) == 0);
(void)snprintf(run_path, sizeof(run_path), "%s/trainlog/sync_runs", temporary);
CHECK(rmdir(run_path) == 0);
(void)snprintf(run_path, sizeof(run_path), "%s/trainlog", temporary);
CHECK(rmdir(run_path) == 0);
CHECK(rmdir(temporary) == 0);
puts("PASS sync history directions");
return 0;
}

View file

@ -0,0 +1,113 @@
#include <stdio.h>
#include <string.h>
#include "trainlog/sync_screen_action.h"
#define CHECK(value) do { \
if (!(value)) { \
fprintf(stderr, "FAIL line %d\n", __LINE__); \
return 1; \
} \
} while (0)
typedef struct RunRecorder {
size_t calls;
TrainlogSyncDirection direction;
} RunRecorder;
static TrainlogStatus record_run(
TrainlogSyncDirection direction,
TrainlogSyncReport *report,
void *context
)
{
RunRecorder *recorder = context;
++recorder->calls;
recorder->direction = direction;
report->success = true;
report->direction = direction;
return TRAINLOG_STATUS_OK;
}
static int check_direction_key(
int key,
TrainlogSyncDirection expected
)
{
TrainlogSyncScreenState state;
TrainlogSyncScreenAction action;
trainlog_sync_screen_state_init(&state);
action = trainlog_sync_screen_dispatch(&state, key);
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM);
CHECK(action.direction == expected);
CHECK(state.confirming);
CHECK(state.direction == expected);
return 0;
}
int main(void)
{
TrainlogSyncScreenState state;
TrainlogSyncScreenAction action;
TrainlogSyncReport report = {0};
RunRecorder recorder = {0};
CHECK(check_direction_key('a', TRAINLOG_SYNC_ANDROID_TO_PC) == 0);
CHECK(check_direction_key('p', TRAINLOG_SYNC_PC_TO_ANDROID) == 0);
CHECK(check_direction_key('b', TRAINLOG_SYNC_BIDIRECTIONAL) == 0);
trainlog_sync_screen_state_init(&state);
action = trainlog_sync_screen_dispatch(&state, 'a');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM);
action = trainlog_sync_screen_dispatch(&state, 27);
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CANCEL);
CHECK(!state.confirming);
CHECK(recorder.calls == 0U);
action = trainlog_sync_screen_dispatch(&state, 'p');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM);
action = trainlog_sync_screen_dispatch(&state, '\n');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_RUN);
CHECK(trainlog_sync_screen_execute(
&action, record_run, &recorder, &report
) == TRAINLOG_STATUS_OK);
CHECK(recorder.calls == 1U);
CHECK(recorder.direction == TRAINLOG_SYNC_PC_TO_ANDROID);
action = trainlog_sync_screen_dispatch(&state, '\n');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_NONE);
CHECK(recorder.calls == 1U);
action = trainlog_sync_screen_dispatch(&state, 'r');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_REFRESH);
CHECK(recorder.calls == 1U);
action = trainlog_sync_screen_dispatch(&state, 's');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_NONE);
CHECK(recorder.calls == 1U);
CHECK(strcmp(
trainlog_sync_screen_confirmation(TRAINLOG_SYNC_ANDROID_TO_PC),
"Importer Android vers le PC maintenant ?"
) == 0);
CHECK(strcmp(
trainlog_sync_screen_confirmation(TRAINLOG_SYNC_PC_TO_ANDROID),
"Publier le catalogue du PC vers Android maintenant ?"
) == 0);
CHECK(strcmp(
trainlog_sync_screen_confirmation(TRAINLOG_SYNC_BIDIRECTIONAL),
"Synchroniser Android et PC dans les deux sens maintenant ?"
) == 0);
CHECK(strstr(trainlog_sync_screen_footer(120), "a Android→PC") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(120), "p PC→Android") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(120), "b PC↔Android") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(80), "a A→PC") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(80), "p PC→A") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(80), "b A↔PC") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(120), "s synchroniser") == NULL);
CHECK(strstr(trainlog_sync_screen_footer(80), "mode") == NULL);
puts("PASS sync screen action dispatcher");
return 0;
}

View file

@ -128,6 +128,7 @@ int main(
trainlog_sync_run(
trigger,
request_only,
TRAINLOG_SYNC_BIDIRECTIONAL,
&report
);