feat(sync): stabilize bidirectional sync and equipment reconciliation
This commit is contained in:
parent
7093dd5606
commit
8066b39ce3
56 changed files with 7355 additions and 740 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -24,6 +24,7 @@ local.properties
|
||||||
# Local user data
|
# Local user data
|
||||||
/data/
|
/data/
|
||||||
exports/
|
exports/
|
||||||
|
.local-backups/
|
||||||
|
|
||||||
# Python helpers
|
# Python helpers
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
|
||||||
71
CHANGELOG.md
71
CHANGELOG.md
|
|
@ -9,6 +9,11 @@ Detailed implementation chronology remains available in Git history and
|
||||||
|
|
||||||
### Added
|
### 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
|
- multi-occurrence session V2: stable per-occurrence `entry_id`, repeated
|
||||||
catalogue exercises in one session, per-set actual weights, and occurrence
|
catalogue exercises in one session, per-set actual weights, and occurrence
|
||||||
equipment associations across Android, desktop, import and export;
|
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
|
Android-local custom equipment creation, and explicit rejection of unknown
|
||||||
equipment identities rather than silent association loss;
|
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,
|
- Android `EXERCISE_EDIT_V1`: visible catalog editing, stable-ID name rename,
|
||||||
explicit invalid/conflict/profile/database results, and profile locking once
|
explicit invalid/conflict/profile/database results, and profile locking once
|
||||||
completed history or an active draft references the exercise;
|
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;
|
- native Kotlin/Compose Android capture client;
|
||||||
- Android local exercise, session, continuous-activity, and body persistence;
|
- 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
|
- profile-aware exercise model using recording mode, tracking mode, and
|
||||||
supplemental fields;
|
supplemental fields;
|
||||||
- continuous activity persistence without synthetic sets;
|
- continuous activity persistence without synthetic sets;
|
||||||
|
|
@ -52,6 +74,12 @@ Detailed implementation chronology remains available in Git history and
|
||||||
|
|
||||||
### Changed
|
### 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
|
- desktop SQLite schema v7 and Android SQLite schema v7 preserve historic rows
|
||||||
while adding durable occurrence identities and occurrence-level equipment;
|
while adding durable occurrence identities and occurrence-level equipment;
|
||||||
- the active Android↔PC completed-session exchange is V2; frozen V1 artifacts
|
- 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
|
- synchronization invokes each local helper with the explicit XDG-resolved
|
||||||
desktop database path and records the concrete equipment-import failure;
|
desktop database path and records the concrete equipment-import failure;
|
||||||
|
|
||||||
- same-ID Android ↔ PC catalog reconciliation now updates display-name metadata
|
- same-ID Android ↔ PC catalog reconciliation updates display-name metadata in
|
||||||
in place and rejects a different-ID normalized-name collision, preserving
|
place. Compatible different-ID normalized-name collisions now merge through
|
||||||
synchronization identity and preventing renamed duplicates;
|
the explicit V2 policy; incompatible collisions still reject atomically;
|
||||||
|
|
||||||
- Android local SQLite v3 -> v4 additive migration for structured active drafts;
|
- Android local SQLite v3 -> v4 additive migration for structured active drafts;
|
||||||
- completed-session insertion and draft clearing are atomic; drafts remain
|
- completed-session insertion and draft clearing are atomic; drafts remain
|
||||||
|
|
@ -83,11 +111,24 @@ Detailed implementation chronology remains available in Git history and
|
||||||
|
|
||||||
### Fixed
|
### 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`
|
- equipment companion import previously omitted its required `--database`
|
||||||
argument and blocked synchronization after a successful session import;
|
argument and blocked synchronization after a successful session import;
|
||||||
- PC catalogue/mobile export paths now accept schema v7 and preserve catalogue
|
- PC catalogue/mobile export paths now accept schema v7 and preserve catalogue
|
||||||
tracking metadata; Android completed-session equipment editing now targets
|
tracking metadata; Android completed-session equipment editing now targets
|
||||||
the stable occurrence `entry_id`, not an ambiguous catalogue exercise ID;
|
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
|
- in-progress Android workout loss when leaving the foreground or recreating
|
||||||
the Activity/process;
|
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`;
|
- synchronization failures that previously surfaced only as `error=unknown`;
|
||||||
- Android folder-selection UX so a wrong SAF folder can be changed without
|
- Android folder-selection UX so a wrong SAF folder can be changed without
|
||||||
clearing application data;
|
clearing application data;
|
||||||
- libmtp terminal output leaking into ncurses rendering.
|
- libmtp terminal output leaking into terminal rendering.
|
||||||
|
|
||||||
### Validation
|
### Validation
|
||||||
|
|
||||||
|
|
@ -109,21 +150,23 @@ Current validated baseline:
|
||||||
```text
|
```text
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V7=PASS
|
DESKTOP_SCHEMA_V8=PASS
|
||||||
DESKTOP_TESTS=25/25 PASS
|
DESKTOP_TESTS=32/32 PASS
|
||||||
|
|
||||||
ANDROID_BUILD=PASS
|
ANDROID_BUILD=PASS
|
||||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V7=PASS
|
ANDROID_LOCAL_DATABASE_V8=PASS
|
||||||
ANDROID_HOST_TESTS=8/8 PASS
|
ANDROID_TEST_DEBUG_UNIT=PASS
|
||||||
ANDROID_DEVICE_INSTRUMENTATION=5/5 PASS
|
|
||||||
ANDROID_SESSION_DRAFT_V1=PASS
|
ANDROID_SESSION_DRAFT_V1=PASS
|
||||||
|
|
||||||
USB_MTP_DETECTION=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
|
ANDROID_TO_PC_MTP=HISTORICAL_PASS
|
||||||
PC_TO_ANDROID_MTP_PUBLISH=PASS
|
PC_TO_ANDROID_MTP_PUBLISH=HISTORICAL_PASS
|
||||||
COMMON_SYNC_ENGINE=PASS
|
COMMON_SYNC_ENGINE=PASS
|
||||||
TRAINLOG_SYNCD=PASS
|
TRAINLOG_SYNCD=PASS
|
||||||
ANDROID_TRIGGERED_SYNC=PASS
|
ANDROID_TRIGGERED_SYNC=PASS
|
||||||
|
|
@ -132,6 +175,8 @@ TUI_SYNC_LOG_SHOW=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
MULTI_OCCURRENCE_SESSION_V2=PASS
|
MULTI_OCCURRENCE_SESSION_V2=PASS
|
||||||
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
||||||
|
EQUIPMENT_DEFINITIONS_V1=PASS
|
||||||
|
EXERCISE_RECONCILIATION_V2=PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
### Measured max v1
|
### Measured max v1
|
||||||
|
|
|
||||||
25
README.md
25
README.md
|
|
@ -16,9 +16,9 @@ desktop.
|
||||||
```text
|
```text
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V7=PASS
|
DESKTOP_SCHEMA_V8=PASS
|
||||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V7=PASS
|
ANDROID_LOCAL_DATABASE_V8=PASS
|
||||||
ANDROID_SESSION_DRAFT_V1=PASS
|
ANDROID_SESSION_DRAFT_V1=PASS
|
||||||
EXERCISE_EDIT_V1=PASS
|
EXERCISE_EDIT_V1=PASS
|
||||||
ANDROID_BANNER_PARITY_V1=PASS
|
ANDROID_BANNER_PARITY_V1=PASS
|
||||||
|
|
@ -34,8 +34,10 @@ ANDROID_SYNC_RECEIPT=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
MULTI_OCCURRENCE_SESSION_V2=PASS
|
MULTI_OCCURRENCE_SESSION_V2=PASS
|
||||||
EQUIPMENT_ASSOCIATIONS_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
|
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
|
machine-displayed load; `assistance` records assistance and is not interpreted
|
||||||
as increasing strength.
|
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
|
## Repository layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -147,9 +157,10 @@ export or desktop synchronization as completed sessions.
|
||||||
Schema v4 migrates additively from v3, preserving existing capture data. See
|
Schema v4 migrates additively from v3, preserving existing capture data. See
|
||||||
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
||||||
|
|
||||||
The current Android schema is v7. Its additive v4 -> v7 chain adds the shared
|
The current Android schema is v8. Its additive v4 -> v8 chain adds the shared
|
||||||
equipment catalogue, per-occurrence equipment links and durable occurrence
|
equipment catalogue, per-occurrence equipment links, durable occurrence
|
||||||
identities without recreating completed history or the active draft.
|
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
|
Exercises can be renamed in place from Android. The `ex_<uuid-v4>` identity is
|
||||||
unchanged; completed history, an active draft, and synchronization therefore
|
unchanged; completed history, an active draft, and synchronization therefore
|
||||||
|
|
@ -272,5 +283,5 @@ BODY_ANALYTICS_V1=PASS
|
||||||
BODY_COMPOSITION_ESTIMATE=PASS
|
BODY_COMPOSITION_ESTIMATE=PASS
|
||||||
BODY_PROPORTION_RATIOS=PASS
|
BODY_PROPORTION_RATIOS=PASS
|
||||||
BODY_SYMMETRY_ANALYTICS=PASS
|
BODY_SYMMETRY_ANALYTICS=PASS
|
||||||
DESKTOP_TESTS=25/25 PASS
|
DESKTOP_TESTS=32/32 PASS
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,21 @@ package com.labfytools.trainlog.ui
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
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.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
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.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
|
import com.labfytools.trainlog.model.ExerciseProfile
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
import com.labfytools.trainlog.model.SessionDraftForm
|
import com.labfytools.trainlog.model.SessionDraftForm
|
||||||
|
|
@ -29,6 +35,10 @@ import org.junit.runner.RunWith
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class SessionDraftUiInstrumentedTest {
|
class SessionDraftUiInstrumentedTest {
|
||||||
|
private lateinit var marcheExerciseId: String
|
||||||
|
private lateinit var elevationExerciseId: String
|
||||||
|
private lateinit var marcheTenExerciseId: String
|
||||||
|
|
||||||
private val context: Context =
|
private val context: Context =
|
||||||
ApplicationProvider.getApplicationContext()
|
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() {
|
private fun seedDraft() {
|
||||||
context.deleteDatabase(
|
context.deleteDatabase(
|
||||||
DRAFT_UI_TEST_DATABASE_NAME
|
DRAFT_UI_TEST_DATABASE_NAME
|
||||||
|
|
@ -178,6 +315,24 @@ class SessionDraftUiInstrumentedTest {
|
||||||
dataFields = 0,
|
dataFields = 0,
|
||||||
)
|
)
|
||||||
) as CreateExerciseResult.Created
|
) 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(
|
assertEquals(
|
||||||
ActiveDraftMutationResult.Saved,
|
ActiveDraftMutationResult.Saved,
|
||||||
repository.saveActiveSessionDraft(
|
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(
|
private inline fun TrainlogRepository.useForTest(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import java.text.Normalizer
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
enum class EquipmentLoadSemantics {
|
enum class EquipmentLoadSemantics {
|
||||||
|
NONE,
|
||||||
EXTERNAL,
|
EXTERNAL,
|
||||||
ASSISTANCE,
|
ASSISTANCE,
|
||||||
BODYWEIGHT,
|
BODYWEIGHT,
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,9 @@ class SyncCatalogInbox(
|
||||||
it.readText()
|
it.readText()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val definitionsError = importPcEquipmentDefinitions(directory)
|
||||||
|
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
|
||||||
|
|
||||||
when (
|
when (
|
||||||
val result =
|
val result =
|
||||||
repository
|
repository
|
||||||
|
|
@ -183,6 +186,20 @@ class SyncCatalogInbox(
|
||||||
} catch (error: Exception) { error.message ?: "Import séances V2 impossible." }
|
} 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? {
|
private fun importPcEquipmentAssociations(directory: DocumentFile): String? {
|
||||||
val file = directory.findFile("trainlog-equipment-associations-v2.json")
|
val file = directory.findFile("trainlog-equipment-associations-v2.json")
|
||||||
?: directory.findFile("trainlog-equipment-associations-v1.json")
|
?: directory.findFile("trainlog-equipment-associations-v1.json")
|
||||||
|
|
|
||||||
|
|
@ -39,14 +39,26 @@ class SyncExporter(
|
||||||
return SyncExportResult.Unsupported
|
return SyncExportResult.Unsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
/* V2 is the authoritative mobile session exchange. V1 remains
|
/* CONTRACT: capture all three artifacts before publishing any of
|
||||||
* readable by desktop for historic devices but is not published here. */
|
* them. The files are separate for compatibility, but a user edit
|
||||||
val json = repository.buildMobileExportV2Json()
|
* must not make a newly-written V2 reference a definition assembled
|
||||||
|
* from a different logical export state. */
|
||||||
val bytes =
|
val definitionsJson: String
|
||||||
json.toByteArray(
|
val mobileJson: String
|
||||||
Charsets.UTF_8
|
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. */
|
||||||
|
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 =
|
val resolver =
|
||||||
appContext.contentResolver
|
appContext.contentResolver
|
||||||
|
|
@ -65,6 +77,11 @@ class SyncExporter(
|
||||||
val displayName =
|
val displayName =
|
||||||
"trainlog-mobile-export-v2.json"
|
"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 =
|
val existing =
|
||||||
findExisting(
|
findExisting(
|
||||||
collection,
|
collection,
|
||||||
|
|
@ -171,7 +188,7 @@ class SyncExporter(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val companionError = writeEquipmentAssociations()
|
val companionError = writeEquipmentAssociations(associationsJson)
|
||||||
if (companionError != null) {
|
if (companionError != null) {
|
||||||
return SyncExportResult.Error(companionError)
|
return SyncExportResult.Error(companionError)
|
||||||
}
|
}
|
||||||
|
|
@ -201,7 +218,7 @@ class SyncExporter(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Publish the companion separately so frozen mobile-export-v1 stays byte-compatible. */
|
/** 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 resolver = appContext.contentResolver
|
||||||
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||||
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
|
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
|
||||||
|
|
@ -216,7 +233,7 @@ class SyncExporter(
|
||||||
}) ?: return "Création de l'extension équipement impossible."
|
}) ?: return "Création de l'extension équipement impossible."
|
||||||
return try {
|
return try {
|
||||||
resolver.openOutputStream(uri, "wt")?.use {
|
resolver.openOutputStream(uri, "wt")?.use {
|
||||||
it.write(repository.buildEquipmentAssociationsJson().toByteArray(Charsets.UTF_8))
|
it.write(json.toByteArray(Charsets.UTF_8))
|
||||||
it.flush()
|
it.flush()
|
||||||
} ?: return "Écriture de l'extension équipement impossible."
|
} ?: return "Écriture de l'extension équipement impossible."
|
||||||
if (created) resolver.update(uri, ContentValues().apply {
|
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(
|
private fun findExisting(
|
||||||
collection: Uri,
|
collection: Uri,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,10 @@ import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
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.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -19,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
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.model.TrackingMode
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||||
|
import java.text.Normalizer
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionScreen(
|
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(
|
TrainlogFrame(
|
||||||
title = "SEANCE EN COURS"
|
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 =
|
val editingExercise =
|
||||||
currentDraft.form.selectedExercise
|
currentDraft.form.selectedExercise
|
||||||
|
|
||||||
|
|
@ -544,8 +516,8 @@ fun SessionScreen(
|
||||||
private fun CatalogChoice(
|
private fun CatalogChoice(
|
||||||
exercise: ExerciseProfile,
|
exercise: ExerciseProfile,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
disabled: Boolean,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
|
|
@ -558,6 +530,7 @@ private fun CatalogChoice(
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
.then(modifier)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 2.dp)
|
.padding(vertical = 2.dp)
|
||||||
.background(
|
.background(
|
||||||
|
|
@ -568,7 +541,6 @@ private fun CatalogChoice(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = !disabled,
|
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
.padding(
|
.padding(
|
||||||
|
|
@ -579,9 +551,7 @@ private fun CatalogChoice(
|
||||||
BasicText(
|
BasicText(
|
||||||
text =
|
text =
|
||||||
(
|
(
|
||||||
if (disabled) {
|
if (selected) {
|
||||||
"✓ "
|
|
||||||
} else if (selected) {
|
|
||||||
"▌ "
|
"▌ "
|
||||||
} else {
|
} else {
|
||||||
" "
|
" "
|
||||||
|
|
@ -593,9 +563,7 @@ private fun CatalogChoice(
|
||||||
style =
|
style =
|
||||||
TrainlogTypography.normal.copy(
|
TrainlogTypography.normal.copy(
|
||||||
color =
|
color =
|
||||||
if (disabled) {
|
if (selected) {
|
||||||
colors.muted
|
|
||||||
} else if (selected) {
|
|
||||||
colors.warning
|
colors.warning
|
||||||
} else {
|
} else {
|
||||||
colors.text
|
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
|
@Composable
|
||||||
private fun SessionExerciseForm(
|
private fun SessionExerciseForm(
|
||||||
key: String,
|
key: String,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
@ -225,6 +226,7 @@ fun TrainlogInputField(
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
keyboardOptions: KeyboardOptions =
|
keyboardOptions: KeyboardOptions =
|
||||||
KeyboardOptions.Default,
|
KeyboardOptions.Default,
|
||||||
|
testTag: String? = null,
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
|
|
@ -276,6 +278,13 @@ fun TrainlogInputField(
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
focused = it.isFocused
|
focused = it.isFocused
|
||||||
}
|
}
|
||||||
|
.then(
|
||||||
|
if (testTag == null) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.testTag(testTag)
|
||||||
|
}
|
||||||
|
)
|
||||||
.background(colors.surface)
|
.background(colors.surface)
|
||||||
.padding(
|
.padding(
|
||||||
horizontal = 12.dp,
|
horizontal = 12.dp,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -33,7 +33,7 @@ Accueil
|
||||||
Android local database version:
|
Android local database version:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
7
|
8
|
||||||
```
|
```
|
||||||
|
|
||||||
Domain tables cover:
|
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`,
|
Schema v4 introduced `active_session_draft`, `draft_session_exercises`,
|
||||||
`draft_performed_sets` and `draft_continuous_activity`. The implemented
|
`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,
|
observations and the draft while adding the shared equipment catalogue,
|
||||||
occurrence-level equipment links and stable completed/draft `entry_id` values.
|
occurrence-level equipment links and stable completed/draft `entry_id` values.
|
||||||
Exactly one active draft is supported; it is separate from completed history.
|
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
|
PC catalog reconciliation preserves draft references through catalog row
|
||||||
ownership. If an editing selection no longer resolves, only the selection is
|
ownership. If an editing selection no longer resolves, only the selection is
|
||||||
cleared; added exercises and raw text remain, with a specific diagnostic.
|
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
|
When a received catalog entry has the same `exercise_id`, a changed display
|
||||||
display name is reconciled in that same row. A different-ID normalized-name
|
name is reconciled in that same row. When the incoming PC identity differs but
|
||||||
collision is rejected, so a rename cannot become a duplicate exercise.
|
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
|
## 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
|
`trainlog-equipment-associations-v2.json`; its `set` and `cleared` states are
|
||||||
targeted by `(session_id, entry_id)`.
|
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.
|
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
|
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
|
On success Android then applies the latest PC catalog and displays the final
|
||||||
result.
|
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
|
A receipt belonging to another request is ignored as pending rather than
|
||||||
misreported as the current result.
|
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.
|
`local.properties` is local machine configuration and must not be committed.
|
||||||
|
|
||||||
Host regression suite: 8 tests. Device instrumentation: 5 tests (2 repository,
|
The prior host regression suite had 8 tests and the prior device
|
||||||
3 production-screen UI tests using an isolated database and no shared export).
|
instrumentation suite had 5 tests (2 repository, 3 production-screen UI tests
|
||||||
The real device matrix additionally exercised production `MainActivity`,
|
using an isolated database and no shared export). That device matrix exercised
|
||||||
|
production `MainActivity`,
|
||||||
including verified process exit with `am kill`, force-stop, configuration
|
including verified process exit with `am kill`, force-stop, configuration
|
||||||
relaunch, raw-form recovery, removal, discard and unchanged user data. Final-save
|
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.
|
UI checks used isolated data so fictitious workouts did not enter user history.
|
||||||
See [tests](tests.md) for commands and the precise validation boundary.
|
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
|
## 14. Non-goals
|
||||||
|
|
||||||
|
|
@ -333,9 +356,7 @@ Android is not intended to own:
|
||||||
- exercise-name heuristics;
|
- exercise-name heuristics;
|
||||||
- a mounted-filesystem dependency.
|
- a mounted-filesystem dependency.
|
||||||
|
|
||||||
## 15. Test-max sessions
|
## 15. Equipment and multi-occurrence exchange V2
|
||||||
|
|
||||||
## 16. Equipment and multi-occurrence exchange V2
|
|
||||||
|
|
||||||
During exercise entry, `Machine / équipement (optionnel)` searches the shared
|
During exercise entry, `Machine / équipement (optionnel)` searches the shared
|
||||||
catalogue by display name, physical-machine label and aliases. The selected
|
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
|
`Nouvelle machine` in that same selector creates a persistent local custom
|
||||||
equipment entry with a generated stable `eq_…` ID and selects it immediately.
|
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
|
The shared bundled catalogue uses reserved canonical IDs. User-created IDs are
|
||||||
not silently converted to null on the PC and is rejected until its definition
|
exchanged first by definitions V1, so a V2 association can resolve them on the
|
||||||
is available to the receiving catalogue.
|
receiving side; conflicts remain explicit and are never converted to null.
|
||||||
The active-session list exposes `Modifier <exercice>`; saving replaces that
|
The active-session list exposes `Modifier <exercice>`; saving replaces that
|
||||||
entry in place, while cancelling only discards the form and preserves it.
|
entry in place, while cancelling only discards the form and preserves it.
|
||||||
|
|
||||||
|
## 16. Test-max sessions
|
||||||
|
|
||||||
Android session entry exposes:
|
Android session entry exposes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -383,3 +406,33 @@ from large repetition or duration values.
|
||||||
Android's current session form still records the exercise data fields it
|
Android's current session form still records the exercise data fields it
|
||||||
supports. Measured-max classification on the desktop uses only actual values
|
supports. Measured-max classification on the desktop uses only actual values
|
||||||
that were truly captured and synchronized.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ Android capture client
|
||||||
shared desktop sync engine
|
shared desktop sync engine
|
||||||
/ \
|
/ \
|
||||||
/ \
|
/ \
|
||||||
desktop SQLite PC catalog artifact
|
desktop SQLite directional PC artifacts
|
||||||
| |
|
| |
|
||||||
v v
|
v v
|
||||||
desktop TUI Android
|
desktop TUI Android
|
||||||
|
|
@ -120,7 +120,7 @@ Continuous work is persisted separately from performed sets.
|
||||||
|
|
||||||
### Desktop
|
### 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
|
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
|
||||||
occur more than once in one session without identity fusion.
|
occur more than once in one session without identity fusion.
|
||||||
|
|
||||||
|
|
@ -133,11 +133,12 @@ session_exercises
|
||||||
performed_sets
|
performed_sets
|
||||||
continuous_activity
|
continuous_activity
|
||||||
body_observations
|
body_observations
|
||||||
|
custom_equipment
|
||||||
```
|
```
|
||||||
|
|
||||||
### Android
|
### 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
|
It mirrors domain concepts needed for capture, but its schema version is not
|
||||||
coupled to the desktop schema.
|
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
|
the display name and normalized form in the existing catalog row identified by
|
||||||
`exercise_id`; foreign-key ownership consequently preserves completed history
|
`exercise_id`; foreign-key ownership consequently preserves completed history
|
||||||
and active drafts. A profile edit is admitted only before that row is referenced
|
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
|
by either completed or active-draft data. Same-ID reconciliation applies name
|
||||||
reconciliation apply name metadata in place and reject a collision with a
|
metadata in place. A different-ID normalized-name collision may merge only for
|
||||||
different stable ID.
|
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.
|
Compose presentation has one `TrainlogScreen` header component for every page.
|
||||||
It uses the TUI's compact accent `◆ TRAINLOG ◆` plaque and muted context line;
|
It uses the TUI's compact accent `◆ TRAINLOG ◆` plaque and muted context line;
|
||||||
|
|
@ -181,7 +185,10 @@ Synchronization uses separate formats:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
trainlog-mobile-export v1
|
trainlog-mobile-export v1
|
||||||
|
trainlog-mobile-export v2
|
||||||
trainlog-pc-catalog v1
|
trainlog-pc-catalog v1
|
||||||
|
trainlog-equipment-associations v2
|
||||||
|
trainlog-equipment-definitions v1
|
||||||
trainlog-sync-request v1
|
trainlog-sync-request v1
|
||||||
trainlog-sync-receipt 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,
|
A new domain requirement must not be forced into frozen v1 by using notes,
|
||||||
synthetic sets, or data loss.
|
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
|
## 6. Direct MTP transport
|
||||||
|
|
||||||
Linux transport:
|
Linux transport:
|
||||||
|
|
@ -238,19 +254,45 @@ Android request
|
||||||
-> receipt
|
-> 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
|
```text
|
||||||
1. direct-MTP device/storage discovery
|
1. direct-MTP device/storage discovery
|
||||||
2. exchange-folder resolution
|
2. exchange-folder resolution
|
||||||
3. mobile snapshot download
|
3. definition artifact transfer and reconciliation before dependent V2 data
|
||||||
4. strict transactional Android -> PC import
|
4. strict transactional Android -> PC import when selected
|
||||||
5. PC catalog export
|
5. PC catalog, mobile/body, and association export when selected
|
||||||
6. direct-MTP PC catalog publication
|
6. direct-MTP publication of the selected PC -> Android artifacts
|
||||||
7. optional request receipt publication
|
7. optional request receipt publication
|
||||||
8. structured run-history recording
|
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
|
## 8. Synchronization concurrency
|
||||||
|
|
||||||
The shared engine serializes synchronization with:
|
The shared engine serializes synchronization with:
|
||||||
|
|
@ -298,7 +340,7 @@ known errors with generic placeholders.
|
||||||
## 11. Layering rule
|
## 11. Layering rule
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ncurses / Compose rendering
|
Notcurses / Compose rendering
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
application workflow
|
application workflow
|
||||||
|
|
@ -313,7 +355,7 @@ persistence / MTP transport
|
||||||
|
|
||||||
Rendering does not own persistence rules.
|
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
|
## 12. Body analytics boundary
|
||||||
|
|
||||||
|
|
@ -345,3 +387,37 @@ with `~/.config` fallback.
|
||||||
|
|
||||||
This profile is not synchronized to Android and does not require a SQLite
|
This profile is not synchronized to Android and does not require a SQLite
|
||||||
schema change.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Avoid clever code when a straightforward implementation is easier to verify.
|
||||||
|
|
||||||
The TUI uses C17.
|
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
|
```text
|
||||||
-Wall
|
-Wall
|
||||||
|
|
@ -63,6 +63,12 @@ Comments are required for:
|
||||||
- error-handling decisions;
|
- error-handling decisions;
|
||||||
- algorithms whose intent is not immediately obvious.
|
- 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:
|
Bad:
|
||||||
|
|
||||||
```c
|
```c
|
||||||
|
|
@ -81,9 +87,9 @@ Good:
|
||||||
|
|
||||||
## 6. Formatting
|
## 6. Formatting
|
||||||
|
|
||||||
The project will use `clang-format`.
|
The repository currently has no canonical `.clang-format` file. Do not apply a
|
||||||
|
bulk formatter with tool-default behavior; introducing a format definition and
|
||||||
A canonical `.clang-format` file will be added before substantial C implementation.
|
any broad normalization requires a dedicated, reviewable change.
|
||||||
|
|
||||||
Formatting changes should not be mixed with unrelated semantic changes when avoidable.
|
Formatting changes should not be mixed with unrelated semantic changes when avoidable.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Current implementation state
|
# 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
|
This document is the compact source of truth for the implemented Trainlog
|
||||||
baseline. Detailed behavior belongs in the topic-specific documents.
|
baseline. Detailed behavior belongs in the topic-specific documents.
|
||||||
|
|
@ -14,8 +14,8 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
|
||||||
|
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V7=PASS
|
DESKTOP_SCHEMA_V8=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V7=PASS
|
ANDROID_LOCAL_DATABASE_V8=PASS
|
||||||
ANDROID_SESSION_DRAFT_V1=PASS
|
ANDROID_SESSION_DRAFT_V1=PASS
|
||||||
ANDROID_DRAFT_DURABLE=PASS
|
ANDROID_DRAFT_DURABLE=PASS
|
||||||
ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS
|
ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS
|
||||||
|
|
@ -48,10 +48,12 @@ TUI_SYNC_LOG_SHOW=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
MULTI_OCCURRENCE_SESSION_V2=PASS
|
MULTI_OCCURRENCE_SESSION_V2=PASS
|
||||||
EQUIPMENT_ASSOCIATIONS_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
|
ANDROID_BUILD=PASS
|
||||||
HARDWARE_SYNC_VALIDATION=PASS
|
HARDWARE_SYNC_VALIDATION=HISTORICAL_BASELINE_ONLY
|
||||||
```
|
```
|
||||||
|
|
||||||
## Desktop
|
## Desktop
|
||||||
|
|
@ -59,12 +61,15 @@ HARDWARE_SYNC_VALIDATION=PASS
|
||||||
Implemented:
|
Implemented:
|
||||||
|
|
||||||
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
|
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
|
||||||
- SQLite schema v7, with stable ordered `session_exercises.entry_id` and
|
- SQLite schema v8, with stable ordered `session_exercises.entry_id`,
|
||||||
occurrence-level equipment identity;
|
occurrence-level equipment identity, and desktop-local custom-equipment
|
||||||
|
definitions;
|
||||||
- direct session entry;
|
- direct session entry;
|
||||||
- persisted session detail and editing;
|
- persisted session detail and editing;
|
||||||
- exercise removal from a session through transactional child replacement;
|
- exercise removal from a session through transactional child replacement;
|
||||||
- exercise catalog;
|
- exercise catalog;
|
||||||
|
- supplied-equipment browsing/search/detail and desktop-local custom-equipment
|
||||||
|
creation/selection;
|
||||||
- profile-aware set and continuous activities;
|
- profile-aware set and continuous activities;
|
||||||
- heterogeneous repetition sets;
|
- heterogeneous repetition sets;
|
||||||
- multiple occurrences of one catalogue exercise in a session;
|
- multiple occurrences of one catalogue exercise in a session;
|
||||||
|
|
@ -73,7 +78,7 @@ Implemented:
|
||||||
- body graphs and normalized overlays;
|
- body graphs and normalized overlays;
|
||||||
- exercise performance history;
|
- exercise performance history;
|
||||||
- direct USB/MTP device access;
|
- direct USB/MTP device access;
|
||||||
- manual bidirectional synchronization;
|
- manual Android -> PC, PC -> Android, and bidirectional synchronization;
|
||||||
- structured synchronization history and detail.
|
- structured synchronization history and detail.
|
||||||
|
|
||||||
Primary navigation:
|
Primary navigation:
|
||||||
|
|
@ -83,8 +88,9 @@ Primary navigation:
|
||||||
1 Séance
|
1 Séance
|
||||||
2 Historique
|
2 Historique
|
||||||
3 Exercices
|
3 Exercices
|
||||||
4 Corps
|
4 Équipements
|
||||||
5 Sync
|
5 Corps
|
||||||
|
6 Sync
|
||||||
```
|
```
|
||||||
|
|
||||||
## Android
|
## Android
|
||||||
|
|
@ -92,7 +98,7 @@ Primary navigation:
|
||||||
Implemented:
|
Implemented:
|
||||||
|
|
||||||
- native Kotlin/Compose application;
|
- 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;
|
- one durable active-session draft, Home resume and raw-form restoration;
|
||||||
- explicit confirmed discard and atomic completed-save/draft-clear;
|
- explicit confirmed discard and atomic completed-save/draft-clear;
|
||||||
- exercise creation;
|
- 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,
|
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
|
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
|
change is only accepted while the row has neither completed-session nor draft
|
||||||
references. Same-ID catalog reconciliation updates display metadata in place in
|
references. Same-ID catalog reconciliation updates display metadata in place.
|
||||||
both Android and desktop import directions.
|
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
|
All Android screens use the shared compact `◆ TRAINLOG ◆` header: the
|
||||||
Notcurses accent, muted context line, and flat touch layout reproduce the TUI
|
Notcurses accent, muted context line, and flat touch layout reproduce the TUI
|
||||||
|
|
@ -134,10 +144,12 @@ Artifacts:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Android -> PC
|
Android -> PC
|
||||||
|
trainlog-mobile-equipment-definitions-v1.json
|
||||||
trainlog-mobile-export-v2.json
|
trainlog-mobile-export-v2.json
|
||||||
trainlog-equipment-associations-v2.json
|
trainlog-equipment-associations-v2.json
|
||||||
|
|
||||||
PC -> Android
|
PC -> Android
|
||||||
|
trainlog-pc-equipment-definitions-v1.json
|
||||||
trainlog-pc-catalog-v1.json
|
trainlog-pc-catalog-v1.json
|
||||||
trainlog-pc-mobile-export-v2.json
|
trainlog-pc-mobile-export-v2.json
|
||||||
trainlog-equipment-associations-v2.json
|
trainlog-equipment-associations-v2.json
|
||||||
|
|
@ -149,12 +161,51 @@ PC agent -> Android
|
||||||
trainlog-sync-receipt-v1.json
|
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
|
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
|
catalogue identity alone. V1 files remain legacy-compatible and do not gain
|
||||||
multi-occurrence semantics retroactively.
|
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 SQLite file is copied.
|
||||||
|
|
||||||
No mounted Android filesystem is required.
|
No mounted Android filesystem is required.
|
||||||
|
|
@ -164,35 +215,50 @@ No mounted Android filesystem is required.
|
||||||
Desktop:
|
Desktop:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
25/25 Meson tests PASS
|
32/32 Meson tests PASS for the current desktop schema v8 baseline
|
||||||
frozen JSON validator PASS
|
frozen JSON validator PASS
|
||||||
import-contract validator PASS
|
import-contract validator PASS
|
||||||
|
ASan/UBSan 32/32 Meson tests PASS
|
||||||
git diff --check PASS
|
git diff --check PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
Android:
|
Android:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
assembleDebug and Android unit tests are run for every Android delivery.
|
testDebugUnitTest PASS
|
||||||
The prior device baseline below is hardware evidence, not a claim that every
|
assembleDebug PASS
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Current implementation cursor
|
||||||
|
|
||||||
The Android draft correction is implemented, device-validated and reviewed.
|
Exercise reconciliation, definition-first V2 synchronization and the current
|
||||||
No product-roadmap ordering changes were made.
|
real-data importer/exporter copy validation are complete. No product-roadmap
|
||||||
|
ordering change was made by this corrective tranche.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
MEASURED_MAX_V1=PASS
|
MEASURED_MAX_V1=PASS
|
||||||
WORKING_LOAD_PERCENTAGES=PASS
|
WORKING_LOAD_PERCENTAGES=PASS
|
||||||
ANDROID_MAX_TEST_SESSION=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
|
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
|
WORKING_LOAD_PERCENTAGES=PASS
|
||||||
ASSISTANCE_DIRECTION_AWARE=PASS
|
ASSISTANCE_DIRECTION_AWARE=PASS
|
||||||
ANDROID_MAX_TEST_SESSION=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
|
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_PROPORTION_RATIOS=PASS
|
||||||
BODY_SYMMETRY_ANALYTICS=PASS
|
BODY_SYMMETRY_ANALYTICS=PASS
|
||||||
NO_ESTIMATE_PERSISTENCE=PASS
|
NO_ESTIMATE_PERSISTENCE=PASS
|
||||||
DESKTOP_TESTS=25/25 PASS
|
DESKTOP_TESTS=32/32 PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
Android remains capture-only for this feature.
|
Android remains capture-only for this feature.
|
||||||
|
|
|
||||||
102
docs/database.md
102
docs/database.md
|
|
@ -3,8 +3,8 @@
|
||||||
## 1. Status
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION=7
|
TRAINLOG_DATABASE_SCHEMA_VERSION=8
|
||||||
DATABASE_SCHEMA_V7=PASS
|
DATABASE_SCHEMA_V8=PASS
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ PRAGMA user_version;
|
||||||
Current value:
|
Current value:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
7
|
8
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported historical databases are migrated explicitly through the implemented
|
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
|
constraint while retaining rows, sets, continuous activities, weights and
|
||||||
equipment associations.
|
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
|
A schema fixture must represent the real historical structure. Rewriting only
|
||||||
`user_version` is not an acceptable migration test.
|
`user_version` is not an acceptable migration test.
|
||||||
|
|
||||||
|
|
@ -99,7 +104,7 @@ target_sets
|
||||||
target_reps
|
target_reps
|
||||||
target_duration_seconds
|
target_duration_seconds
|
||||||
target_weight_kg
|
target_weight_kg
|
||||||
equipment_id nullable canonical equipment identity
|
equipment_id nullable equipment identity
|
||||||
notes
|
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
|
session row. `tracking_mode` remains associated with the referenced exercise
|
||||||
catalog identity.
|
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:
|
`SETS` rows support two target shapes in schema v5:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -202,6 +222,7 @@ ex_ exercise
|
||||||
se_ session
|
se_ session
|
||||||
bo_ body observation
|
bo_ body observation
|
||||||
sy_ synchronization run
|
sy_ synchronization run
|
||||||
|
sxe_ session-exercise occurrence
|
||||||
```
|
```
|
||||||
|
|
||||||
All use random UUIDv4 values.
|
All use random UUIDv4 values.
|
||||||
|
|
@ -241,7 +262,7 @@ committing database changes.
|
||||||
Properties:
|
Properties:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
schema-v5 aware
|
schema-v5-through-v8 aware
|
||||||
transactional
|
transactional
|
||||||
idempotent by stable IDs
|
idempotent by stable IDs
|
||||||
profile-aware catalog reconciliation
|
profile-aware catalog reconciliation
|
||||||
|
|
@ -250,6 +271,15 @@ no fake target generated
|
||||||
continuous activity kept separate
|
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
|
## 8. Units
|
||||||
|
|
||||||
Canonical desktop persistence:
|
Canonical desktop persistence:
|
||||||
|
|
@ -266,8 +296,10 @@ distance km
|
||||||
|
|
||||||
The Android SQLite database is independent.
|
The Android SQLite database is independent.
|
||||||
|
|
||||||
Current Android-local version: **4**. The explicit v3 -> v4 migration only adds
|
Current Android-local version: **8**. The explicit migration chain adds the
|
||||||
structured draft tables; it does not rebuild or delete existing domain tables.
|
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 |
|
| 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_session_exercises` | Ordered draft exercises and profile snapshots |
|
||||||
| `draft_performed_sets` | Ordered heterogeneous repetition or duration actuals |
|
| `draft_performed_sets` | Ordered heterogeneous repetition or duration actuals |
|
||||||
| `draft_continuous_activity` | Duration and configured speed/distance without synthetic sets |
|
| `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
|
Foreign keys remain enabled. Draft deletion cascades only through draft child
|
||||||
tables; it cannot delete catalog entries or completed history. The repository
|
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
|
both on failure. Repeating finalization after success cannot create another
|
||||||
completed session. Completed `started_at` semantics are unchanged by this repair.
|
completed session. Completed `started_at` semantics are unchanged by this repair.
|
||||||
|
|
||||||
Migration tests use a real v3-shaped fixture. The physical Samsung upgrade also
|
Migration tests cover historical v4 and v7 shapes rather than changing only
|
||||||
preserved every existing domain row, with successful integrity and foreign-key
|
`user_version`. The prior physical Samsung migration preserved every existing
|
||||||
checks. Device backup files are outside the repository; no SQLite files are
|
domain row, with successful integrity and foreign-key checks. Current
|
||||||
used as synchronization artifacts.
|
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.
|
Desktop and Android schema versions are not required to match.
|
||||||
|
|
||||||
|
|
@ -302,13 +354,15 @@ Migration-specific regression coverage includes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
schema_v5_migration
|
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
|
## 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
|
The existing `sessions.session_type = max_test` classification plus actual
|
||||||
`performed_sets` are sufficient.
|
`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.
|
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`. Schema v7
|
||||||
|
adds the non-null stable `entry_id` and removes the obsolete
|
||||||
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
|
|
||||||
`UNIQUE(session_row_id, exercise_row_id)` constraint. The v6 -> v7 rebuild
|
`UNIQUE(session_row_id, exercise_row_id)` constraint. The v6 -> v7 rebuild
|
||||||
preserves primary keys, completed sessions, ordered sets, continuous activities,
|
preserves primary keys, completed sessions, ordered sets, continuous activities,
|
||||||
per-set weights and equipment values.
|
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
|
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
|
set rows. Schema v7 assigns stable `entry_id` values to completed and draft
|
||||||
occurrences. Actual per-set weights remain independent values, so heterogeneous
|
occurrences. Actual per-set weights remain independent values, so heterogeneous
|
||||||
sets and weights survive edit, finalization, reopen and V2 exchange.
|
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
|
Canonical persistence continues to contain only measurements actually entered
|
||||||
by the user.
|
by the user.
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ GATE_1=PASS
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
```
|
```
|
||||||
|
|
||||||
This document defines the proposed final Trainlog v1 exchange contract.
|
This document defines the frozen Trainlog v1 exchange contract. Gate 1
|
||||||
|
validation and review are complete; incompatible new semantics require a new,
|
||||||
The format remains `DRAFT` until Gate 1 validation and mirror review complete.
|
separately versioned format.
|
||||||
|
|
||||||
## 2. Design goal
|
## 2. Design goal
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,14 @@ data_fields = supplemental field bit mask
|
||||||
Known supplemental fields:
|
Known supplemental fields:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SPEED_KMH
|
SPEED_KMH bit 0, mask value 1
|
||||||
DISTANCE_KM
|
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:
|
Valid model-v1 combinations:
|
||||||
|
|
||||||
```text
|
```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
|
Any future change to that historical contract requires an explicit schema
|
||||||
decision rather than an implicit name-based migration.
|
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
|
## 8. Android/TUI parity
|
||||||
|
|
||||||
Both interfaces use the same metadata axes.
|
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.
|
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
|
## 9. Exchange boundaries
|
||||||
|
|
||||||
Frozen Trainlog session JSON v1 remains unchanged.
|
Frozen Trainlog session JSON v1 remains unchanged.
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ GATE_1=PASS
|
||||||
GATE_2=PASS
|
GATE_2=PASS
|
||||||
|
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
DESKTOP_SCHEMA_V7=PASS
|
DESKTOP_SCHEMA_V8=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V7=PASS
|
ANDROID_LOCAL_DATABASE_V8=PASS
|
||||||
|
|
||||||
DIRECT_MTP_TRANSPORT=PASS
|
DIRECT_MTP_TRANSPORT=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
|
|
@ -26,7 +26,7 @@ BODY_ANALYTICS_V1=PASS
|
||||||
EXERCISE_EDIT_V1=PASS
|
EXERCISE_EDIT_V1=PASS
|
||||||
ANDROID_BANNER_PARITY_V1=PASS
|
ANDROID_BANNER_PARITY_V1=PASS
|
||||||
|
|
||||||
DESKTOP_TESTS=25/25 PASS
|
DESKTOP_TESTS=32/32 PASS
|
||||||
TUI_NOTCURSES_V1=PASS
|
TUI_NOTCURSES_V1=PASS
|
||||||
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
||||||
NOTCURSES_TRUECOLOR_THEME=PASS
|
NOTCURSES_TRUECOLOR_THEME=PASS
|
||||||
|
|
@ -46,6 +46,8 @@ The current product baseline includes:
|
||||||
- desktop body analytics;
|
- desktop body analytics;
|
||||||
- direct USB/MTP transport;
|
- direct USB/MTP transport;
|
||||||
- bidirectional Android/PC synchronization;
|
- 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`.
|
- shared synchronization engine and `trainlog-syncd`.
|
||||||
|
|
||||||
`EXERCISE_EDIT_V1` is a completed capture correction: Android permits
|
`EXERCISE_EDIT_V1` is a completed capture correction: Android permits
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ TUI_SYNC_LOG_SHOW=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
MULTI_OCCURRENCE_SESSION_V2=PASS
|
MULTI_OCCURRENCE_SESSION_V2=PASS
|
||||||
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
||||||
|
EQUIPMENT_DEFINITIONS_V1=PASS
|
||||||
|
EXERCISE_RECONCILIATION_V2=PASS
|
||||||
|
|
||||||
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
|
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-v1.json` | `trainlog-mobile-export` v1 |
|
||||||
| Android -> PC | `trainlog-mobile-export-v2.json` | `trainlog-mobile-export` v2 (active) |
|
| 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 |
|
| 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-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-pc-mobile-export-v2.json` | `trainlog-mobile-export` v2 |
|
||||||
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
||||||
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||||
|
|
@ -49,6 +53,20 @@ Framework folder grant.
|
||||||
|
|
||||||
No SQLite file is transferred.
|
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`,
|
V2 is a separate format: every session entry has an `entry_id`, `position`,
|
||||||
metrics, actual loads and optional equipment identity. This permits two
|
metrics, actual loads and optional equipment identity. This permits two
|
||||||
occurrences of the same exercise without fusion. V1 remains readable with its
|
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
|
exercise/order correspondence is unambiguous; otherwise the importer reports
|
||||||
a conflict rather than silently overwriting data.
|
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:
|
Header:
|
||||||
|
|
||||||
|
|
@ -75,6 +119,12 @@ sessions
|
||||||
body_observations
|
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:
|
V2 session entries additionally carry:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -84,10 +134,11 @@ equipment_id optional canonical equipment identity
|
||||||
weight_kg optional actual value on each set
|
weight_kg optional actual value on each set
|
||||||
```
|
```
|
||||||
|
|
||||||
The desktop imports sessions first, preserving `entry_id`, then applies the
|
The desktop imports sessions first, preserving `entry_id` and their equipment,
|
||||||
equipment companion only after all referenced entries exist. Reimporting either
|
then validates the equipment companion only after all referenced entries exist.
|
||||||
artifact reconciles stable identities; it neither duplicates sessions nor
|
The companion corroborates explicit `set`/`cleared` state; it does not overwrite
|
||||||
regenerates occurrence IDs.
|
a divergent occurrence. Reimporting either artifact reconciles stable
|
||||||
|
identities; it neither duplicates sessions nor regenerates occurrence IDs.
|
||||||
|
|
||||||
Exercise profile fields:
|
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
|
Same-ID catalog entries may update display-name metadata in their existing
|
||||||
catalog row; a rename never creates a second exercise identity.
|
catalog row; a rename never creates a second exercise identity.
|
||||||
|
|
||||||
## 5. Desktop mobile importer
|
## 6. Desktop mobile importer
|
||||||
|
|
||||||
Reference importer:
|
Reference importer:
|
||||||
|
|
||||||
|
|
@ -160,7 +211,38 @@ continuous activity kept separate
|
||||||
|
|
||||||
The importer never invents a uniform target merely to fit desktop persistence.
|
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:
|
The desktop publishes:
|
||||||
|
|
||||||
|
|
@ -171,10 +253,13 @@ trainlog-pc-catalog-v1.json
|
||||||
It is a canonical exercise catalog snapshot containing stable profile metadata.
|
It is a canonical exercise catalog snapshot containing stable profile metadata.
|
||||||
|
|
||||||
Android reconciles the received catalog into its local exercise catalog.
|
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.
|
This direction does not overload frozen Trainlog session JSON v1.
|
||||||
|
|
||||||
## 7. Android sync request
|
## 8. Android sync request
|
||||||
|
|
||||||
Android writes:
|
Android writes:
|
||||||
|
|
||||||
|
|
@ -201,7 +286,7 @@ The artifact also carries the request timestamp.
|
||||||
|
|
||||||
A new `request_id` represents a new synchronization request.
|
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:
|
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
|
Android accepts a receipt only when its `request_id` matches the pending
|
||||||
request.
|
request.
|
||||||
|
|
||||||
## 9. Shared desktop engine
|
## 10. Shared desktop engine
|
||||||
|
|
||||||
Canonical implementation:
|
Canonical implementation:
|
||||||
|
|
||||||
|
|
@ -256,21 +341,29 @@ Android request
|
||||||
-> receipt
|
-> receipt
|
||||||
```
|
```
|
||||||
|
|
||||||
One synchronization transaction performs:
|
The engine has three explicit modes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
mobile snapshot download
|
a Android -> PC: definition V1 -> mobile V2 -> association V2; no publish
|
||||||
-> mobile import
|
p PC -> Android: definition V1 -> catalog V1 -> mobile V2 (including bodies)
|
||||||
-> equipment companion import by (session_id, entry_id)
|
-> association V2; no receive
|
||||||
-> PC catalog export
|
b bidirectional: complete inbound sequence, then complete outbound sequence
|
||||||
-> PC mobile V2 export
|
|
||||||
-> PC equipment companion V2 export
|
|
||||||
-> PC catalog MTP publication
|
|
||||||
-> optional receipt publication
|
|
||||||
-> structured run history
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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:
|
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
|
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.
|
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:
|
Every real run has:
|
||||||
|
|
||||||
|
|
@ -293,6 +386,12 @@ Every real run has:
|
||||||
sy_<uuid-v4>
|
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:
|
Artifacts:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -305,9 +404,10 @@ The TUI presents newest runs in a selectable list and opens the detail file with
|
||||||
`Enter`.
|
`Enter`.
|
||||||
|
|
||||||
Legacy history rows without a `sync_id` remain readable as list entries but
|
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:
|
Install or refresh:
|
||||||
|
|
||||||
|
|
@ -330,7 +430,7 @@ tail -f ~/.local/state/trainlog/syncd.log
|
||||||
|
|
||||||
No root privilege is required.
|
No root privilege is required.
|
||||||
|
|
||||||
## 13. Transport invariants
|
## 15. Transport invariants
|
||||||
|
|
||||||
Do not regress to:
|
Do not regress to:
|
||||||
|
|
||||||
|
|
@ -343,25 +443,34 @@ fake uniform targets for heterogeneous actual sets
|
||||||
overloading frozen Trainlog JSON v1
|
overloading frozen Trainlog JSON v1
|
||||||
```
|
```
|
||||||
|
|
||||||
## 14. Hardware validation
|
## 16. Equipment associations V2 and legacy V1
|
||||||
|
|
||||||
## 15. Equipment associations V2 and legacy V1
|
|
||||||
|
|
||||||
`TRAINLOG_FORMAT_V1` remains frozen. The active companion is
|
`TRAINLOG_FORMAT_V1` remains frozen. The active companion is
|
||||||
`trainlog-equipment-associations-v2.json`, format
|
`trainlog-equipment-associations-v2.json`, format
|
||||||
`trainlog-equipment-associations`, version `2`. Each row is identified by
|
`trainlog-equipment-associations`, version `2`. Each row is identified by
|
||||||
`(session_id, entry_id)` and contains `exercise_id` as consistency metadata,
|
`(session_id, entry_id)` and contains `exercise_id` as consistency metadata,
|
||||||
then either `state: set` with a canonical `equipment_id`, or `state: cleared`
|
then either `state: set` with a canonical `equipment_id`, or `state: cleared`
|
||||||
for an intentional removal. A missing companion conveys no equipment
|
for an intentional removal. In the current V2 flow, the mobile snapshot already
|
||||||
information and cannot clear a previously known choice. Unknown canonical IDs,
|
carries the occurrence equipment value and the companion validates it. A
|
||||||
unknown entries and ambiguous identities reject the companion transaction
|
missing companion conveys no equipment information and cannot clear a
|
||||||
explicitly; an unknown equipment reference is never silently changed to null.
|
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
|
The historical V1 companion remains readable only where its
|
||||||
`(session_id, exercise_id)` targeting is unambiguous. It cannot represent two
|
`(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.
|
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
|
```text
|
||||||
MTP device discovery PASS
|
MTP device discovery PASS
|
||||||
|
|
@ -375,3 +484,52 @@ trainlog-syncd processing PASS
|
||||||
receipt publication/readback PASS
|
receipt publication/readback PASS
|
||||||
multiple distinct Android request IDs 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.
|
||||||
|
|
|
||||||
157
docs/tests.md
157
docs/tests.md
|
|
@ -56,42 +56,54 @@ different ID + equivalent normalized name -> reject
|
||||||
new unique identity -> create
|
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
|
## 4. Desktop Meson suite
|
||||||
|
|
||||||
Current normal suite:
|
Current registered set (Meson executes tests in parallel, so displayed order is
|
||||||
|
not contractual):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1 database
|
database
|
||||||
2 catalog
|
catalog
|
||||||
3 equipment_catalog
|
equipment_catalog
|
||||||
4 session_detail
|
custom_equipment
|
||||||
5 duration
|
session_detail
|
||||||
6 body_metrics
|
duration
|
||||||
7 bodyviz
|
body_metrics
|
||||||
8 exercise_performance
|
bodyviz
|
||||||
9 session_type_schema
|
exercise_performance
|
||||||
10 session_edit
|
session_type_schema
|
||||||
11 body_observation_edit
|
session_edit
|
||||||
12 mtp
|
body_observation_edit
|
||||||
13 continuous_session
|
usb
|
||||||
14 continuous_detail
|
mtp
|
||||||
15 exercise_profile_schema
|
exercise_profile_schema
|
||||||
16 usb
|
continuous_session
|
||||||
17 reps
|
continuous_detail
|
||||||
18 variable_sets
|
reps
|
||||||
19 schema_v5_migration
|
variable_sets
|
||||||
20 measured_max
|
schema_v5_migration
|
||||||
21 body_analytics
|
schema_v7_migration
|
||||||
22 terminal_input_event_type_policy
|
mobile_import_variable_sets
|
||||||
23 mobile_import_multi_occurrence
|
mobile_import_multi_occurrence
|
||||||
24 equipment_associations_exchange
|
equipment_associations_exchange
|
||||||
25 mobile_import_variable_sets
|
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
|
```text
|
||||||
25/25 PASS
|
32/32 Meson tests PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
The desktop executable is additionally smoke-checked in isolated tmux PTYs at
|
The desktop executable is additionally smoke-checked in isolated tmux PTYs at
|
||||||
|
|
@ -107,7 +119,7 @@ Notable regression coverage:
|
||||||
- profile-aware exercise constraints;
|
- profile-aware exercise constraints;
|
||||||
- continuous activity without fake sets;
|
- continuous activity without fake sets;
|
||||||
- repetition shorthand/list/pyramid parsing;
|
- 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;
|
- heterogeneous mobile-set import;
|
||||||
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
|
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
|
||||||
RELEASE event is consumed without creating a second navigation action.
|
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.
|
`--database` target, followed by the corrected complete V2 export chain.
|
||||||
- stable-ID mobile-to-desktop rename reconciliation without duplicate catalog
|
- stable-ID mobile-to-desktop rename reconciliation without duplicate catalog
|
||||||
rows or historical-reference replacement.
|
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
|
## 5. Build
|
||||||
|
|
||||||
|
|
@ -141,7 +181,7 @@ cd android
|
||||||
printf 'sdk.dir=%s\n' "$HOME/Android/Sdk" > local.properties
|
printf 'sdk.dir=%s\n' "$HOME/Android/Sdk" > local.properties
|
||||||
|
|
||||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
|
JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
|
||||||
./gradlew assembleDebug
|
./gradlew testDebugUnitTest assembleDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
Install to the connected device when hardware behavior changes:
|
Install to the connected device when hardware behavior changes:
|
||||||
|
|
@ -184,6 +224,15 @@ MTP_READ=PASS
|
||||||
MTP_ROUNDTRIP=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
|
## 8. Bidirectional synchronization validation
|
||||||
|
|
||||||
Validated workflow:
|
Validated workflow:
|
||||||
|
|
@ -263,10 +312,10 @@ Coverage proves:
|
||||||
- external working loads round to the configured increment;
|
- external working loads round to the configured increment;
|
||||||
- working-load percentages reject assistance.
|
- working-load percentages reject assistance.
|
||||||
|
|
||||||
Current normal baseline:
|
Validated current normal suite:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
25/25 PASS
|
32/32 Meson tests PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
## 12. Body analytics regression
|
## 12. Body analytics regression
|
||||||
|
|
@ -288,17 +337,18 @@ Coverage includes:
|
||||||
- missing required circumference handling;
|
- missing required circumference handling;
|
||||||
- invalid estimation-profile rejection.
|
- invalid estimation-profile rejection.
|
||||||
|
|
||||||
Current normal baseline:
|
Validated current normal suite:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
25/25 PASS
|
32/32 Meson tests PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
## 13. Android session draft v1
|
## 13. Android session draft v1
|
||||||
|
|
||||||
Android schema v4 introduced one durable active draft; the current additive
|
Android schema v4 introduced one durable active draft; the current additive
|
||||||
chain reaches schema v7 without clearing completed history or the draft. The
|
chain reaches schema v8 without clearing completed history or the draft. The
|
||||||
current host suite covers exercise
|
current `testDebugUnitTest` suite and `assembleDebug` pass. Host coverage
|
||||||
|
includes exercise
|
||||||
shapes and raw partial text, fresh repository restore, remove/discard, atomic
|
shapes and raw partial text, fresh repository restore, remove/discard, atomic
|
||||||
finalization and repeated-finalize rejection, rollback, catalog reconciliation,
|
finalization and repeated-finalize rejection, rollback, catalog reconciliation,
|
||||||
missing-selection recovery, explicit DB-open failure, historical migration,
|
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
|
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
|
checks and three production-screen Compose UI checks. Coverage includes exact
|
||||||
raw form restoration through Activity recreation, cancellation and confirmation
|
raw form restoration through Activity recreation, cancellation and confirmation
|
||||||
of discard, final save with no stale Resume after recreation, and refusal of a
|
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
|
and preferences backup is outside the repository. No uninstall, package-data
|
||||||
clear, desktop schema change or frozen artifact change is part of this repair.
|
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).
|
Detailed retained evidence: [Android draft execution record](reviews/android_session_draft_v1_resume.md).
|
||||||
|
|
|
||||||
87
docs/tui.md
87
docs/tui.md
|
|
@ -17,8 +17,9 @@ Large-layout primary navigation:
|
||||||
1 Séance
|
1 Séance
|
||||||
2 Historique
|
2 Historique
|
||||||
3 Exercices
|
3 Exercices
|
||||||
4 Corps
|
4 Équipements
|
||||||
5 Sync
|
5 Corps
|
||||||
|
6 Sync
|
||||||
```
|
```
|
||||||
|
|
||||||
Direct shortcuts include the matching function keys where implemented.
|
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
|
Removing an exercise from one session does not remove the exercise from the
|
||||||
catalog.
|
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;
|
- newest-first body observations;
|
||||||
- detail and correction;
|
- detail and correction;
|
||||||
|
|
@ -157,7 +173,7 @@ catalog.
|
||||||
|
|
||||||
Editing preserves observation identity, timestamp, and optional session link.
|
Editing preserves observation identity, timestamp, and optional session link.
|
||||||
|
|
||||||
## 8. Dashboard
|
## 9. Dashboard
|
||||||
|
|
||||||
The dashboard includes a rolling 12-month normalized body graph.
|
The dashboard includes a rolling 12-month normalized body graph.
|
||||||
|
|
||||||
|
|
@ -172,7 +188,7 @@ Rules include:
|
||||||
|
|
||||||
Detailed raw observations remain in `Corps`.
|
Detailed raw observations remain in `Corps`.
|
||||||
|
|
||||||
## 9. Exercise performance
|
## 10. Exercise performance
|
||||||
|
|
||||||
Exercise detail exposes recorded performance history.
|
Exercise detail exposes recorded performance history.
|
||||||
|
|
||||||
|
|
@ -193,9 +209,10 @@ assistance
|
||||||
|
|
||||||
A best recorded set is not automatically a measured maximum.
|
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:
|
The page shows:
|
||||||
|
|
||||||
|
|
@ -206,10 +223,37 @@ The page shows:
|
||||||
Manual action:
|
Manual action:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
s run bidirectional synchronization
|
a run Android -> PC synchronization
|
||||||
|
p run PC -> Android synchronization
|
||||||
|
b run bidirectional synchronization
|
||||||
r refresh device status
|
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:
|
History behaves like a compact Git log:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -231,7 +275,7 @@ summary
|
||||||
error when applicable
|
error when applicable
|
||||||
```
|
```
|
||||||
|
|
||||||
## 11. Shared sync engine
|
## 12. Shared sync engine
|
||||||
|
|
||||||
The TUI does not own a separate synchronization implementation.
|
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
|
This keeps import/export, MTP publication, locking, history, and diagnostics in
|
||||||
one implementation.
|
one implementation.
|
||||||
|
|
||||||
## 12. Direct MTP
|
## 13. Direct MTP
|
||||||
|
|
||||||
Transport uses:
|
Transport uses:
|
||||||
|
|
||||||
|
|
@ -257,9 +301,9 @@ libmtp -> storage/object operations
|
||||||
|
|
||||||
No filesystem mount is required.
|
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.
|
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
|
Synchronization failure displays a useful final diagnostic and records the
|
||||||
structured run when a transaction actually begins.
|
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
|
```bash
|
||||||
meson compile -C build
|
meson compile -C build
|
||||||
meson test -C build --print-errorlogs
|
meson test -C build --print-errorlogs
|
||||||
```
|
```
|
||||||
|
|
||||||
Current normal suite:
|
Validated current normal suite:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
19/19 PASS
|
32/32 Meson tests PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
## 15. Measured max view
|
## 16. Measured max view
|
||||||
|
|
||||||
From `3 Exercices`, the selected exercise exposes:
|
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
|
No percentage-of-max working load is produced for assistance or no-load
|
||||||
performance.
|
performance.
|
||||||
|
|
||||||
## 16. Body analytics
|
## 17. Body analytics
|
||||||
|
|
||||||
`4 Corps` adds:
|
`5 Corps` adds:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
v analyse corporelle
|
v analyse corporelle
|
||||||
|
|
|
||||||
128
tests/create_pc_android_idempotence_fixture.py
Normal file
128
tests/create_pc_android_idempotence_fixture.py
Normal 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()
|
||||||
|
|
@ -15,11 +15,12 @@ def make_db(path, equipment):
|
||||||
CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT UNIQUE);
|
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 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 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 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 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("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()
|
db.commit(); db.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,14 +28,65 @@ def main():
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
directory = Path(directory)
|
directory = Path(directory)
|
||||||
source, target, artifact = directory / 'source.db', directory / 'target.db', directory / 'equipment.json'
|
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/export_equipment_associations.py', artifact, '--database', source], check=True)
|
||||||
subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], 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'
|
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'}
|
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))
|
artifact.write_text(json.dumps(payload))
|
||||||
subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], check=True)
|
conflict = subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], text=True, capture_output=True)
|
||||||
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] is None
|
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')
|
print('PASS equipment association exchange')
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
68
tests/test_equipment_definitions_exchange.py
Normal file
68
tests/test_equipment_definitions_exchange.py
Normal 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()
|
||||||
490
tests/test_exercise_reconciliation.py
Normal file
490
tests/test_exercise_reconciliation.py
Normal 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()
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""End-to-end V2 import regression: Walk, other movement, Walk again."""
|
"""End-to-end V2 import regression: Walk, other movement, Walk again."""
|
||||||
import json
|
import json
|
||||||
|
import copy
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -10,7 +11,7 @@ from pathlib import Path
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
IMPORTER = ROOT / "tools/import_mobile_export.py"
|
IMPORTER = ROOT / "tools/import_mobile_export.py"
|
||||||
EQUIPMENT_IMPORTER = ROOT / "tools/import_equipment_associations.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"
|
MOBILE_EXPORTER = ROOT / "tools/export_pc_mobile.py"
|
||||||
EQUIPMENT_EXPORTER = ROOT / "tools/export_equipment_associations.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 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 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);
|
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():
|
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_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}},
|
{"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):
|
def invoke(path, db):
|
||||||
result = subprocess.run([sys.executable, str(IMPORTER), str(path), "--database", str(db)], text=True, capture_output=True)
|
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)
|
raise AssertionError(result.stdout + result.stderr)
|
||||||
return result.stdout
|
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():
|
def main():
|
||||||
with tempfile.TemporaryDirectory(prefix="trainlog-multi-") as temp:
|
with tempfile.TemporaryDirectory(prefix="trainlog-multi-") as temp:
|
||||||
root = Path(temp); db = root / "db.sqlite"; artifact = root / "mobile-v2.json"
|
root = Path(temp); db = root / "db.sqlite"; artifact = root / "mobile-v2.json"
|
||||||
equipment = root / "equipment-v2.json"
|
equipment = root / "equipment-v2.json"
|
||||||
con = sqlite3.connect(db); con.executescript(SCHEMA); con.close()
|
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")
|
artifact.write_text(json.dumps(payload()), encoding="utf-8")
|
||||||
assert "sessions_imported=1" in invoke(artifact, db)
|
assert "sessions_imported=1" in invoke(artifact, db)
|
||||||
con = sqlite3.connect(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()
|
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 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 con.execute("SELECT weight_kg FROM performed_sets").fetchone()[0] == 20.0
|
||||||
|
assert "sessions_skipped=1" in invoke(artifact, db)
|
||||||
con.close()
|
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({
|
equipment.write_text(json.dumps({
|
||||||
"format": "trainlog-equipment-associations",
|
"format": "trainlog-equipment-associations",
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"generated_at": "2026-09-07T10:00:00+02:00",
|
"generated_at": "2026-09-07T10:00:00+02:00",
|
||||||
"associations": [
|
"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_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"},
|
{"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"
|
"SELECT entry_id,equipment_id FROM session_exercises ORDER BY position"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
assert rows == [
|
assert rows == [
|
||||||
("sxe_walk_10", "treadmill"),
|
("sxe_walk_10", None),
|
||||||
("sxe_push", "leg_press"),
|
("sxe_push", "leg_press"),
|
||||||
("sxe_walk_15", None),
|
("sxe_walk_15", None),
|
||||||
], rows
|
], rows
|
||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
assert "sessions_reconciled=1" in invoke(artifact, db)
|
|
||||||
assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in invoke_equipment(equipment, db)
|
assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in invoke_equipment(equipment, db)
|
||||||
con = sqlite3.connect(db)
|
con = sqlite3.connect(db)
|
||||||
assert con.execute("SELECT count(*) FROM session_exercises").fetchone()[0] == 3
|
assert con.execute("SELECT count(*) FROM session_exercises").fetchone()[0] == 3
|
||||||
|
|
@ -107,11 +193,9 @@ def main():
|
||||||
).fetchall() == rows
|
).fetchall() == rows
|
||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
catalog_output = root / "pc-catalog.json"
|
|
||||||
mobile_output = root / "pc-mobile-v2.json"
|
mobile_output = root / "pc-mobile-v2.json"
|
||||||
equipment_output = root / "pc-equipment-v2.json"
|
equipment_output = root / "pc-equipment-v2.json"
|
||||||
for tool, output, marker in (
|
for tool, output, marker in (
|
||||||
(CATALOG_EXPORTER, catalog_output, "PC_CATALOG_EXPORT=PASS"),
|
|
||||||
(MOBILE_EXPORTER, mobile_output, "PC_MOBILE_EXPORT=PASS"),
|
(MOBILE_EXPORTER, mobile_output, "PC_MOBILE_EXPORT=PASS"),
|
||||||
(EQUIPMENT_EXPORTER, equipment_output, "EQUIPMENT_ASSOCIATIONS_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 result.returncode == 0, result.stdout + result.stderr
|
||||||
assert marker in result.stdout
|
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")
|
print("PASS mobile_import_multi_occurrence")
|
||||||
|
|
||||||
if __name__ == "__main__": main()
|
if __name__ == "__main__": main()
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,30 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("output", type=Path)
|
parser.add_argument("output", type=Path)
|
||||||
parser.add_argument("--database", type=Path,
|
parser.add_argument("--database", type=Path,
|
||||||
default=Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db")
|
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()
|
args = parser.parse_args()
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] != 7:
|
if connection.execute("PRAGMA user_version;").fetchone()[0] != 8:
|
||||||
raise ValueError("schema desktop v7 requis")
|
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(
|
rows = connection.execute(
|
||||||
"SELECT s.session_id,se.entry_id,e.exercise_id,se.equipment_id FROM session_exercises se "
|
"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 "
|
"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,
|
payload = {"format": "trainlog-equipment-associations", "version": 2,
|
||||||
"generated_at": datetime.now().astimezone().isoformat(), "associations": []}
|
"generated_at": datetime.now().astimezone().isoformat(), "associations": []}
|
||||||
for session_id, entry_id, exercise_id, equipment_id in rows:
|
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,
|
item = {"session_id": session_id, "entry_id": entry_id, "exercise_id": exercise_id,
|
||||||
"state": "set" if equipment_id is not None else "cleared"}
|
"state": "set" if equipment_id is not None else "cleared"}
|
||||||
if equipment_id is not None:
|
if equipment_id is not None:
|
||||||
|
|
|
||||||
46
tools/export_equipment_definitions.py
Normal file
46
tools/export_equipment_definitions.py
Normal 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)
|
||||||
|
|
@ -61,7 +61,10 @@ def main():
|
||||||
"PRAGMA user_version;"
|
"PRAGMA user_version;"
|
||||||
).fetchone()[0]
|
).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(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,20 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
|
||||||
|
|
||||||
|
|
||||||
def default_database():
|
def default_database():
|
||||||
return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db"
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("output", type=Path)
|
parser.add_argument("output", type=Path)
|
||||||
|
|
@ -20,8 +30,11 @@ def main():
|
||||||
con = sqlite3.connect(args.database)
|
con = sqlite3.connect(args.database)
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] != 7:
|
if con.execute("PRAGMA user_version").fetchone()[0] != 8:
|
||||||
raise ValueError("schema desktop v7 requis")
|
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,
|
root = {"format": "trainlog-mobile-export", "version": 2,
|
||||||
"generated_at": datetime.now().astimezone().isoformat(),
|
"generated_at": datetime.now().astimezone().isoformat(),
|
||||||
"exercises": [], "sessions": [], "body_observations": []}
|
"exercises": [], "sessions": [], "body_observations": []}
|
||||||
|
|
@ -34,6 +47,14 @@ def main():
|
||||||
# retain their stable entry_id but do not duplicate that field.
|
# 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"
|
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"],)):
|
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"],
|
item = {"entry_id": entry["entry_id"], "position": entry["position"],
|
||||||
"exercise_id": entry["exercise_id"], "name": entry["name"],
|
"exercise_id": entry["exercise_id"], "name": entry["name"],
|
||||||
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
|
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
|
||||||
|
|
@ -52,6 +73,15 @@ def main():
|
||||||
item["sets"].append(set_value)
|
item["sets"].append(set_value)
|
||||||
payload["exercises"].append(item)
|
payload["exercises"].append(item)
|
||||||
root["sessions"].append(payload)
|
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")
|
args.output.write_text(json.dumps(root, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||||
print("PC_MOBILE_EXPORT=PASS")
|
print("PC_MOBILE_EXPORT=PASS")
|
||||||
print("sessions=" + str(len(root["sessions"])))
|
print("sessions=" + str(len(root["sessions"])))
|
||||||
|
|
|
||||||
|
|
@ -20,54 +20,139 @@ def load_catalog(path):
|
||||||
return {item["id"] for item in root["equipment"]}
|
return {item["id"] for item in root["equipment"]}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_associations(associations, known):
|
||||||
|
"""Validate the complete v2 payload before any database mutation."""
|
||||||
|
validated = []
|
||||||
|
seen = set()
|
||||||
|
for item in associations:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
fail("association invalide")
|
||||||
|
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):
|
||||||
|
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("é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")
|
||||||
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("artifact", type=Path)
|
parser.add_argument("artifact", type=Path)
|
||||||
parser.add_argument("--database", type=Path, required=True)
|
parser.add_argument("--database", type=Path, required=True)
|
||||||
parser.add_argument("--catalog", type=Path,
|
parser.add_argument("--catalog", type=Path,
|
||||||
default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json")
|
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()
|
args = parser.parse_args()
|
||||||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||||
if payload.get("format") != FORMAT or payload.get("version") != VERSION:
|
if payload.get("format") != FORMAT or payload.get("version") != VERSION:
|
||||||
fail("extension équipement non supportée")
|
fail("extension équipement non supportée")
|
||||||
if set(payload) != {"format", "version", "generated_at", "associations"}:
|
if set(payload) != {"format", "version", "generated_at", "associations"}:
|
||||||
fail("clés extension équipement invalides")
|
fail("clés extension équipement invalides")
|
||||||
known = load_catalog(args.catalog)
|
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] != 7:
|
if connection.execute("PRAGMA user_version;").fetchone()[0] != 8:
|
||||||
fail("schema desktop v7 requis")
|
fail("schema desktop v8 requis")
|
||||||
seen = set()
|
known = load_catalog(args.catalog)
|
||||||
with connection:
|
known.update(row[0] for row in connection.execute(
|
||||||
for item in payload["associations"]:
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
if not isinstance(item, dict):
|
associations = validate_associations(payload["associations"], known)
|
||||||
fail("association invalide")
|
proof_path = args.mobile_export
|
||||||
session_id, entry_id, exercise_id, state = (item.get(k) for k in ("session_id", "entry_id", "exercise_id", "state"))
|
if proof_path is None:
|
||||||
key = (session_id, entry_id)
|
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
||||||
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 candidate.exists():
|
||||||
fail("identité association invalide ou dupliquée")
|
proof_path = candidate
|
||||||
seen.add(key)
|
mobile_occurrences = (
|
||||||
if state == "set":
|
load_mobile_occurrences(proof_path)
|
||||||
if set(item) != {"session_id", "entry_id", "exercise_id", "state", "equipment_id"}:
|
if proof_path is not None
|
||||||
fail("association set invalide")
|
else {}
|
||||||
equipment_id = item["equipment_id"]
|
)
|
||||||
if not isinstance(equipment_id, str) or equipment_id not in known:
|
for session_id, entry_id, exercise_id, equipment_id in associations:
|
||||||
fail(f"équipement inconnu: {equipment_id}")
|
exists = connection.execute(
|
||||||
elif state == "cleared":
|
"SELECT e.exercise_id,se.equipment_id FROM session_exercises se "
|
||||||
if set(item) != {"session_id", "entry_id", "exercise_id", "state"}:
|
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
||||||
fail("association cleared invalide")
|
"WHERE s.session_id=? AND se.entry_id=?",
|
||||||
equipment_id = None
|
(session_id, entry_id)).fetchone()
|
||||||
else:
|
if exists is None:
|
||||||
fail("état association invalide")
|
fail(f"entrée séance inconnue: {session_id}/{entry_id}")
|
||||||
cursor = connection.execute(
|
if exists[0] != exercise_id:
|
||||||
"UPDATE session_exercises SET equipment_id=? WHERE id=("
|
proof = mobile_occurrences.get((session_id, entry_id))
|
||||||
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id "
|
incoming_still_exists = connection.execute(
|
||||||
"WHERE s.session_id=? AND se.entry_id=?)",
|
"SELECT 1 FROM exercises WHERE exercise_id=?;",
|
||||||
(equipment_id, session_id, entry_id))
|
(exercise_id,),
|
||||||
if cursor.rowcount != 1:
|
).fetchone() is not None
|
||||||
fail(f"entrée séance inconnue: {session_id}/{entry_id}")
|
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("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS")
|
||||||
print(f"associations={len(seen)}")
|
print(f"associations={len(associations)}")
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
|
||||||
95
tools/import_equipment_definitions.py
Normal file
95
tools/import_equipment_definitions.py
Normal 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)
|
||||||
|
|
@ -11,6 +11,11 @@ from pathlib import Path
|
||||||
FORMAT = "trainlog-mobile-export"
|
FORMAT = "trainlog-mobile-export"
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
KNOWN_DATA_FIELDS = 3
|
KNOWN_DATA_FIELDS = 3
|
||||||
|
DEFAULT_EQUIPMENT_CATALOG = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "catalog"
|
||||||
|
/ "equipment-v1.json"
|
||||||
|
)
|
||||||
|
|
||||||
TOP_LEVEL_KEYS = {
|
TOP_LEVEL_KEYS = {
|
||||||
"format",
|
"format",
|
||||||
|
|
@ -236,6 +241,38 @@ def load_payload(path):
|
||||||
return payload
|
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(
|
def validate_profile(
|
||||||
recording_mode,
|
recording_mode,
|
||||||
tracking_mode,
|
tracking_mode,
|
||||||
|
|
@ -281,6 +318,7 @@ def validate_profile(
|
||||||
|
|
||||||
def validate_exercises(payload):
|
def validate_exercises(payload):
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
|
profiles = {}
|
||||||
|
|
||||||
for index, item in enumerate(
|
for index, item in enumerate(
|
||||||
payload["exercises"]
|
payload["exercises"]
|
||||||
|
|
@ -318,7 +356,9 @@ def validate_exercises(payload):
|
||||||
label,
|
label,
|
||||||
)
|
)
|
||||||
|
|
||||||
return seen_ids
|
profiles[exercise_id] = item
|
||||||
|
|
||||||
|
return profiles
|
||||||
|
|
||||||
|
|
||||||
def validate_set_item(
|
def validate_set_item(
|
||||||
|
|
@ -406,6 +446,19 @@ def validate_session_exercise(
|
||||||
label,
|
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":
|
if item["load_mode"] != "none":
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"{label}: mobile export v1 exige load_mode=none"
|
f"{label}: mobile export v1 exige load_mode=none"
|
||||||
|
|
@ -516,6 +569,7 @@ def validate_session_exercise(
|
||||||
def validate_sessions(
|
def validate_sessions(
|
||||||
payload,
|
payload,
|
||||||
known_exercise_ids,
|
known_exercise_ids,
|
||||||
|
known_equipment_ids,
|
||||||
):
|
):
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
|
|
||||||
|
|
@ -582,6 +636,22 @@ def validate_sessions(
|
||||||
known_exercise_ids,
|
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"]
|
exercise_id = exercise["exercise_id"]
|
||||||
identity = exercise.get("entry_id", 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(
|
exercise_ids = validate_exercises(
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
|
|
@ -657,19 +727,22 @@ def validate_payload(payload):
|
||||||
validate_sessions(
|
validate_sessions(
|
||||||
payload,
|
payload,
|
||||||
exercise_ids,
|
exercise_ids,
|
||||||
|
known_equipment_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
validate_body(payload)
|
validate_body(payload)
|
||||||
|
|
||||||
|
|
||||||
def require_schema_v5(connection):
|
def require_supported_schema(connection):
|
||||||
version = connection.execute(
|
version = connection.execute(
|
||||||
"PRAGMA user_version;"
|
"PRAGMA user_version;"
|
||||||
).fetchone()[0]
|
).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(
|
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()
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
def profile_matches(
|
def data_fields_are_comparable(left, right):
|
||||||
row,
|
"""Return true only when either bounded field mask contains the other."""
|
||||||
exercise,
|
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 (
|
return (
|
||||||
row["tracking_mode"]
|
row["tracking_mode"] == exercise["tracking_mode"]
|
||||||
== exercise["tracking_mode"]
|
and row["recording_mode"] == exercise["recording_mode"]
|
||||||
and row["recording_mode"]
|
and data_fields_are_comparable(
|
||||||
== exercise["recording_mode"]
|
row["data_fields"], exercise["data_fields"]
|
||||||
and 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,
|
connection,
|
||||||
payload,
|
payload,
|
||||||
report,
|
report,
|
||||||
|
trace_exercises=False,
|
||||||
):
|
):
|
||||||
mapping = {}
|
mapping = {}
|
||||||
|
|
||||||
|
|
@ -748,12 +899,18 @@ def import_exercises(
|
||||||
)
|
)
|
||||||
|
|
||||||
if by_id is not None:
|
if by_id is not None:
|
||||||
if not profile_matches(
|
if not profiles_are_reconcilable(
|
||||||
by_id,
|
by_id,
|
||||||
exercise,
|
exercise,
|
||||||
):
|
):
|
||||||
|
trace_exercise_decision(
|
||||||
|
trace_exercises,
|
||||||
|
exercise,
|
||||||
|
f"exercise_id:{by_id['exercise_id']}",
|
||||||
|
"conflict",
|
||||||
|
)
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"profil incompatible pour {exercise_id}"
|
profile_conflict(by_id, exercise)
|
||||||
)
|
)
|
||||||
|
|
||||||
by_name = lookup_exercise_by_normalized(
|
by_name = lookup_exercise_by_normalized(
|
||||||
|
|
@ -764,10 +921,46 @@ def import_exercises(
|
||||||
by_name is not None
|
by_name is not None
|
||||||
and by_name["id"] != by_id["id"]
|
and by_name["id"] != by_id["id"]
|
||||||
):
|
):
|
||||||
raise ImportFailure(
|
if not profiles_are_reconcilable(by_name, by_id):
|
||||||
"conflit de nom pour l'identité "
|
trace_exercise_decision(
|
||||||
+ exercise_id
|
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
|
# CONTRACT: exercise_id is the synchronization identity. A rename
|
||||||
# updates metadata in place, retaining every historical and draft
|
# updates metadata in place, retaining every historical and draft
|
||||||
|
|
@ -779,22 +972,34 @@ def import_exercises(
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE exercises
|
UPDATE exercises
|
||||||
SET name = ?, normalized_name = ?
|
SET name = ?, normalized_name = ?, data_fields = ?
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
exercise["name"],
|
exercise["name"],
|
||||||
normalized,
|
normalized,
|
||||||
|
richer_fields,
|
||||||
by_id["id"],
|
by_id["id"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
report["exercises_reconciled"] += 1
|
report["exercises_reconciled"] += 1
|
||||||
|
decision = "existing-reconciled"
|
||||||
|
elif richer_fields != by_id["data_fields"]:
|
||||||
|
report["exercises_reconciled"] += 1
|
||||||
|
decision = "existing-reconciled"
|
||||||
else:
|
else:
|
||||||
report["exercises_skipped"] += 1
|
report["exercises_skipped"] += 1
|
||||||
|
decision = "existing-identical"
|
||||||
|
|
||||||
mapping[exercise_id] = (
|
mapping[exercise_id] = (
|
||||||
by_id["exercise_id"]
|
by_id["exercise_id"]
|
||||||
)
|
)
|
||||||
|
trace_exercise_decision(
|
||||||
|
trace_exercises,
|
||||||
|
exercise,
|
||||||
|
f"exercise_id:{by_id['exercise_id']}",
|
||||||
|
decision,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
by_name = lookup_exercise_by_normalized(
|
by_name = lookup_exercise_by_normalized(
|
||||||
|
|
@ -803,20 +1008,34 @@ def import_exercises(
|
||||||
)
|
)
|
||||||
|
|
||||||
if by_name is not None:
|
if by_name is not None:
|
||||||
if not profile_matches(
|
if not profiles_are_reconcilable(by_name, exercise):
|
||||||
by_name,
|
# Name equality alone remains insufficient: incompatible modes
|
||||||
exercise,
|
# or incomparable optional-field masks are an explicit conflict.
|
||||||
):
|
trace_exercise_decision(
|
||||||
raise ImportFailure(
|
trace_exercises,
|
||||||
"conflit de profil pour le nom "
|
exercise,
|
||||||
+ exercise["name"]
|
f"normalized_name:{by_name['exercise_id']}",
|
||||||
|
"conflict",
|
||||||
)
|
)
|
||||||
|
raise ImportFailure(profile_conflict(by_name, exercise))
|
||||||
|
|
||||||
mapping[exercise_id] = (
|
richer_fields = enrich_desktop_profile(connection, by_name, exercise)
|
||||||
by_name["exercise_id"]
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
report["exercises_reconciled"] += 1
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
connection.execute(
|
connection.execute(
|
||||||
|
|
@ -842,6 +1061,12 @@ def import_exercises(
|
||||||
|
|
||||||
mapping[exercise_id] = exercise_id
|
mapping[exercise_id] = exercise_id
|
||||||
report["exercises_imported"] += 1
|
report["exercises_imported"] += 1
|
||||||
|
trace_exercise_decision(
|
||||||
|
trace_exercises,
|
||||||
|
exercise,
|
||||||
|
"none",
|
||||||
|
"inserted",
|
||||||
|
)
|
||||||
|
|
||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
|
|
@ -1037,7 +1262,17 @@ def import_sessions(
|
||||||
"SELECT s.id FROM sessions s WHERE s.session_id=?;",
|
"SELECT s.id FROM sessions s WHERE s.session_id=?;",
|
||||||
(session["session_id"],)).fetchone()
|
(session["session_id"],)).fetchone()
|
||||||
session_row_id = existing[0]
|
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(
|
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;",
|
"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()
|
(session_row_id,)).fetchall()
|
||||||
|
|
@ -1047,6 +1282,16 @@ def import_sessions(
|
||||||
# unique. Any other identity disagreement is an explicit conflict.
|
# 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]):
|
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"])
|
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
|
# Explicit child deletion makes reconciliation safe even for old
|
||||||
# databases which were created without enforced foreign keys.
|
# databases which were created without enforced foreign keys.
|
||||||
connection.execute("DELETE FROM performed_sets WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
|
connection.execute("DELETE FROM 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"]
|
!= item["tracking_mode"]
|
||||||
or row["recording_mode"]
|
or row["recording_mode"]
|
||||||
!= item["recording_mode"]
|
!= item["recording_mode"]
|
||||||
or row["data_fields"]
|
or item["data_fields"] & ~row["data_fields"]
|
||||||
!= item["data_fields"]
|
|
||||||
):
|
):
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
"snapshot de séance incompatible avec le catalogue desktop"
|
"snapshot de séance incompatible avec le catalogue desktop"
|
||||||
|
|
@ -1135,6 +1379,47 @@ def import_sessions(
|
||||||
report["sessions_imported"] += 1
|
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(
|
def body_exists(
|
||||||
connection,
|
connection,
|
||||||
observation_id,
|
observation_id,
|
||||||
|
|
@ -1194,10 +1479,13 @@ def import_body(
|
||||||
for observation in payload[
|
for observation in payload[
|
||||||
"body_observations"
|
"body_observations"
|
||||||
]:
|
]:
|
||||||
if body_exists(
|
existing = connection.execute(
|
||||||
connection,
|
f"SELECT observed_at,{','.join(metric_order)} FROM body_observations WHERE observation_id=?",
|
||||||
observation["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
|
report["body_skipped"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -1223,6 +1511,7 @@ def run_import(
|
||||||
payload,
|
payload,
|
||||||
database_path,
|
database_path,
|
||||||
dry_run,
|
dry_run,
|
||||||
|
trace_exercises=False,
|
||||||
):
|
):
|
||||||
if not database_path.exists():
|
if not database_path.exists():
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
|
|
@ -1251,7 +1540,7 @@ def run_import(
|
||||||
"PRAGMA foreign_keys = ON;"
|
"PRAGMA foreign_keys = ON;"
|
||||||
)
|
)
|
||||||
|
|
||||||
require_schema_v5(
|
require_supported_schema(
|
||||||
connection
|
connection
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1263,6 +1552,7 @@ def run_import(
|
||||||
connection,
|
connection,
|
||||||
payload,
|
payload,
|
||||||
report,
|
report,
|
||||||
|
trace_exercises,
|
||||||
)
|
)
|
||||||
|
|
||||||
import_sessions(
|
import_sessions(
|
||||||
|
|
@ -1342,6 +1632,18 @@ def main():
|
||||||
action="store_true",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -1349,14 +1651,20 @@ def main():
|
||||||
args.json_path
|
args.json_path
|
||||||
)
|
)
|
||||||
|
|
||||||
validate_payload(
|
known_equipment_ids = load_supplied_equipment_ids(args.catalog)
|
||||||
payload
|
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(
|
report = run_import(
|
||||||
payload,
|
payload,
|
||||||
args.database,
|
args.database,
|
||||||
args.dry_run,
|
args.dry_run,
|
||||||
|
args.trace_exercises,
|
||||||
)
|
)
|
||||||
|
|
||||||
print_report(
|
print_report(
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,60 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 7
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 8
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
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(
|
TrainlogStatus trainlog_database_open(
|
||||||
const char *path,
|
const char *path,
|
||||||
TrainlogDatabase **output_database
|
TrainlogDatabase **output_database
|
||||||
|
|
|
||||||
|
|
@ -14,33 +14,40 @@
|
||||||
#define TRAINLOG_UUID_TEXT_LENGTH 36U
|
#define TRAINLOG_UUID_TEXT_LENGTH 36U
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Longest official v1 generated identifier:
|
* Longest official generated identifier:
|
||||||
* two-character type prefix + '_' + UUID text + terminating NUL.
|
* 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 \
|
#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
|
* Official creators generate identifiers in the form
|
||||||
* in the form `<prefix>_<uuid-v4>`. The function accepts only the currently
|
* `<prefix>_<uuid-v4>`. The function accepts only the currently reserved
|
||||||
* reserved two-character prefixes:
|
* prefixes:
|
||||||
*
|
*
|
||||||
* - `ex` for exercises;
|
* - `ex` for exercises;
|
||||||
* - `se` for sessions;
|
* - `se` for sessions;
|
||||||
* - `bo` for body observations.
|
* - `bo` for body observations.
|
||||||
|
* - `sy` for synchronization runs;
|
||||||
|
* - `sxe` for persisted session-exercise occurrences.
|
||||||
*
|
*
|
||||||
* Imported v1 documents may contain other schema-valid opaque identifiers;
|
* Imported v1 documents may contain other schema-valid opaque identifiers;
|
||||||
* this API defines creation policy, not import validation.
|
* 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 Caller-owned output buffer.
|
||||||
* @param output_size Size of @p output in bytes.
|
* @param output_size Size of @p output in bytes.
|
||||||
*
|
*
|
||||||
* @return TRAINLOG_STATUS_OK on success.
|
* @return TRAINLOG_STATUS_OK on success.
|
||||||
* @return TRAINLOG_STATUS_INVALID_ARGUMENT for invalid pointers, prefix, or a
|
* @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(
|
TrainlogStatus trainlog_id_generate(
|
||||||
const char *prefix,
|
const char *prefix,
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ typedef struct TrainlogMtpEntry {
|
||||||
uint32_t parent_id;
|
uint32_t parent_id;
|
||||||
uint32_t storage_id;
|
uint32_t storage_id;
|
||||||
uint64_t size_bytes;
|
uint64_t size_bytes;
|
||||||
|
uint64_t modification_unix_seconds;
|
||||||
bool folder;
|
bool folder;
|
||||||
char name[TRAINLOG_MTP_ENTRY_NAME_MAX + 1U];
|
char name[TRAINLOG_MTP_ENTRY_NAME_MAX + 1U];
|
||||||
} TrainlogMtpEntry;
|
} TrainlogMtpEntry;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,17 @@ typedef enum TrainlogSyncTrigger {
|
||||||
TRAINLOG_SYNC_TRIGGER_DAEMON
|
TRAINLOG_SYNC_TRIGGER_DAEMON
|
||||||
} TrainlogSyncTrigger;
|
} 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 {
|
typedef struct TrainlogSyncDeviceInfo {
|
||||||
bool connected;
|
bool connected;
|
||||||
bool storage_ready;
|
bool storage_ready;
|
||||||
|
|
@ -33,6 +44,7 @@ typedef struct TrainlogSyncDeviceInfo {
|
||||||
typedef struct TrainlogSyncReport {
|
typedef struct TrainlogSyncReport {
|
||||||
bool success;
|
bool success;
|
||||||
bool request_present;
|
bool request_present;
|
||||||
|
TrainlogSyncDirection direction;
|
||||||
|
|
||||||
char sync_id[
|
char sync_id[
|
||||||
TRAINLOG_ID_MAX + 1U
|
TRAINLOG_ID_MAX + 1U
|
||||||
|
|
@ -56,6 +68,9 @@ typedef struct TrainlogSyncReport {
|
||||||
size_t body_imported;
|
size_t body_imported;
|
||||||
size_t body_skipped;
|
size_t body_skipped;
|
||||||
|
|
||||||
|
size_t equipment_definitions_imported;
|
||||||
|
size_t equipment_definitions_skipped;
|
||||||
|
|
||||||
size_t catalog_published;
|
size_t catalog_published;
|
||||||
|
|
||||||
char summary[
|
char summary[
|
||||||
|
|
@ -67,6 +82,59 @@ typedef struct TrainlogSyncReport {
|
||||||
];
|
];
|
||||||
} 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.
|
* @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
|
* When require_request is true, the transaction runs only if Android has
|
||||||
* published a new trainlog-sync-request-v1 request ID.
|
* 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(
|
TrainlogStatus trainlog_sync_run(
|
||||||
TrainlogSyncTrigger trigger,
|
TrainlogSyncTrigger trigger,
|
||||||
bool require_request,
|
bool require_request,
|
||||||
|
TrainlogSyncDirection direction,
|
||||||
TrainlogSyncReport *output
|
TrainlogSyncReport *output
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
51
tui/include/trainlog/sync_history.h
Normal file
51
tui/include/trainlog/sync_history.h
Normal 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
|
||||||
68
tui/include/trainlog/sync_screen_action.h
Normal file
68
tui/include/trainlog/sync_screen_action.h
Normal 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
|
||||||
|
|
@ -43,6 +43,7 @@ trainlog_core_sources = files(
|
||||||
'src/reps.c',
|
'src/reps.c',
|
||||||
'src/measured_max.c',
|
'src/measured_max.c',
|
||||||
'src/sync.c',
|
'src/sync.c',
|
||||||
|
'src/sync_history.c',
|
||||||
)
|
)
|
||||||
trainlog_core_sources += equipment_catalog_generated
|
trainlog_core_sources += equipment_catalog_generated
|
||||||
|
|
||||||
|
|
@ -76,6 +77,7 @@ trainlog_core_dep = declare_dependency(
|
||||||
|
|
||||||
trainlog_tui_sources = files(
|
trainlog_tui_sources = files(
|
||||||
'src/main.c',
|
'src/main.c',
|
||||||
|
'src/sync_screen_action.c',
|
||||||
'src/terminal.c',
|
'src/terminal.c',
|
||||||
'src/theme.c',
|
'src/theme.c',
|
||||||
'src/tui.c',
|
'src/tui.c',
|
||||||
|
|
@ -124,6 +126,14 @@ test_equipment_catalog = executable(
|
||||||
)
|
)
|
||||||
test('equipment_catalog', test_equipment_catalog)
|
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 = executable(
|
||||||
'test_session_detail',
|
'test_session_detail',
|
||||||
'tests/test_session_detail.c',
|
'tests/test_session_detail.c',
|
||||||
|
|
@ -341,6 +351,18 @@ test(
|
||||||
test_schema_v5_migration,
|
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')
|
python3_trainlog_tests = find_program('python3')
|
||||||
|
|
||||||
test(
|
test(
|
||||||
|
|
@ -363,6 +385,46 @@ test(
|
||||||
args: [meson.project_source_root() / 'tests/test_equipment_associations_exchange.py'],
|
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 = executable(
|
||||||
'trainlog-sync-once',
|
'trainlog-sync-once',
|
||||||
'tools/sync_once.c',
|
'tools/sync_once.c',
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,21 @@ struct TrainlogDatabase {
|
||||||
sqlite3 *connection;
|
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(
|
static void set_open_diagnostic(
|
||||||
char *output,
|
char *output,
|
||||||
size_t capacity,
|
size_t capacity,
|
||||||
|
|
@ -28,15 +43,42 @@ static void set_open_diagnostic(
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
const char *message;
|
const char *message;
|
||||||
|
int extended_status = sqlite_status;
|
||||||
|
|
||||||
if (output == NULL || capacity == 0U) {
|
if (output == NULL || capacity == 0U) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
message = connection != NULL
|
if (connection != NULL) {
|
||||||
? sqlite3_errmsg(connection)
|
message = sqlite3_errmsg(connection);
|
||||||
: sqlite3_errstr(sqlite_status);
|
sqlite_status = sqlite3_errcode(connection);
|
||||||
(void)snprintf(output, capacity, "%s: %s", operation, message);
|
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 =
|
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;"
|
"DROP TABLE performed_sets_v6;DROP TABLE continuous_activity_v6;DROP TABLE session_exercises_v6;"
|
||||||
"PRAGMA user_version = 7;COMMIT;PRAGMA foreign_keys = ON;";
|
"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 =
|
static const char *const MIGRATE_V1_TO_V3_SQL =
|
||||||
"BEGIN IMMEDIATE;"
|
"BEGIN IMMEDIATE;"
|
||||||
"ALTER TABLE sessions "
|
"ALTER TABLE sessions "
|
||||||
|
|
@ -576,13 +629,13 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
version >
|
version >
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION
|
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||||
) {
|
) {
|
||||||
|
char message[128];
|
||||||
(void)snprintf(
|
(void)snprintf(
|
||||||
output_diagnostic,
|
message, sizeof(message),
|
||||||
output_diagnostic_capacity,
|
|
||||||
"schema version %d is newer than supported version %d",
|
"schema version %d is newer than supported version %d",
|
||||||
version,
|
version, TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION
|
set_application_diagnostic(
|
||||||
);
|
output_diagnostic, output_diagnostic_capacity, message);
|
||||||
return
|
return
|
||||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||||
}
|
}
|
||||||
|
|
@ -611,6 +664,13 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
SCHEMA_V7_SQL_B
|
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 {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
status =
|
status =
|
||||||
|
|
@ -693,6 +753,13 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
} else if (version == 6) {
|
} else if (version == 6) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} 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
|
return
|
||||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||||
}
|
}
|
||||||
|
|
@ -725,6 +792,9 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = execute_sql(database, MIGRATE_V6_TO_V7_SQL);
|
status = execute_sql(database, MIGRATE_V6_TO_V7_SQL);
|
||||||
}
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = execute_sql(database, MIGRATE_V7_TO_V8_SQL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -734,7 +804,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
set_open_diagnostic(
|
set_open_diagnostic(
|
||||||
output_diagnostic,
|
output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
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,
|
database->connection,
|
||||||
SQLITE_ERROR
|
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)
|
TrainlogStatus trainlog_database_begin(TrainlogDatabase *database)
|
||||||
{
|
{
|
||||||
return execute_sql(database, "BEGIN IMMEDIATE;");
|
return execute_sql(database, "BEGIN IMMEDIATE;");
|
||||||
|
|
@ -1574,15 +1779,17 @@ static TrainlogStatus insert_session_exercise(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input->equipment_id[0] != '\0' &&
|
if (input->equipment_id[0] != '\0' &&
|
||||||
trainlog_equipment_catalog_lookup(input->equipment_id) == NULL) {
|
trainlog_equipment_catalog_lookup(input->equipment_id) == NULL &&
|
||||||
/* INVARIANT: a persisted occurrence only names a manifest identity. */
|
!custom_equipment_exists(database, input->equipment_id)) {
|
||||||
|
/* INVARIANT: a persisted occurrence names a known canonical or custom definition. */
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
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. */
|
* at persistence, while imported V2 identities pass through unchanged. */
|
||||||
if (input->entry_id[0] == '\0') {
|
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));
|
sizeof(generated_entry_id));
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
return status;
|
return status;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ static bool prefix_is_supported(const char *prefix)
|
||||||
return strcmp(prefix, "ex") == 0 ||
|
return strcmp(prefix, "ex") == 0 ||
|
||||||
strcmp(prefix, "se") == 0 ||
|
strcmp(prefix, "se") == 0 ||
|
||||||
strcmp(prefix, "bo") == 0 ||
|
strcmp(prefix, "bo") == 0 ||
|
||||||
strcmp(prefix, "sy") == 0;
|
strcmp(prefix, "sy") == 0 ||
|
||||||
|
strcmp(prefix, "sxe") == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogStatus trainlog_id_generate(
|
TrainlogStatus trainlog_id_generate(
|
||||||
|
|
@ -39,7 +40,7 @@ TrainlogStatus trainlog_id_generate(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!prefix_is_supported(prefix) ||
|
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;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -682,6 +682,16 @@ TrainlogStatus trainlog_mtp_list_folder(
|
||||||
entry->size_bytes =
|
entry->size_bytes =
|
||||||
item->filesize;
|
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 =
|
entry->folder =
|
||||||
item->filetype ==
|
item->filetype ==
|
||||||
LIBMTP_FILETYPE_FOLDER;
|
LIBMTP_FILETYPE_FOLDER;
|
||||||
|
|
|
||||||
514
tui/src/sync.c
514
tui/src/sync.c
|
|
@ -19,6 +19,7 @@
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
#include "trainlog/id.h"
|
#include "trainlog/id.h"
|
||||||
|
#include "trainlog/sync_history.h"
|
||||||
#include "trainlog/timeutil.h"
|
#include "trainlog/timeutil.h"
|
||||||
|
|
||||||
#define SYNC_DEVICE_CAPACITY 8U
|
#define SYNC_DEVICE_CAPACITY 8U
|
||||||
|
|
@ -42,6 +43,12 @@ static const char *const PC_CATALOG_NAME =
|
||||||
static const char *const EQUIPMENT_ASSOCIATIONS_NAME =
|
static const char *const EQUIPMENT_ASSOCIATIONS_NAME =
|
||||||
"trainlog-equipment-associations-v2.json";
|
"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 =
|
static const char *const SYNC_REQUEST_NAME =
|
||||||
"trainlog-sync-request-v1.json";
|
"trainlog-sync-request-v1.json";
|
||||||
|
|
||||||
|
|
@ -60,6 +67,15 @@ static const char *const PC_CATALOG_LOCAL =
|
||||||
static const char *const EQUIPMENT_ASSOCIATIONS_LOCAL =
|
static const char *const EQUIPMENT_ASSOCIATIONS_LOCAL =
|
||||||
"/tmp/trainlog-equipment-associations-v2.json";
|
"/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 =
|
static const char *const SYNC_REQUEST_LOCAL =
|
||||||
"/tmp/trainlog-sync-request-v1.json";
|
"/tmp/trainlog-sync-request-v1.json";
|
||||||
|
|
||||||
|
|
@ -81,6 +97,22 @@ typedef struct SyncSilence {
|
||||||
int null_fd;
|
int null_fd;
|
||||||
} SyncSilence;
|
} 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(
|
static bool ensure_directory(
|
||||||
const char *path
|
const char *path
|
||||||
)
|
)
|
||||||
|
|
@ -673,6 +705,137 @@ static TrainlogStatus sync_find_child(
|
||||||
return TRAINLOG_STATUS_NOT_FOUND;
|
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(
|
static TrainlogStatus sync_find_exchange_folder(
|
||||||
const TrainlogSyncDeviceInfo *device,
|
const TrainlogSyncDeviceInfo *device,
|
||||||
uint32_t *output_folder_id
|
uint32_t *output_folder_id
|
||||||
|
|
@ -774,6 +937,54 @@ static TrainlogStatus sync_receive_named(
|
||||||
return status;
|
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(
|
static TrainlogStatus sync_publish_named(
|
||||||
const TrainlogSyncDeviceInfo *device,
|
const TrainlogSyncDeviceInfo *device,
|
||||||
uint32_t folder_id,
|
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(
|
static bool sync_local_timestamp(
|
||||||
char output[17]
|
char output[17]
|
||||||
)
|
)
|
||||||
|
|
@ -1654,6 +1884,37 @@ static bool sync_json_write_escaped(
|
||||||
) != EOF;
|
) != 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(
|
static bool sync_write_receipt(
|
||||||
const TrainlogSyncReport *report
|
const TrainlogSyncReport *report
|
||||||
)
|
)
|
||||||
|
|
@ -1769,7 +2030,7 @@ static bool sync_write_receipt(
|
||||||
fclose(file) == 0;
|
fclose(file) == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool sync_record_run(
|
bool trainlog_sync_record_local_run(
|
||||||
TrainlogSyncTrigger trigger,
|
TrainlogSyncTrigger trigger,
|
||||||
const TrainlogSyncReport *report
|
const TrainlogSyncReport *report
|
||||||
)
|
)
|
||||||
|
|
@ -1796,9 +2057,45 @@ static bool sync_record_run(
|
||||||
FILE *detail_file;
|
FILE *detail_file;
|
||||||
FILE *history_file;
|
FILE *history_file;
|
||||||
int written;
|
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 (
|
if (
|
||||||
report == NULL ||
|
report == NULL ||
|
||||||
|
direction_code == NULL ||
|
||||||
|
direction_text == NULL ||
|
||||||
|
direction_label == NULL ||
|
||||||
report->sync_id[0] == '\0' ||
|
report->sync_id[0] == '\0' ||
|
||||||
!sync_runs_directory(
|
!sync_runs_directory(
|
||||||
directory,
|
directory,
|
||||||
|
|
@ -1910,6 +2207,21 @@ static bool sync_record_run(
|
||||||
return false;
|
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(
|
(void)fputs(
|
||||||
",\n \"status\":",
|
",\n \"status\":",
|
||||||
json_file
|
json_file
|
||||||
|
|
@ -2003,12 +2315,14 @@ static bool sync_record_run(
|
||||||
detail_file,
|
detail_file,
|
||||||
"SYNC %s\n\n"
|
"SYNC %s\n\n"
|
||||||
"Déclencheur : %s\n"
|
"Déclencheur : %s\n"
|
||||||
|
"Direction : %s\n"
|
||||||
"Début : %s\n"
|
"Début : %s\n"
|
||||||
"État : %s\n",
|
"État : %s\n",
|
||||||
report->sync_id,
|
report->sync_id,
|
||||||
sync_trigger_text(
|
sync_trigger_text(
|
||||||
trigger
|
trigger
|
||||||
),
|
),
|
||||||
|
direction_label,
|
||||||
local_timestamp,
|
local_timestamp,
|
||||||
report->success
|
report->success
|
||||||
? "succès"
|
? "succès"
|
||||||
|
|
@ -2078,25 +2392,38 @@ static bool sync_record_run(
|
||||||
return false;
|
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,
|
history_file,
|
||||||
"%s\t%s\t%d\t%.*s\n",
|
"%s\t%s\t%d\t%s\t",
|
||||||
report->sync_id,
|
report->sync_id,
|
||||||
local_timestamp,
|
local_timestamp,
|
||||||
report->success
|
report->success
|
||||||
? 1
|
? 1
|
||||||
: 0,
|
: 0,
|
||||||
(int)TRAINLOG_SYNC_SUMMARY_MAX,
|
direction_code
|
||||||
report->summary
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
written < 0 ||
|
||||||
|
!sync_history_write_summary(
|
||||||
|
history_file,
|
||||||
|
report->summary
|
||||||
|
) ||
|
||||||
|
fputc('\n', history_file) == EOF
|
||||||
|
) {
|
||||||
|
(void)fclose(history_file);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
fclose(
|
fclose(
|
||||||
history_file
|
history_file
|
||||||
) == 0;
|
) == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void sync_build_summary(
|
void trainlog_sync_build_summary(
|
||||||
TrainlogSyncReport *report
|
TrainlogSyncReport *report
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
|
@ -2105,16 +2432,24 @@ static void sync_build_summary(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (report->success) {
|
if (report->success) {
|
||||||
(void)snprintf(
|
if (report->direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
|
||||||
report->summary,
|
(void)snprintf(report->summary, sizeof(report->summary),
|
||||||
sizeof(report->summary),
|
"Android→PC +%zu séance(s), +%zu exercice(s), +%zu mesure(s), +%zu équipement(s)",
|
||||||
"Android→PC +%zu séance(s), +%zu exercice(s), +%zu mesure(s) · PC→Android catalogue %zu exercice(s)",
|
report->sessions_imported,
|
||||||
report->sessions_imported,
|
report->exercises_imported,
|
||||||
report->exercises_imported +
|
report->body_imported, report->equipment_definitions_imported);
|
||||||
report->exercises_reconciled,
|
} else if (report->direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
|
||||||
report->body_imported,
|
(void)snprintf(report->summary, sizeof(report->summary),
|
||||||
report->catalog_published
|
"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 {
|
} else {
|
||||||
(void)snprintf(
|
(void)snprintf(
|
||||||
report->summary,
|
report->summary,
|
||||||
|
|
@ -2209,6 +2544,7 @@ static void sync_parse_import_report(
|
||||||
TrainlogStatus trainlog_sync_run(
|
TrainlogStatus trainlog_sync_run(
|
||||||
TrainlogSyncTrigger trigger,
|
TrainlogSyncTrigger trigger,
|
||||||
bool require_request,
|
bool require_request,
|
||||||
|
TrainlogSyncDirection direction,
|
||||||
TrainlogSyncReport *output
|
TrainlogSyncReport *output
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
|
@ -2236,8 +2572,11 @@ TrainlogStatus trainlog_sync_run(
|
||||||
bool silence_active = false;
|
bool silence_active = false;
|
||||||
bool run_started = false;
|
bool run_started = false;
|
||||||
bool receipt_published = 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
|
return
|
||||||
TRAINLOG_STATUS_INVALID_ARGUMENT;
|
TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
@ -2247,6 +2586,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
0,
|
0,
|
||||||
sizeof(*output)
|
sizeof(*output)
|
||||||
);
|
);
|
||||||
|
output->direction = direction;
|
||||||
|
|
||||||
lock_fd =
|
lock_fd =
|
||||||
sync_lock_open(
|
sync_lock_open(
|
||||||
|
|
@ -2448,14 +2788,54 @@ TrainlogStatus trainlog_sync_run(
|
||||||
goto finalize;
|
goto finalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
status =
|
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
|
||||||
sync_receive_named(
|
goto outbound;
|
||||||
&device,
|
}
|
||||||
folder_id,
|
|
||||||
MOBILE_EXPORT_NAME,
|
/* Definitions must reconcile before either v2 reference artifact. A
|
||||||
MOBILE_EXPORT_LOCAL,
|
* missing file is accepted only for historic snapshots with no custom ID. */
|
||||||
&ignored_size
|
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 (
|
if (
|
||||||
status !=
|
status !=
|
||||||
|
|
@ -2525,10 +2905,20 @@ TrainlogStatus trainlog_sync_run(
|
||||||
output
|
output
|
||||||
);
|
);
|
||||||
|
|
||||||
/* V1 exports carry no equipment signal. A missing companion therefore
|
/* V1 exports carry no equipment signal. A V2 companion beside a historic
|
||||||
* preserves existing desktop associations rather than clearing them. */
|
* V1 snapshot belongs to another generation and must not be applied. Its
|
||||||
status = sync_receive_named(&device, folder_id, EQUIPMENT_ASSOCIATIONS_NAME,
|
* absence therefore preserves existing associations rather than clearing
|
||||||
EQUIPMENT_ASSOCIATIONS_LOCAL, &ignored_size);
|
* 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) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
char useful[
|
char useful[
|
||||||
TRAINLOG_SYNC_ERROR_MAX + 1U
|
TRAINLOG_SYNC_ERROR_MAX + 1U
|
||||||
|
|
@ -2557,6 +2947,35 @@ TrainlogStatus trainlog_sync_run(
|
||||||
goto finalize;
|
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 =
|
status =
|
||||||
sync_run_python_tool(
|
sync_run_python_tool(
|
||||||
"export_pc_catalog.py",
|
"export_pc_catalog.py",
|
||||||
|
|
@ -2605,6 +3024,14 @@ TrainlogStatus trainlog_sync_run(
|
||||||
"exercises"
|
"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,
|
status = sync_run_python_tool("export_pc_mobile.py", PC_MOBILE_EXPORT_LOCAL,
|
||||||
database_path,
|
database_path,
|
||||||
PC_CATALOG_RESULT, tool_output, sizeof(tool_output));
|
PC_CATALOG_RESULT, tool_output, sizeof(tool_output));
|
||||||
|
|
@ -2620,29 +3047,6 @@ TrainlogStatus trainlog_sync_run(
|
||||||
goto finalize;
|
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",
|
status = sync_run_python_tool("export_equipment_associations.py",
|
||||||
EQUIPMENT_ASSOCIATIONS_LOCAL,
|
EQUIPMENT_ASSOCIATIONS_LOCAL,
|
||||||
database_path,
|
database_path,
|
||||||
|
|
@ -2670,7 +3074,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
TRAINLOG_STATUS_OK;
|
TRAINLOG_STATUS_OK;
|
||||||
|
|
||||||
finalize:
|
finalize:
|
||||||
sync_build_summary(
|
trainlog_sync_build_summary(
|
||||||
output
|
output
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -2711,13 +3115,13 @@ finalize:
|
||||||
final_status =
|
final_status =
|
||||||
TRAINLOG_STATUS_SYSTEM_ERROR;
|
TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
|
||||||
sync_build_summary(
|
trainlog_sync_build_summary(
|
||||||
output
|
output
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(void)sync_record_run(
|
(void)trainlog_sync_record_local_run(
|
||||||
trigger,
|
trigger,
|
||||||
output
|
output
|
||||||
);
|
);
|
||||||
|
|
|
||||||
181
tui/src/sync_history.c
Normal file
181
tui/src/sync_history.c
Normal 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;
|
||||||
|
}
|
||||||
167
tui/src/sync_screen_action.c
Normal file
167
tui/src/sync_screen_action.c
Normal 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";
|
||||||
|
}
|
||||||
767
tui/src/tui.c
767
tui/src/tui.c
File diff suppressed because it is too large
Load diff
121
tui/tests/test_custom_equipment.c
Normal file
121
tui/tests/test_custom_equipment.c
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,8 @@ static bool test_generated_ids(void)
|
||||||
char first[TRAINLOG_GENERATED_ID_CAPACITY];
|
char first[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||||
char second[TRAINLOG_GENERATED_ID_CAPACITY];
|
char second[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||||
char sync_id[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(
|
CHECK(
|
||||||
trainlog_id_generate("ex", first, sizeof(first)) ==
|
trainlog_id_generate("ex", first, sizeof(first)) ==
|
||||||
|
|
@ -85,9 +87,18 @@ static bool test_generated_ids(void)
|
||||||
TRAINLOG_STATUS_OK
|
TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
CHECK(strncmp(first, "ex_", 3U) == 0);
|
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(strcmp(first, second) != 0);
|
||||||
CHECK(first[3U + 14U] == '4');
|
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 */
|
/* TRAINLOG_SYNC_ID_PREFIX_TEST */
|
||||||
CHECK(
|
CHECK(
|
||||||
|
|
@ -111,6 +122,17 @@ static bool test_generated_ids(void)
|
||||||
'4'
|
'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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +153,8 @@ static bool test_session_insert(void)
|
||||||
TrainlogSetInput sets[2];
|
TrainlogSetInput sets[2];
|
||||||
TrainlogSessionExerciseInput exercise;
|
TrainlogSessionExerciseInput exercise;
|
||||||
TrainlogSessionInput session;
|
TrainlogSessionInput session;
|
||||||
|
TrainlogSessionSummary persisted_session;
|
||||||
|
TrainlogPersistedExerciseDetail persisted_exercises[1];
|
||||||
size_t count = 0U;
|
size_t count = 0U;
|
||||||
|
|
||||||
CHECK(
|
CHECK(
|
||||||
|
|
@ -196,6 +220,19 @@ static bool test_session_insert(void)
|
||||||
TRAINLOG_STATUS_OK
|
TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
CHECK(count == 1U);
|
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);
|
trainlog_database_close(database);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* @file test_schema_v5_migration.c
|
* @file test_schema_v5_migration.c
|
||||||
* @brief Direct v4 -> v7 migration regression test.
|
* @brief Direct v4 -> current migration regression test.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
|
@ -27,10 +27,10 @@
|
||||||
} \
|
} \
|
||||||
} while (0)
|
} while (0)
|
||||||
|
|
||||||
static bool test_v4_to_v7_preserves_session(void)
|
static bool test_v4_to_current_preserves_session(void)
|
||||||
{
|
{
|
||||||
char path[] =
|
char path[] =
|
||||||
"/tmp/trainlog-schema-v4-v5-XXXXXX";
|
"/tmp/trainlog-schema-v4-current-XXXXXX";
|
||||||
|
|
||||||
static const char *const V4_SQL =
|
static const char *const V4_SQL =
|
||||||
"CREATE TABLE exercises ("
|
"CREATE TABLE exercises ("
|
||||||
|
|
@ -187,9 +187,7 @@ static bool test_v4_to_v7_preserves_session(void)
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION
|
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK(version == 8);
|
||||||
version == 7
|
|
||||||
);
|
|
||||||
|
|
||||||
CHECK(
|
CHECK(
|
||||||
trainlog_database_get_session_details(
|
trainlog_database_get_session_details(
|
||||||
|
|
@ -228,7 +226,7 @@ static bool test_v4_to_v7_preserves_session(void)
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
CHECK(
|
CHECK(
|
||||||
test_v4_to_v7_preserves_session()
|
test_v4_to_current_preserves_session()
|
||||||
);
|
);
|
||||||
|
|
||||||
(void)printf(
|
(void)printf(
|
||||||
|
|
|
||||||
198
tui/tests/test_schema_v7_migration.c
Normal file
198
tui/tests/test_schema_v7_migration.c
Normal 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;
|
||||||
|
}
|
||||||
97
tui/tests/test_sync_direction.c
Normal file
97
tui/tests/test_sync_direction.c
Normal 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;
|
||||||
|
}
|
||||||
172
tui/tests/test_sync_history.c
Normal file
172
tui/tests/test_sync_history.c
Normal 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;
|
||||||
|
}
|
||||||
113
tui/tests/test_sync_screen_action.c
Normal file
113
tui/tests/test_sync_screen_action.c
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -128,6 +128,7 @@ int main(
|
||||||
trainlog_sync_run(
|
trainlog_sync_run(
|
||||||
trigger,
|
trigger,
|
||||||
request_only,
|
request_only,
|
||||||
|
TRAINLOG_SYNC_BIDIRECTIONAL,
|
||||||
&report
|
&report
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue