feat(trainlog): complete stats knowledge and sync hardening

This commit is contained in:
fy59 2026-09-13 20:56:16 +02:00
parent 2dec03e7b2
commit 32fae197ee
76 changed files with 3701 additions and 1031 deletions

View file

@ -1,5 +1,28 @@
# Changelog
- Fixed Android-triggered synchronization so the complete current Android→PC
companion bundle is published before the request signal, including Training
Feedback V2 and Exercise Profile State V1. Desktop orchestration now refuses
stale temporary profile-state reuse and reports a specific missing causal
companion when a modern same-ID profile mismatch requires it.
- Made Android exchange-file reuse exact and directory-scoped: pending,
trashed, unrelated, and conflict-numbered MediaStore rows no longer enter
canonical selection, while genuine duplicate exact names fail closed.
## Unreleased — Desktop historical tracking snapshot
- Added desktop schema v17 / Android schema v16 causal current-profile state,
bounded 32-revision transitive ancestry, deterministic bidirectional
convergence, and explicit concurrent-edit conflicts; legacy feedback without
`ended_at` now shows approximate H+ from
valid `started_at` instead of a misleading during-session label.
- Added desktop schema v16 with an atomic v15 backfill of occurrence-owned
`session_exercises.tracking_mode`, preserving identities and all child facts.
- Switched historical readers and V3 exchange to the occurrence snapshot while
retaining catalogue profile modes as future-entry defaults.
- Enabled explicitly confirmed future-only tracking/profile edits in the TUI
without rewriting completed sessions.
## Unreleased — Training feedback UX and safety
- Added additive schema v15 immutable feedback revisions and Training Feedback

View file

@ -0,0 +1,162 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import android.util.Log
import java.io.OutputStream
internal const val SYNC_PREFERENCES_NAME = "trainlog-sync"
internal const val SYNC_TREE_URI_KEY = "trainlog_tree_uri"
internal const val EXCHANGE_MEDIASTORE_LOG_TAG = "TrainlogMediaStore"
internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) {
"trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES"
"trainlog-sync-request-v1.json" -> "SYNC_REQUEST"
"trainlog-mobile-export-v3.json" -> "MOBILE_EXPORT"
"trainlog-mobile-equipment-definitions-v1.json" -> "EQUIPMENT_DEFINITIONS"
"trainlog-equipment-associations-v2.json" -> "EQUIPMENT_ASSOCIATIONS"
"trainlog-exercise-aliases-v1.json" -> "EXERCISE_ALIASES"
"trainlog-training-feedback-v2.json" -> "TRAINING_FEEDBACK"
"trainlog-exercise-profile-state-v1.json" -> "EXERCISE_PROFILE_STATE"
else -> displayName
}
/* WHY: document providers may throw the same message from different SAF
* operations. CONTRACT: traces contain artifact identity, operation phase,
* URI and exception metadata only; user JSON is never logged. */
internal fun logExchangeMediaStore(displayName: String, phase: String, detail: String? = null) {
val suffix = detail?.let { " $it" } ?: ""
Log.d(EXCHANGE_MEDIASTORE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} $phase$suffix")
}
internal fun logExchangeMediaStoreError(displayName: String, phase: String, error: Throwable) {
Log.e(
EXCHANGE_MEDIASTORE_LOG_TAG,
"${exchangeArtifactIdentity(displayName)} error phase=$phase " +
"exception=${error.javaClass.name}:${error.message}",
)
}
internal data class ExchangeSafDocument(
val uri: Uri,
val displayName: String,
)
internal interface ExchangeSafDirectory {
fun listDirectChildren(): List<ExchangeSafDocument>
fun createJson(displayName: String): ExchangeSafDocument?
fun openForRewrite(document: ExchangeSafDocument): OutputStream?
}
private class DocumentFileExchangeSafDirectory(
private val context: Context,
private val directory: DocumentFile,
) : ExchangeSafDirectory {
override fun listDirectChildren(): List<ExchangeSafDocument> =
directory.listFiles().mapNotNull { child ->
child.name?.let { ExchangeSafDocument(child.uri, it) }
}
override fun createJson(displayName: String): ExchangeSafDocument? =
directory.createFile("application/json", displayName)?.let { created ->
val actualName = created.name
require(actualName == displayName) {
"Le fournisseur SAF a créé $actualName au lieu de $displayName."
}
ExchangeSafDocument(created.uri, displayName)
}
override fun openForRewrite(document: ExchangeSafDocument): OutputStream? =
context.contentResolver.openOutputStream(document.uri, "wt")
}
internal fun persistedExchangeSafDirectory(context: Context): ExchangeSafDirectory? {
val appContext = context.applicationContext
val treeUri = appContext.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE)
.getString(SYNC_TREE_URI_KEY, null)?.let(Uri::parse) ?: return null
val directory = DocumentFile.fromTreeUri(appContext, treeUri) ?: return null
if (!directory.isDirectory || !directory.canWrite()) return null
return DocumentFileExchangeSafDirectory(appContext, directory)
}
internal class ExchangeSafPublisher(
private val directoryProvider: () -> ExchangeSafDirectory?,
) {
fun snapshot(): ExchangeSafSnapshotResult {
val directory = directoryProvider()
?: return ExchangeSafSnapshotResult.Error(
"Dossier Trainlog non autorisé ou inaccessible.",
)
val started = android.os.SystemClock.elapsedRealtime()
return try {
logExchangeMediaStore("SAF_TREE", "snapshot.begin", "thread=${Thread.currentThread().name}")
val children = directory.listDirectChildren()
val byName = children.groupByTo(linkedMapOf()) { it.displayName }
val elapsed = android.os.SystemClock.elapsedRealtime() - started
logExchangeMediaStore(
"SAF_TREE",
"snapshot.success",
"children=${children.size} duration_ms=$elapsed thread=${Thread.currentThread().name}",
)
ExchangeSafSnapshotResult.Ready(ExchangeSafSnapshot(directory, byName))
} catch (error: Exception) {
logExchangeMediaStoreError("SAF_TREE", "snapshot", error)
ExchangeSafSnapshotResult.Error(error.message ?: "Lecture du dossier SAF impossible.")
}
}
}
internal sealed interface ExchangeSafSnapshotResult {
data class Ready(val snapshot: ExchangeSafSnapshot) : ExchangeSafSnapshotResult
data class Error(val message: String) : ExchangeSafSnapshotResult
}
internal class ExchangeSafSnapshot(
private val directory: ExchangeSafDirectory,
private val byName: MutableMap<String, MutableList<ExchangeSafDocument>>,
) {
fun writeJson(displayName: String, json: String): String? {
var phase = "saf.resolve"
return try {
logExchangeMediaStore(displayName, "saf.resolve.begin")
val exact = byName[displayName].orEmpty()
require(exact.size <= 1) {
"Plusieurs documents SAF canoniques exacts pour $displayName (${exact.size})."
}
val existing = exact.singleOrNull()
val document = if (existing != null) {
logExchangeMediaStore(displayName, "saf.resolve.existing", "uri=${existing.uri}")
existing
} else {
phase = "saf.create"
logExchangeMediaStore(displayName, "saf.resolve.absent")
logExchangeMediaStore(displayName, "saf.create.begin", "mime=application/json")
directory.createJson(displayName)?.also { created ->
/* INVARIANT: creation mutates this transaction-local index
* so later artifacts never rescan the live directory. */
byName.getOrPut(displayName) { mutableListOf() }.add(created)
logExchangeMediaStore(displayName, "saf.create.success", "uri=${created.uri}")
} ?: return "Création SAF de $displayName impossible."
}
phase = "saf.openOutputStream"
logExchangeMediaStore(
displayName,
"saf.write.begin",
"uri=${document.uri} mode=wt thread=${Thread.currentThread().name}",
)
val stream = directory.openForRewrite(document)
?: return "Écriture SAF de $displayName impossible."
phase = "saf.stream.write"
stream.use {
it.write(json.toByteArray(Charsets.UTF_8))
it.flush()
}
logExchangeMediaStore(displayName, "saf.write.success", "uri=${document.uri}")
null
} catch (error: Exception) {
logExchangeMediaStoreError(displayName, phase, error)
error.message ?: "Publication SAF de $displayName impossible."
}
}
}

View file

@ -52,7 +52,7 @@ class SyncCatalogInbox(
private val preferences =
appContext.getSharedPreferences(
"trainlog-sync",
SYNC_PREFERENCES_NAME,
Context.MODE_PRIVATE,
)
@ -74,7 +74,7 @@ class SyncCatalogInbox(
preferences
.edit()
.putString(
KEY_TREE_URI,
SYNC_TREE_URI_KEY,
uri.toString(),
)
.apply()
@ -133,6 +133,19 @@ class SyncCatalogInbox(
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
val aliasesError = importExerciseAliases(directory)
if (aliasesError != null) return CatalogInboxResult.Error(aliasesError)
val profileFile = directory.findFile("trainlog-exercise-profile-state-v1.json")
val profileJson = profileFile?.let { candidate ->
appContext.contentResolver.openInputStream(candidate.uri)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
}
if (profileFile != null && profileJson == null)
return CatalogInboxResult.Error("Lecture profile-state impossible.")
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson,allowPending=true)) {
is ExerciseProfileStateImportResult.Applied -> Unit
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
is ExerciseProfileStateImportResult.Conflict -> return CatalogInboxResult.Error("Conflit de profil : ${profile.exerciseId}")
ExerciseProfileStateImportResult.DatabaseError -> return CatalogInboxResult.Error("Erreur base locale profils.")
}
when (
val result =
@ -141,7 +154,15 @@ class SyncCatalogInbox(
json
)
) {
is PcCatalogImportResult.Applied ->
is PcCatalogImportResult.Applied -> {
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson)) {
is ExerciseProfileStateImportResult.Applied -> Unit
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
is ExerciseProfileStateImportResult.Conflict -> return CatalogInboxResult.Error("Conflit de profil : ${profile.exerciseId}")
ExerciseProfileStateImportResult.DatabaseError -> return CatalogInboxResult.Error("Erreur base locale profils.")
}
/* Catalogue identities now exist and the strict second pass
* has either installed or verified their causal state. */
when (val sessions = importPcSessions(directory)) {
null -> when (val equipment = importPcEquipmentAssociations(directory)) {
null -> when (val bodyZones = importPcBodyZones(directory)) {
@ -159,6 +180,7 @@ class SyncCatalogInbox(
}
else -> CatalogInboxResult.Error(sessions)
}
}
is PcCatalogImportResult.Invalid ->
CatalogInboxResult.Error(
@ -399,15 +421,11 @@ fun readSyncReceipt(
private fun savedTreeUri(): Uri? =
preferences
.getString(
KEY_TREE_URI,
SYNC_TREE_URI_KEY,
null,
)
?.let {
Uri.parse(it)
}
private companion object {
const val KEY_TREE_URI =
"trainlog_tree_uri"
}
}

View file

@ -1,12 +1,9 @@
package com.labfytools.trainlog.data
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
sealed interface SyncExportResult {
data class Exported(
@ -22,16 +19,34 @@ sealed interface SyncExportResult {
) : SyncExportResult
}
class SyncExporter(
context: Context,
class SyncExporter private constructor(
private val repository:
TrainlogRepository,
private val safPublisher: ExchangeSafPublisher,
) {
private val appContext =
context.applicationContext
constructor(context: Context, repository: TrainlogRepository) : this(
repository,
ExchangeSafPublisher { persistedExchangeSafDirectory(context) },
)
fun exportMobileBundle():
SyncExportResult {
internal constructor(
context: Context,
repository: TrainlogRepository,
directoryProvider: () -> ExchangeSafDirectory?,
) : this(repository, ExchangeSafPublisher(directoryProvider))
/* CONTRACT: repository snapshot construction and every SAF operation are
* blocking I/O work and must never execute on the Compose/main thread. */
suspend fun exportMobileBundle(): SyncExportResult = withContext(Dispatchers.IO) {
when (val opened = openSafSnapshot()) {
is ExchangeSafSnapshotResult.Ready -> exportMobileBundle(opened.snapshot)
is ExchangeSafSnapshotResult.Error -> SyncExportResult.Error(opened.message)
}
}
internal fun openSafSnapshot(): ExchangeSafSnapshotResult = safPublisher.snapshot()
internal fun exportMobileBundle(snapshot: ExchangeSafSnapshot): SyncExportResult {
if (
Build.VERSION.SDK_INT <
Build.VERSION_CODES.Q
@ -39,8 +54,8 @@ class SyncExporter(
return SyncExportResult.Unsupported
}
/* CONTRACT: capture all four artifacts before publishing any of
* them. The files are separate for compatibility, but a user edit
/* CONTRACT: capture every artifact before publishing any of them.
* The files are separate for compatibility, but a user edit
* must not make a newly-written V2 reference a definition assembled
* from a different logical export state. */
val definitionsJson: String
@ -49,6 +64,7 @@ class SyncExporter(
val bodyZonesJson: String
val aliasesJson: String
val feedbackJson: String
val profileStateJson: String
try {
definitionsJson = repository.buildEquipmentDefinitionsJson()
/* V3 is the authoritative mobile session exchange. V1/V2 remain
@ -58,362 +74,54 @@ class SyncExporter(
bodyZonesJson = repository.buildExerciseBodyZonesJson()
aliasesJson = repository.buildExerciseAliasesJson()
feedbackJson = repository.buildTrainingFeedbackJson()
profileStateJson = repository.buildExerciseProfileStateJson()
} catch (error: Exception) {
return SyncExportResult.Error(
error.message ?: "Préparation de l'export impossible.",
)
}
val bytes = mobileJson.toByteArray(Charsets.UTF_8)
val artifacts = listOf(
"trainlog-mobile-equipment-definitions-v1.json" to definitionsJson,
"trainlog-mobile-export-v3.json" to mobileJson,
"trainlog-exercise-body-zones-v1.json" to bodyZonesJson,
"trainlog-exercise-aliases-v1.json" to aliasesJson,
"trainlog-training-feedback-v2.json" to feedbackJson,
"trainlog-exercise-profile-state-v1.json" to profileStateJson,
)
/* CONTRACT: the persisted user-authorized SAF tree is the sole
* outbound directory authority. MediaStore ownership must neither hide
* a canonical document nor cause creation of a numbered conflict. */
artifacts.forEach { (displayName, json) ->
snapshot.writeJson(displayName, json)?.let { error ->
return SyncExportResult.Error(error)
}
}
val resolver =
appContext.contentResolver
val collection =
MediaStore.Downloads
.getContentUri(
MediaStore
.VOLUME_EXTERNAL_PRIMARY
)
val relativePath =
Environment.DIRECTORY_DOWNLOADS +
"/Trainlog/"
val displayName =
"trainlog-mobile-export-v3.json"
/* Definitions are visible before any session file which may reference a
* custom ID; a failed definition write aborts the bundle. */
val definitionsError = writeEquipmentDefinitions(definitionsJson)
if (definitionsError != null) return SyncExportResult.Error(definitionsError)
val existing =
findExisting(
collection,
displayName,
relativePath
)
val targetUri: Uri
val created: Boolean
if (existing != null) {
targetUri = existing
created = false
} else {
val values =
ContentValues().apply {
put(
MediaStore
.MediaColumns
.DISPLAY_NAME,
displayName
)
put(
MediaStore
.MediaColumns
.MIME_TYPE,
"application/json"
)
put(
MediaStore
.MediaColumns
.RELATIVE_PATH,
relativePath
)
put(
MediaStore
.MediaColumns
.IS_PENDING,
1
)
}
val inserted =
resolver.insert(
collection,
values
)
if (inserted == null) {
/* CONTRACT: publication, not JSON construction, establishes the
* common sync ancestor. Applying the exact local snapshot can only
* record equal baselines; the strict reconciler never unions zones. */
when (val acknowledgement = repository.applyExerciseBodyZonesJson(bodyZonesJson)) {
is ExerciseBodyZoneImportResult.Applied -> Unit
is ExerciseBodyZoneImportResult.Conflict ->
return SyncExportResult.Error(
"Création du fichier impossible."
"Conflit pendant l'enregistrement de la baseline zones : " +
acknowledgement.exerciseId,
)
}
targetUri = inserted
created = true
}
try {
val stream =
resolver.openOutputStream(
targetUri,
"wt"
)
if (stream == null) {
if (created) {
resolver.delete(
targetUri,
null,
null
)
}
is ExerciseBodyZoneImportResult.Invalid ->
return SyncExportResult.Error(acknowledgement.message)
ExerciseBodyZoneImportResult.DatabaseError ->
return SyncExportResult.Error(
"Flux d'écriture indisponible."
"Enregistrement de la baseline zones impossible.",
)
}
stream.use {
it.write(bytes)
it.flush()
}
if (created) {
val finished =
ContentValues().apply {
put(
MediaStore
.MediaColumns
.IS_PENDING,
0
)
}
resolver.update(
targetUri,
finished,
null,
null
)
}
val bodyZonesError = writeBodyZones(bodyZonesJson)
if (bodyZonesError != null) return SyncExportResult.Error(bodyZonesError)
val aliasesError = writeJsonCompanion(
"trainlog-exercise-aliases-v1.json",
aliasesJson,
"alias exercice",
)
if (aliasesError != null) return SyncExportResult.Error(aliasesError)
val feedbackError = writeJsonCompanion(
"trainlog-training-feedback-v2.json", feedbackJson, "ressentis d’entraînement")
if (feedbackError != null) return SyncExportResult.Error(feedbackError)
/* CONTRACT: publication, not JSON construction, establishes the
* common sync ancestor. Applying the exact local snapshot can only
* record equal baselines; the strict reconciler never unions zones. */
when (val acknowledgement = repository.applyExerciseBodyZonesJson(bodyZonesJson)) {
is ExerciseBodyZoneImportResult.Applied -> Unit
is ExerciseBodyZoneImportResult.Conflict ->
return SyncExportResult.Error(
"Conflit pendant l'enregistrement de la baseline zones : " +
acknowledgement.exerciseId,
)
is ExerciseBodyZoneImportResult.Invalid ->
return SyncExportResult.Error(acknowledgement.message)
ExerciseBodyZoneImportResult.DatabaseError ->
return SyncExportResult.Error(
"Enregistrement de la baseline zones impossible.",
)
}
val companionError = writeEquipmentAssociations(associationsJson)
if (companionError != null) {
return SyncExportResult.Error(companionError)
}
return SyncExportResult.Exported(
displayPath =
"Download/Trainlog/" +
displayName,
bytes =
bytes.size,
)
} catch (
error: Exception
) {
if (created) {
resolver.delete(
targetUri,
null,
null
)
}
return SyncExportResult.Error(
error.message
?: "Erreur d'export."
)
}
}
/** Publish the companion separately so frozen mobile-export-v1 stays byte-compatible. */
private fun writeEquipmentAssociations(json: String): String? {
val resolver = appContext.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
val name = "trainlog-equipment-associations-v2.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 de l'extension équipement impossible."
return try {
resolver.openOutputStream(uri, "wt")?.use {
it.write(json.toByteArray(Charsets.UTF_8))
it.flush()
} ?: return "Écriture de l'extension é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 extension équipement impossible."
}
}
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 writeJsonCompanion(name: String, json: String, label: String): String? {
val resolver = appContext.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
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 du compagnon $label impossible."
return try {
resolver.openOutputStream(uri, "wt")?.use {
it.write(json.toByteArray(Charsets.UTF_8)); it.flush()
} ?: return "Écriture du compagnon $label 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 du compagnon $label impossible."
}
}
/** One directional-neutral companion is used by both peers. */
private fun writeBodyZones(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-exercise-body-zones-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 zones corporelles impossible."
return try {
resolver.openOutputStream(uri, "wt")?.use {
it.write(json.toByteArray(Charsets.UTF_8)); it.flush()
} ?: return "Écriture des zones corporelles 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 des zones corporelles impossible."
}
}
private fun findExisting(
collection: Uri,
displayName: String,
relativePath: String,
): Uri? {
val projection =
arrayOf(
MediaStore
.MediaColumns
._ID
)
val selection =
(
MediaStore
.MediaColumns
.DISPLAY_NAME +
" = ? AND " +
MediaStore
.MediaColumns
.RELATIVE_PATH +
" = ?"
)
val cursor =
appContext
.contentResolver
.query(
collection,
projection,
selection,
arrayOf(
displayName,
relativePath,
),
null,
)
?: return null
try {
if (!cursor.moveToFirst()) {
return null
}
val itemId =
cursor.getLong(0)
return ContentUris.withAppendedId(
collection,
itemId
)
} finally {
cursor.close()
}
snapshot.writeJson(
"trainlog-equipment-associations-v2.json",
associationsJson,
)?.let { return SyncExportResult.Error(it) }
return SyncExportResult.Exported(
displayPath = "Download/Trainlog/trainlog-mobile-export-v3.json",
bytes = mobileJson.toByteArray(Charsets.UTF_8).size,
)
}
}

View file

@ -1,15 +1,12 @@
package com.labfytools.trainlog.data
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import org.json.JSONObject
import java.time.OffsetDateTime
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
sealed interface SyncRequestResult {
data class Requested(
@ -24,14 +21,25 @@ sealed interface SyncRequestResult {
) : SyncRequestResult
}
class SyncRequestOutbox(
context: Context,
class SyncRequestOutbox private constructor(
private val safPublisher: ExchangeSafPublisher,
) {
private val appContext =
context.applicationContext
constructor(context: Context) : this(
ExchangeSafPublisher { persistedExchangeSafDirectory(context) },
)
fun requestSync():
SyncRequestResult {
internal constructor(directoryProvider: () -> ExchangeSafDirectory?) : this(
ExchangeSafPublisher(directoryProvider),
)
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
when (val opened = safPublisher.snapshot()) {
is ExchangeSafSnapshotResult.Ready -> requestSync(opened.snapshot)
is ExchangeSafSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
}
}
internal fun requestSync(snapshot: ExchangeSafSnapshot): SyncRequestResult {
if (
Build.VERSION.SDK_INT <
Build.VERSION_CODES.Q
@ -65,187 +73,13 @@ class SyncRequestOutbox(
)
.toString()
val bytes =
payload.toByteArray(
Charsets.UTF_8
)
val resolver =
appContext.contentResolver
val collection =
MediaStore.Downloads
.getContentUri(
MediaStore
.VOLUME_EXTERNAL_PRIMARY
)
val relativePath =
Environment
.DIRECTORY_DOWNLOADS +
"/Trainlog/"
val displayName =
"trainlog-sync-request-v1.json"
val existing =
findExisting(
collection,
displayName,
relativePath,
)
val targetUri: Uri
val created: Boolean
if (existing != null) {
targetUri = existing
created = false
val error = snapshot.writeJson(displayName, payload)
return if (error == null) {
SyncRequestResult.Requested(requestId)
} else {
val values =
ContentValues().apply {
put(
MediaStore
.MediaColumns
.DISPLAY_NAME,
displayName
)
put(
MediaStore
.MediaColumns
.MIME_TYPE,
"application/json"
)
put(
MediaStore
.MediaColumns
.RELATIVE_PATH,
relativePath
)
put(
MediaStore
.MediaColumns
.IS_PENDING,
1
)
}
val inserted =
resolver.insert(
collection,
values
)
?: return SyncRequestResult.Error(
"Création de la demande impossible."
)
targetUri = inserted
created = true
}
return try {
val stream =
resolver.openOutputStream(
targetUri,
"wt"
)
?: return SyncRequestResult.Error(
"Écriture de la demande impossible."
)
stream.use {
it.write(bytes)
it.flush()
}
if (created) {
val finished =
ContentValues().apply {
put(
MediaStore
.MediaColumns
.IS_PENDING,
0
)
}
resolver.update(
targetUri,
finished,
null,
null
)
}
SyncRequestResult.Requested(
requestId
)
} catch (
error: Exception
) {
if (created) {
resolver.delete(
targetUri,
null,
null
)
}
SyncRequestResult.Error(
error.message
?: "Demande de synchronisation impossible."
)
}
}
private fun findExisting(
collection: Uri,
displayName: String,
relativePath: String,
): Uri? {
val cursor =
appContext
.contentResolver
.query(
collection,
arrayOf(
MediaStore
.MediaColumns
._ID
),
(
MediaStore
.MediaColumns
.DISPLAY_NAME +
" = ? AND " +
MediaStore
.MediaColumns
.RELATIVE_PATH +
" = ?"
),
arrayOf(
displayName,
relativePath,
),
null,
)
?: return null
try {
if (!cursor.moveToFirst()) {
return null
}
return ContentUris
.withAppendedId(
collection,
cursor.getLong(0)
)
} finally {
cursor.close()
SyncRequestResult.Error(error)
}
}
}

View file

@ -35,12 +35,56 @@ import org.json.JSONArray
import org.json.JSONObject
import java.text.Normalizer
import java.io.StringReader
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.temporal.WeekFields
import java.util.Locale
import java.util.UUID
private val PROFILE_REVISION_NAMESPACE = UUID.fromString("4f4c8ea6-18f6-5e48-9d65-a54a24889149")
private val PROFILE_REVISION_PATTERN = Regex("^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
private val PROTOTYPE_PROFILE_REVISION_PATTERN = Regex("^pr1\\|(sets|continuous)\\|(reps|duration)\\|([0-9]+)$")
private const val PROFILE_HISTORY_MAX = 32
/** Fixed-width UUIDv5 identity over the direct causal parent and resulting profile. */
internal fun exerciseProfileRevision(parent: String, recording: String, tracking: String, fields: Int): String {
val namespace = ByteBuffer.allocate(16).putLong(PROFILE_REVISION_NAMESPACE.mostSignificantBits)
.putLong(PROFILE_REVISION_NAMESPACE.leastSignificantBits).array()
val name = "$parent\n$recording\n$tracking\n$fields".toByteArray(StandardCharsets.UTF_8)
val digest = MessageDigest.getInstance("SHA-1").digest(namespace + name)
digest[6] = ((digest[6].toInt() and 0x0f) or 0x50).toByte()
digest[8] = ((digest[8].toInt() and 0x3f) or 0x80).toByte()
val bytes = ByteBuffer.wrap(digest)
return "pr2_" + UUID(bytes.long, bytes.long).toString()
}
private fun advanceExerciseProfileRevision(
db: SQLiteDatabase, rowId: Long, recording: String, tracking: String, fields: Int,
): Boolean {
val state = db.rawQuery(
"SELECT revision_id FROM exercise_profile_state WHERE exercise_row_id=?",
arrayOf(rowId.toString()),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else return false }
val count = db.rawQuery(
"SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",
arrayOf(rowId.toString()),
).use { cursor -> cursor.moveToFirst(); cursor.getInt(0) }
if (count >= PROFILE_HISTORY_MAX) return false
val child = exerciseProfileRevision(state, recording, tracking, fields)
db.execSQL(
"UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=0 WHERE exercise_row_id=?",
arrayOf(child,state,rowId),
)
db.execSQL(
"INSERT INTO exercise_profile_revisions VALUES(?,?,?,?,?,?,0)",
arrayOf(rowId,child,state,recording,tracking,fields),
)
return true
}
/* WHY: ended_at is absent on valid imported/history rows and present lifecycle
* metadata cannot prove actual work. CONTRACT: observable history requires at
* least one occurrence-owned performed set, continuous activity, or explicit
@ -98,6 +142,13 @@ sealed interface PcCatalogImportResult {
PcCatalogImportResult
}
sealed interface ExerciseProfileStateImportResult {
data class Applied(val updated: Int, val skipped: Int) : ExerciseProfileStateImportResult
data class Invalid(val message: String) : ExerciseProfileStateImportResult
data class Conflict(val exerciseId: String) : ExerciseProfileStateImportResult
data object DatabaseError : ExerciseProfileStateImportResult
}
/** Read-only diagnostic emitted by the production PC-catalog importer. */
data class PcCatalogExerciseDecision(
val exerciseId: String,
@ -183,6 +234,12 @@ sealed interface SaveSessionResult {
SaveSessionResult
}
sealed interface CorrectCompletedSessionResult {
data object Saved : CorrectCompletedSessionResult
data class Invalid(val message: String) : CorrectCompletedSessionResult
data class DatabaseError(val message: String) : CorrectCompletedSessionResult
}
sealed interface SaveFeedbackResult {
data class Saved(val stableId: String) : SaveFeedbackResult
data class Invalid(val message: String) : SaveFeedbackResult
@ -969,6 +1026,16 @@ class TrainlogRepository(
exercise.dataFields,
),
)
db.execSQL(
"UPDATE exercise_profile_state SET revision_id=?,parent_revision_id='pr_legacy_v1',legacy_seed=0 " +
"WHERE exercise_row_id=(SELECT id FROM exercises WHERE exercise_id=?);",
arrayOf(exerciseProfileRevision("pr_legacy_v1", exercise.recordingMode.wireValue,
exercise.trackingMode.wireValue, exercise.dataFields), exercise.exerciseId),
)
db.execSQL(
"INSERT INTO exercise_profile_revisions SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,0 FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?",
arrayOf(exercise.exerciseId),
)
replaceExerciseBodyZones(db, exercise.exerciseId,
exercise.primaryZoneId, exercise.secondaryZoneIds)
@ -989,18 +1056,12 @@ class TrainlogRepository(
}
}
/**
* WHY: completed sessions and active drafts snapshot profile fields, but
* keeping referenced catalog profiles immutable prevents a later catalog
* sync/profile edit from appearing to change an established exercise.
* A rename remains safe because all relationships use the unchanged row.
*/
/** CONTRACT: occurrences snapshot profile semantics, so references never lock CURRENT edits. */
fun canEditExerciseProfile(
exerciseId: String,
): Boolean {
val db = database.readableDatabase
val rowId = lookupExerciseRowIdOrNull(db, exerciseId) ?: return false
return !exerciseHasReferences(db, rowId)
return lookupExerciseRowIdOrNull(db, exerciseId) != null
}
fun editExercise(
@ -1022,13 +1083,11 @@ class TrainlogRepository(
db.beginTransaction()
val current = findExerciseRow(db, "exercise_id = ?", arrayOf(input.exerciseId))
?: return EditExerciseResult.DatabaseError
val profileChanged =
current.recordingMode != input.recordingMode ||
current.trackingMode != input.trackingMode ||
current.dataFields != input.dataFields
if (profileChanged && exerciseHasReferences(db, current.rowId)) {
return EditExerciseResult.IncompatibleProfileChange
}
/* CONTRACT: completed and draft occurrences own profile snapshots.
* Updating the catalogue controls future uses only; it must never
* rewrite or reject because historical/draft facts reference this
* stable exercise identity. The UI confirms incompatible future
* behavior before invoking this transaction. */
val nameOwner = findExerciseRow(db, "normalized_name = ?", arrayOf(normalized))
if (nameOwner != null && nameOwner.rowId != current.rowId) {
@ -1042,9 +1101,32 @@ class TrainlogRepository(
put("tracking_mode", input.trackingMode.wireValue)
put("data_fields", input.dataFields)
}
val parentRevision = db.rawQuery(
"SELECT revision_id FROM exercise_profile_state WHERE exercise_row_id=?",
arrayOf(current.rowId.toString()),
).use { cursor -> check(cursor.moveToFirst()); cursor.getString(0) }
if (db.update("exercises", values, "id = ?", arrayOf(current.rowId.toString())) != 1) {
return EditExerciseResult.DatabaseError
}
if (current.recordingMode != input.recordingMode || current.trackingMode != input.trackingMode ||
current.dataFields != input.dataFields) {
val revisionCount = db.rawQuery(
"SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",
arrayOf(current.rowId.toString()),
).use { cursor -> cursor.moveToFirst(); cursor.getInt(0) }
if (revisionCount >= PROFILE_HISTORY_MAX) return EditExerciseResult.DatabaseError
db.execSQL(
"UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=0 WHERE exercise_row_id=?",
arrayOf(exerciseProfileRevision(parentRevision, input.recordingMode.wireValue,
input.trackingMode.wireValue, input.dataFields), parentRevision, current.rowId),
)
db.execSQL(
"INSERT INTO exercise_profile_revisions VALUES(?,?,?,?,?,?,0)",
arrayOf(current.rowId, exerciseProfileRevision(parentRevision, input.recordingMode.wireValue,
input.trackingMode.wireValue, input.dataFields), parentRevision,
input.recordingMode.wireValue, input.trackingMode.wireValue, input.dataFields),
)
}
replaceExerciseBodyZones(db, input.exerciseId,
input.primaryZoneId, input.secondaryZoneIds)
db.setTransactionSuccessful()
@ -1093,6 +1175,176 @@ class TrainlogRepository(
.toString()
}
/** Separately versioned causal state for mutable CURRENT profiles. */
fun buildExerciseProfileStateJson(): String {
val array = JSONArray()
database.readableDatabase.rawQuery(
"SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields," +
"e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id," +
"e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed " +
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id ORDER BY e.exercise_id",
null,
).use { c ->
while (c.moveToNext()) {
check(c.getType(3)==Cursor.FIELD_TYPE_INTEGER && c.getType(12)==Cursor.FIELD_TYPE_INTEGER &&
c.getInt(12) in 0..1 && c.getType(10)==Cursor.FIELD_TYPE_STRING &&
c.getType(11) in setOf(Cursor.FIELD_TYPE_NULL,Cursor.FIELD_TYPE_STRING))
val item = JSONObject().put("exercise_id", c.getString(0))
.put("recording_mode", c.getString(1)).put("tracking_mode", c.getString(2))
.put("data_fields", c.getInt(3))
for (i in 4..9) item.put(arrayOf("load_semantics","machine_variant","machine_provenance",
"scientific_profile_id","science_state","legacy_equipment_id")[i-4],
if (c.isNull(i)) JSONObject.NULL else c.getString(i))
item.put("revision_id", c.getString(10))
.put("parent_revision_id", if (c.isNull(11)) JSONObject.NULL else c.getString(11))
.put("legacy_seed", c.getInt(12) == 1)
val history = JSONArray()
val rowId = database.readableDatabase.rawQuery(
"SELECT id FROM exercises WHERE exercise_id=?", arrayOf(c.getString(0)),
).use { idCursor -> check(idCursor.moveToFirst()); idCursor.getLong(0) }
database.readableDatabase.rawQuery(
"WITH RECURSIVE lineage(revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,level) AS (" +
"SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,0 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=? " +
"UNION ALL SELECT r.revision_id,r.parent_revision_id,r.recording_mode,r.tracking_mode,r.data_fields,r.legacy_seed,lineage.level+1 FROM exercise_profile_revisions r JOIN lineage ON r.revision_id=lineage.parent_revision_id WHERE r.exercise_row_id=? AND lineage.level<31) " +
"SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed FROM lineage ORDER BY level DESC",
arrayOf(rowId.toString(), c.getString(10), rowId.toString()),
).use { revisions ->
var previous: String? = null
var index = 0
while (revisions.moveToNext()) history.put(JSONObject()
.put("revision_id", revisions.getString(0))
.put("parent_revision_id", if (revisions.isNull(1)) JSONObject.NULL else revisions.getString(1))
.put("recording_mode", revisions.getString(2)).put("tracking_mode", revisions.getString(3))
.put("data_fields", revisions.getInt(4)).put("legacy_seed", revisions.getInt(5) == 1).also {
val revision = revisions.getString(0)
val parent = if (revisions.isNull(1)) null else revisions.getString(1)
val recording = revisions.getString(2)
val tracking = revisions.getString(3)
val fields = revisions.getInt(4)
val legacy = revisions.getInt(5) == 1
check(revisions.getType(4)==Cursor.FIELD_TYPE_INTEGER &&
revisions.getType(5)==Cursor.FIELD_TYPE_INTEGER && revisions.getInt(5) in 0..1)
check(recording in setOf("sets","continuous") && tracking in setOf("reps","duration") &&
!(recording=="continuous"&&tracking!="duration") && fields in 0..3 &&
!(recording=="sets"&&fields!=0))
if (index == 0) check(legacy && revision=="pr_legacy_v1" && parent==null)
else check(!legacy && parent==previous && PROFILE_REVISION_PATTERN.matches(revision) &&
revision==exerciseProfileRevision(parent!!,recording,tracking,fields))
previous=revision;index++
})
}
check(history.length() in 1..PROFILE_HISTORY_MAX)
val tip=history.getJSONObject(history.length()-1)
check(tip.getString("revision_id")==c.getString(10) &&
(if(tip.isNull("parent_revision_id"))null else tip.getString("parent_revision_id"))==
(if(c.isNull(11))null else c.getString(11)) &&
tip.getBoolean("legacy_seed")== (c.getInt(12)==1) &&
tip.getString("recording_mode")==c.getString(1) &&
tip.getString("tracking_mode")==c.getString(2) && tip.getInt("data_fields")==c.getInt(3))
item.put("history", history)
array.put(item)
}
}
return JSONObject().put("format","trainlog-exercise-profile-state").put("version",1)
.put("generated_at",OffsetDateTime.now().toString()).put("exercises",array).toString()
}
fun applyExerciseProfileStateJson(json: String, allowPending: Boolean = false): ExerciseProfileStateImportResult {
if (!hasStrictJsonShape(json))
return ExerciseProfileStateImportResult.Invalid("Profile-state JSON invalide ou champ dupliqué.")
val root = try { JSONObject(json) } catch (_: Exception) {
return ExerciseProfileStateImportResult.Invalid("Profile-state JSON invalide.")
}
if (!root.hasExactKeys(setOf("format","version","generated_at","exercises")) ||
root.value("format") != "trainlog-exercise-profile-state" || !root.value("version").isExactJsonInteger(1) ||
root.value("generated_at") !is String || TrainlogTimestamp.parse(root.value("generated_at") as String) == null)
return ExerciseProfileStateImportResult.Invalid("Profile-state non supporté.")
val entries = root.value("exercises") as? JSONArray
?: return ExerciseProfileStateImportResult.Invalid("Profile-state sans exercices.")
val db=database.writableDatabase;var updated=0;var skipped=0;val seen=mutableSetOf<String>()
return try {
db.beginTransaction()
for(i in 0 until entries.length()) {
val x=entries.get(i) as? JSONObject ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
val required=setOf("exercise_id","recording_mode","tracking_mode","data_fields",
"load_semantics","machine_variant","machine_provenance","scientific_profile_id",
"science_state","legacy_equipment_id","revision_id","parent_revision_id","legacy_seed","history")
if(!x.hasExactKeys(required)) return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
val id=x.value("exercise_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
val recording=x.value("recording_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
val tracking=x.value("tracking_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
val fieldsValue=x.value("data_fields"); if(!fieldsValue.isExactJsonInteger()) return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
val fieldsLong=(fieldsValue as Number).toLong()
if(fieldsLong !in 0L..3L) return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
val fields=fieldsLong.toInt()
val revision=x.value("revision_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
val parent=if(x.value("parent_revision_id")===JSONObject.NULL)null else x.value("parent_revision_id") as? String
?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
val legacy=x.value("legacy_seed") as? Boolean ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
val nullableAdjacent=listOf("load_semantics","machine_variant","machine_provenance","scientific_profile_id","legacy_equipment_id")
if(nullableAdjacent.any { x.value(it)!==JSONObject.NULL && x.value(it) !is String } || x.value("science_state") !in setOf("resolved","unresolved") ||
!EXERCISE_ID_V4_PATTERN.matches(id)||!seen.add(id)||recording !in setOf("sets","continuous")||
tracking !in setOf("reps","duration")||(recording=="continuous"&&tracking!="duration")||
(recording=="sets"&&fieldsLong!=0L)||
(legacy&&(revision!="pr_legacy_v1"||parent!=null))||(!legacy&&(parent==null||
!PROFILE_REVISION_PATTERN.matches(revision)||revision!=exerciseProfileRevision(parent,recording,tracking,fields))))
return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
val history=x.value("history") as? JSONArray ?: return ExerciseProfileStateImportResult.Invalid("Historique profile-state invalide.")
if(history.length() !in 1..PROFILE_HISTORY_MAX) return ExerciseProfileStateImportResult.Invalid("Historique profile-state hors limite.")
val revisions=mutableListOf<List<Any?>>()
val historyIds=mutableSetOf<String>();var previous:String?=null
val revisionKeys=setOf("revision_id","parent_revision_id","recording_mode","tracking_mode","data_fields","legacy_seed")
for(j in 0 until history.length()) {
val h=history.get(j) as? JSONObject ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
if(!h.hasExactKeys(revisionKeys)) return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
val hr=h.value("recording_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
val ht=h.value("tracking_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
val hfValue=h.value("data_fields");if(!hfValue.isExactJsonInteger()) return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
val hfLong=(hfValue as Number).toLong();val hid=h.value("revision_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
if(hfLong !in 0L..3L) return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
val hf=hfLong.toInt()
val hp=if(h.value("parent_revision_id")===JSONObject.NULL)null else h.value("parent_revision_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
val hl=h.value("legacy_seed") as? Boolean ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
if(hr !in setOf("sets","continuous")||ht !in setOf("reps","duration")||(hr=="continuous"&&ht!="duration")||(hr=="sets"&&hfLong!=0L)||!historyIds.add(hid)||(j>0&&hp!=previous)||
(j==0&&(!hl||hid!="pr_legacy_v1"||hp!=null))||(j>0&&hl)||
(hl&&(hid!="pr_legacy_v1"||hp!=null))||(!hl&&(hp==null||!PROFILE_REVISION_PATTERN.matches(hid)||hid!=exerciseProfileRevision(hp,hr,ht,hf))))
return ExerciseProfileStateImportResult.Invalid("Chaîne profile-state invalide.")
revisions.add(listOf(hid,hp,hr,ht,hf,hl));previous=hid
}
if(revisions.last()!=listOf(revision,parent,recording,tracking,fields,legacy)) return ExerciseProfileStateImportResult.Invalid("Pointe profile-state incohérente.")
val row=db.rawQuery("SELECT e.id,e.recording_mode,e.tracking_mode,e.data_fields,"+
"e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id,e.science_state,e.legacy_equipment_id,"+
"s.revision_id,s.parent_revision_id,s.legacy_seed FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?",arrayOf(id)).use{c->
if(!c.moveToFirst()) null else (0..12).map{if(c.isNull(it)) null else c.getString(it)}}
if(row==null){if(allowPending){skipped++;continue};return ExerciseProfileStateImportResult.Invalid("Identité exercice inconnue : $id")}
val incomingAdj=arrayOf("load_semantics","machine_variant","machine_provenance","scientific_profile_id","science_state","legacy_equipment_id").map{key->if(x.value(key)===JSONObject.NULL)null else x.value(key) as String}
if((4..9).map{row[it]}!=incomingAdj)return ExerciseProfileStateImportResult.Conflict(id)
val localRevision=row[10]!!;val localParent=row[11];val localLegacy=row[12]=="1"
val sameProfile=row[1]==recording&&row[2]==tracking&&row[3]?.toInt()==fields
val accept = when {
localRevision==revision && sameProfile -> false
localRevision==revision && localLegacy && legacy -> true
localRevision in historyIds -> true
db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revision)).use{it.moveToFirst()} -> false
else -> return ExerciseProfileStateImportResult.Conflict(id)
}
if(!accept){skipped++;continue}
val knownCount=db.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",arrayOf(row[0])).use{it.moveToFirst();it.getInt(0)}
val additions=revisions.count { revisionRow -> db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revisionRow[0] as String)).use{!it.moveToFirst()} }
if(knownCount+additions>PROFILE_HISTORY_MAX)return ExerciseProfileStateImportResult.Invalid("Historique profile-state hors limite : $id")
revisions.forEach { h -> db.execSQL("INSERT OR IGNORE INTO exercise_profile_revisions VALUES(?,?,?,?,?,?,?)",arrayOf(row[0],h[0],h[1],h[2],h[3],h[4],if(h[5] as Boolean)1 else 0)) }
if(legacy) db.execSQL(
"UPDATE exercise_profile_revisions SET recording_mode=?,tracking_mode=?,data_fields=? WHERE exercise_row_id=? AND revision_id='pr_legacy_v1'",
arrayOf<Any?>(recording,tracking,fields,row[0]),
)
db.execSQL("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=? WHERE exercise_row_id=?",arrayOf<Any?>(revision,parent,if(legacy)1 else 0,row[0]!!.toLong()))
db.execSQL("UPDATE exercises SET recording_mode=?,tracking_mode=?,data_fields=? WHERE id=?",arrayOf<Any?>(recording,tracking,fields,row[0]!!.toLong()))
updated++
}
db.setTransactionSuccessful();ExerciseProfileStateImportResult.Applied(updated,skipped)
} catch(_:Exception){ExerciseProfileStateImportResult.DatabaseError} finally {if(db.inTransaction())db.endTransaction()}
}
fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult {
val root = try { JSONObject(json) } catch (_: Exception) {
return ExerciseBodyZoneImportResult.Invalid("Relations de zones JSON invalides.")
@ -1763,6 +2015,37 @@ class TrainlogRepository(
}
}
/**
* WHY: correcting history changes facts, not their logical owners.
* CONTRACT: the session row and every retained entry_id survive; feedback
* roots/revisions are reattached only to retained entry_ids and follow-ups
* remain attached to the untouched session row. The complete replacement
* is one transaction, so any failure restores the original history.
*/
fun correctCompletedSession(
sessionId: String,
draft: SessionDraft,
): CorrectCompletedSessionResult {
if (sessionId.isBlank() || draft.exercises.isEmpty() ||
draft.exercises.map { it.entryId }.distinct().size != draft.exercises.size ||
draft.exercises.any { !validateSessionExercise(it, draft.sessionType) }) {
return CorrectCompletedSessionResult.Invalid("La correction de séance est invalide.")
}
val db = database.writableDatabase
return try {
db.beginTransaction()
insertCompletedSession(db, draft, sessionId, generalCorrection = true)
db.setTransactionSuccessful()
CorrectCompletedSessionResult.Saved
} catch (error: Exception) {
CorrectCompletedSessionResult.DatabaseError(
error.message ?: "Correction de la séance impossible."
)
} finally {
if (db.inTransaction()) db.endTransaction()
}
}
fun listSessions(): List<SessionSummary> {
val output =
mutableListOf<SessionSummary>()
@ -2157,6 +2440,10 @@ class TrainlogRepository(
) {
return PcCatalogImportResult.DatabaseError
}
if (byId.dataFields != richerFields && !advanceExerciseProfileRevision(
db, byId.rowId, byId.recordingMode.wireValue,
byId.trackingMode.wireValue, richerFields))
return PcCatalogImportResult.DatabaseError
reconciled += 1
traceDecision(
@ -2227,6 +2514,11 @@ class TrainlogRepository(
) {
return PcCatalogImportResult.DatabaseError
}
val mergedFields = byName.dataFields or dataFields
if (byName.dataFields != mergedFields && !advanceExerciseProfileRevision(
db, byName.rowId, byName.recordingMode.wireValue,
byName.trackingMode.wireValue, mergedFields))
return PcCatalogImportResult.DatabaseError
reconciled += 1
traceDecision(
@ -2274,6 +2566,26 @@ class TrainlogRepository(
null,
values
)
/* WHY: catalog reconciliation inserts outside the local
* creation API. CONTRACT: the strict profile-state second
* pass must observe a canonical causal root, never the
* trigger's transaction-local placeholder. */
db.execSQL(
"UPDATE exercise_profile_state SET revision_id=?," +
"parent_revision_id='pr_legacy_v1',legacy_seed=0 " +
"WHERE exercise_row_id=(SELECT id FROM exercises WHERE exercise_id=?);",
arrayOf(
exerciseProfileRevision(
"pr_legacy_v1", recording.wireValue,
tracking.wireValue, dataFields,
),
exerciseId,
),
)
db.execSQL(
"INSERT INTO exercise_profile_revisions SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,0 FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?",
arrayOf(exerciseId),
)
imported += 1
traceDecision("none", "inserted")
@ -2897,26 +3209,34 @@ class TrainlogRepository(
arrayOf(resolveExerciseId(db, exerciseId)),
)
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
if (
entry.optString("recording_mode") != exerciseRow.recordingMode.wireValue ||
entry.optString("tracking_mode") != exerciseRow.trackingMode.wireValue ||
entry.optInt("data_fields", -1) and
exerciseRow.dataFields.inv() != 0
) {
return MobileSessionImportResult.Invalid("Profil V2 incompatible : $exerciseId")
}
/* CONTRACT: the resolved row proves stable exercise
* identity only. Completed occurrences own their profile
* snapshot; CURRENT catalogue evolution cannot reject it. */
}
val existingRowId = db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf(sessionId)).use {
if (it.moveToFirst()) it.getLong(0) else null
}
val retainedFeedback = mutableMapOf<String, MutableList<Triple<String,String,String>>>()
val retainedRevisions = mutableMapOf<String, MutableList<Triple<String,String,String>>>()
if (existingRowId != null) {
if (pcSessionMatches(db, existingRowId, session, version)) {
sessionsSkipped += 1
continue
}
if (!pcResumedMaxUpdateIsSafe(db, existingRowId, session)) {
/* V2 has no complete planning authority; it must never
* erase V3 targets while applying a correction. */
if (version != 3 && session.optString("session_type") != "max_test") return MobileSessionImportResult.Invalid(
"Une correction de séance exige le snapshot V3 complet."
)
if (!pcCompletedCorrectionIsSafe(db, existingRowId, session)) {
return MobileSessionImportResult.Invalid("Conflit de contenu pour la séance $sessionId")
}
db.rawQuery("SELECT se.entry_id,f.feedback_id,f.observed_at,f.raw_text FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?", arrayOf(existingRowId.toString())).use { cursor ->
while (cursor.moveToNext()) retainedFeedback.getOrPut(cursor.getString(0)){mutableListOf()}.add(Triple(cursor.getString(1),cursor.getString(2),cursor.getString(3)))
}
db.rawQuery("SELECT r.feedback_id,r.revision_id,r.created_at,r.raw_text FROM exercise_feedback_revisions r JOIN exercise_feedback f ON f.feedback_id=r.feedback_id JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?", arrayOf(existingRowId.toString())).use { cursor ->
while (cursor.moveToNext()) retainedRevisions.getOrPut(cursor.getString(0)){mutableListOf()}.add(Triple(cursor.getString(1),cursor.getString(2),cursor.getString(3)))
}
/* Child replacement is inside this import transaction;
* entry identity/order may only be retained and appended. */
db.delete(
@ -2976,6 +3296,21 @@ class TrainlogRepository(
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
}
val occurrence = db.insertOrThrow("session_exercises", null, values)
/* INVARIANT: correction transport follows session_id +
* entry_id. Retained observations keep all immutable
* revisions; an omitted entry intentionally inherits none. */
retainedFeedback[entryId].orEmpty().forEach { feedback ->
db.insertOrThrow("exercise_feedback", null, ContentValues().apply {
put("feedback_id",feedback.first);put("session_exercise_row_id",occurrence)
put("observed_at",feedback.second);put("raw_text",feedback.third)
})
retainedRevisions[feedback.first].orEmpty().forEach { revision ->
db.insertOrThrow("exercise_feedback_revisions",null,ContentValues().apply {
put("revision_id",revision.first);put("feedback_id",feedback.first)
put("created_at",revision.second);put("raw_text",revision.third)
})
}
}
if (entry.has("max_weight_kg")) {
db.insertOrThrow("max_results", null, ContentValues().apply {
put("session_exercise_row_id", occurrence)
@ -3059,7 +3394,6 @@ class TrainlogRepository(
}
return try {
val exerciseIds = mutableSetOf<String>()
val exerciseProfiles = mutableMapOf<String, Triple<String, String, Int>>()
val exerciseKeys = setOf("exercise_id", "name", "recording_mode", "tracking_mode", "data_fields")
val exercises = root.getJSONArray("exercises")
for (index in 0 until exercises.length()) {
@ -3069,11 +3403,6 @@ class TrainlogRepository(
!item.value("name").isNonemptyJsonString() || !validJsonProfile(item)) {
return "Catalogue exercices V2 invalide."
}
exerciseProfiles[id] = Triple(
item.getString("recording_mode"),
item.getString("tracking_mode"),
item.getInt("data_fields"),
)
}
val sessionKeys = setOf("session_id", "started_at", "session_type", "exercises")
@ -3109,29 +3438,25 @@ class TrainlogRepository(
val exerciseId = entry.value("exercise_id")
val positionValue = entry.value("position")
if (!entry.hasExactKeys(expectedKeys) || !entryId.isNonemptyJsonString() || !entryIds.add(entryId as String) ||
!exerciseId.isNonemptyJsonString() || exerciseId !in exerciseIds || !entry.value("name").isNonemptyJsonString() ||
!validJsonProfile(entry) || !validEntryPlan(entry, version, recording as String, tracking as String, hasMax) ||
!exerciseId.isNonemptyJsonString() || !entry.value("name").isNonemptyJsonString() ||
!positionValue.isJsonInt(0, 100000) || !positions.add((positionValue as Number).toInt()) ||
!(entry.value("equipment_id") === JSONObject.NULL || entry.value("equipment_id").isNonemptyJsonString())) {
return "Entrée de séance V2 invalide."
}
if (exerciseId !in exerciseIds) {
return "Exercice de séance V2 absent du catalogue."
}
if (!validJsonProfile(entry)) {
return "Profil historique V2 invalide."
}
if (!validEntryPlan(entry, version, recording as String, tracking as String, hasMax)) {
return "Plan/payload historique V2 incompatible avec son profil."
}
if (hasMax &&
(session.getString("session_type") != "max_test" ||
!entry.value("max_weight_kg").isPositiveJsonNumber())) {
return "Résultat max V2 invalide."
}
val catalogProfile = exerciseProfiles[exerciseId]
?: return "Exercice de séance V2 absent du catalogue."
val entryFields = entry.getInt("data_fields")
if (
entry.getString("recording_mode") != catalogProfile.first ||
entry.getString("tracking_mode") != catalogProfile.second ||
entryFields and catalogProfile.third.inv() != 0
) {
/* CONTRACT: richer current catalog metadata must not
* rewrite an older occurrence snapshot. */
return "Profil historique V2 incompatible avec le catalogue."
}
if (hasMax) {
/* The exact-key check above excludes set/continuous
* shadows; max identity remains the exercise entry. */
@ -3280,6 +3605,9 @@ class TrainlogRepository(
val number = toDouble()
return number.isFinite() && number % 1.0 == 0.0 && number >= minimum && number <= maximum
}
/** CONTRACT: profile-state mirrors Python's exact JSON integer rule; no strings, booleans, or 1.0. */
private fun Any?.isExactJsonInteger(expected: Int? = null): Boolean =
(this is Int || this is Long) && (expected == null || (this as Number).toLong() == expected.toLong())
private fun Any?.isPositiveJsonNumber(): Boolean = this is Number && toDouble().isFinite() && toDouble() > 0.0
private fun Any?.isNonnegativeJsonNumber(): Boolean =
this is Number && toDouble().isFinite() && toDouble() >= 0.0
@ -3353,18 +3681,17 @@ class TrainlogRepository(
return true
}
private fun pcResumedMaxUpdateIsSafe(
private fun pcCompletedCorrectionIsSafe(
db: SQLiteDatabase,
rowId: Long,
session: JSONObject,
): Boolean {
if (session.optString("session_type") != "max_test") return false
val headerMatches = db.rawQuery(
"SELECT started_at,session_type FROM sessions WHERE id=?",
arrayOf(rowId.toString()),
).use {
it.moveToFirst() && it.getString(0) == session.optString("started_at") &&
it.getString(1) == "max_test"
it.getString(1) == session.optString("session_type")
}
if (!headerMatches) return false
val current = mutableListOf<Triple<String, Int, String>>()
@ -3379,14 +3706,14 @@ class TrainlogRepository(
}
}
val incoming = session.optJSONArray("exercises") ?: return false
if (incoming.length() < current.size) return false
return current.indices.all { index ->
val currentById = current.associateBy { it.first }
val seen = mutableSetOf<String>()
return (0 until incoming.length()).all { index ->
val item = incoming.optJSONObject(index) ?: return@all false
current[index] == Triple(
item.optString("entry_id"),
item.optInt("position", -1),
resolveExerciseId(db, item.optString("exercise_id")),
)
val entryId = item.optString("entry_id")
val existing = currentById[entryId]
seen.add(entryId) && (existing == null || existing.third ==
resolveExerciseId(db, item.optString("exercise_id")))
}
}
@ -5623,6 +5950,7 @@ class TrainlogRepository(
db: SQLiteDatabase,
draft: SessionDraft,
sourceSessionId: String? = null,
generalCorrection: Boolean = false,
): String {
val sessionId: String
val sessionRowId: Long
@ -5642,20 +5970,24 @@ class TrainlogRepository(
}
sessionRowId = db.insertOrThrow("sessions", null, sessionValues)
} else {
check(draft.sessionType == SessionType.MAX_TEST) {
check(generalCorrection || draft.sessionType == SessionType.MAX_TEST) {
"Seul un Test max peut remplacer une séance reprise."
}
sessionId = sourceSessionId
sessionRowId = db.rawQuery(
"SELECT id FROM sessions WHERE session_id=? AND session_type='max_test';",
if (generalCorrection) "SELECT id FROM sessions WHERE session_id=?;"
else "SELECT id FROM sessions WHERE session_id=? AND session_type='max_test';",
arrayOf(sourceSessionId),
).use { cursor ->
check(cursor.moveToFirst()) { "Séance Test max source introuvable." }
cursor.getLong(0)
}
check(resumedDraftIdentityIsSafe(db, sessionRowId, draft)) {
check(generalCorrection || resumedDraftIdentityIsSafe(db, sessionRowId, draft)) {
"Une séance reprise ne peut supprimer, réordonner ou réaffecter ses entrées existantes."
}
check(!generalCorrection || completedCorrectionIdentityIsSafe(db, sessionRowId, draft)) {
"Une correction ne peut réaffecter un entry_id existant à un autre exercice."
}
/* Android has one bounded completed-session correction path:
* resumed MAX_TEST finalization. Preserve feedback by the stable
* occurrence entry_id before cascading reconstruction; the
@ -5675,7 +6007,7 @@ class TrainlogRepository(
).use { cursor -> while (cursor.moveToNext()) retainedCompletedRevisions
.getOrPut(cursor.getString(0)) { mutableListOf() }
.add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) }
db.execSQL("UPDATE sessions SET ended_at=? WHERE id=?;",
if (!generalCorrection) db.execSQL("UPDATE sessions SET ended_at=? WHERE id=?;",
arrayOf<Any>(OffsetDateTime.now().toString(), sessionRowId))
/* INVARIANT: child replacement and draft deletion are in the
* caller's transaction; failure restores the completed baseline. */
@ -5822,6 +6154,34 @@ class TrainlogRepository(
return sessionId
}
/**
* CONTRACT: an occurrence entry_id retains its exercise identity during a
* completed-session replacement. Aliases compare canonical creator IDs so
* retirement/reconciliation cannot manufacture a false reassignment.
* This guard runs inside the replacement transaction before any delete.
*/
private fun completedCorrectionIdentityIsSafe(
db: SQLiteDatabase,
sessionRowId: Long,
draft: SessionDraft,
): Boolean {
fun canonical(id: String): String = db.rawQuery(
"SELECT COALESCE((SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?),?)",
arrayOf(id, id),
).use { cursor -> check(cursor.moveToFirst()); cursor.getString(0) }
val incoming = draft.exercises.associate { it.entryId to canonical(it.exercise.exerciseId) }
return db.rawQuery(
"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=?", arrayOf(sessionRowId.toString()),
).use { cursor ->
while (cursor.moveToNext()) {
val replacement = incoming[cursor.getString(0)] ?: continue
if (replacement != canonical(cursor.getString(1))) return@use false
}
true
}
}
private fun resumedDraftIdentityIsSafe(
db: SQLiteDatabase,
sessionRowId: Long,
@ -6252,7 +6612,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
15,
16,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -6268,7 +6628,16 @@ private class TrainlogDatabaseHelper(
db: SQLiteDatabase,
) {
super.onOpen(db)
migrateApprovedLegPressIdentity(db)
db.beginTransaction()
try {
migrateApprovedLegPressIdentity(db)
/* WHY: early unpublished v16 builds used a variable-width pr1
* current token. CONTRACT: recognize only its exact grammar and
* atomically replace it by the shared root plus canonical pr2 tip. */
createExerciseProfileStateTable(db, seedLegacy = false)
normalizePrototypeExerciseProfileState(db)
db.setTransactionSuccessful()
} finally { db.endTransaction() }
}
override fun onCreate(
@ -6283,6 +6652,7 @@ private class TrainlogDatabaseHelper(
createExerciseAliasTable(db)
createTrainingFeedbackTables(db)
createTrainingFeedbackRevisionTables(db)
createExerciseProfileStateTable(db, seedLegacy = false)
seedEquipment(db)
}
@ -6401,6 +6771,15 @@ private class TrainlogDatabaseHelper(
version = 15
}
if (version < 16 && newVersion >= 16) {
/* WHY/CONTRACT/INVARIANT: wall clocks cannot order offline profile
* edits. Existing rows share one explicit legacy root without any
* catalogue or occurrence rewrite; later edits carry direct causal
* ancestry in this companion table. */
createExerciseProfileStateTable(db, seedLegacy = true)
version = 16
}
if (version != newVersion) {
error(
"Unsupported Android DB upgrade " +
@ -6430,6 +6809,91 @@ private class TrainlogDatabaseHelper(
)
}
private fun createExerciseProfileStateTable(db: SQLiteDatabase, seedLegacy: Boolean) {
/* WHY: wall clocks cannot order offline edits. CONTRACT: revisions form
* one explicit causal chain. INVARIANT: at most 32 records are retained
* per exercise and overflow aborts instead of pruning ancestry. */
db.execSQL(
"CREATE TABLE IF NOT EXISTS exercise_profile_state(" +
"exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE," +
"revision_id TEXT NOT NULL,parent_revision_id TEXT," +
"legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1)));",
)
if (seedLegacy) {
db.execSQL(
"INSERT OR IGNORE INTO exercise_profile_state(" +
"exercise_row_id,revision_id,parent_revision_id,legacy_seed) " +
"SELECT id,'pr_legacy_v1',NULL,1 FROM exercises;",
)
}
db.execSQL(
"CREATE TABLE IF NOT EXISTS exercise_profile_revisions(" +
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE," +
"revision_id TEXT NOT NULL,parent_revision_id TEXT,recording_mode TEXT NOT NULL," +
"tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL," +
"legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1))," +
"PRIMARY KEY(exercise_row_id,revision_id));",
)
db.execSQL(
"INSERT OR IGNORE INTO exercise_profile_revisions " +
"SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,s.legacy_seed " +
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id;",
)
db.execSQL("DROP TRIGGER IF EXISTS exercise_profile_state_insert;")
db.execSQL("DROP TRIGGER IF EXISTS exercise_profile_state_update;")
db.execSQL(
"CREATE TRIGGER exercise_profile_state_insert AFTER INSERT ON exercises BEGIN " +
"INSERT INTO exercise_profile_state(exercise_row_id,revision_id,parent_revision_id,legacy_seed) " +
"VALUES(NEW.id,'pr_legacy_v1',NULL,1);" +
"INSERT INTO exercise_profile_revisions VALUES(NEW.id,'pr_legacy_v1',NULL,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,1);END;",
)
}
private fun normalizePrototypeExerciseProfileState(db: SQLiteDatabase) {
data class Prototype(val rowId: Long, val token: String, val recording: String,
val tracking: String, val fields: Int)
val prototypes=mutableMapOf<Long,Prototype>()
db.rawQuery(
"SELECT s.exercise_row_id,s.revision_id,s.parent_revision_id,s.legacy_seed,"+
"e.recording_mode,e.tracking_mode,e.data_fields FROM exercise_profile_state s "+
"JOIN exercises e ON e.id=s.exercise_row_id WHERE s.revision_id LIKE 'pr1%'", null,
).use { c -> while(c.moveToNext()) {
val token=c.getString(1);val match=PROTOTYPE_PROFILE_REVISION_PATTERN.matchEntire(token)
?: error("Malformed prototype profile revision")
val recording=match.groupValues[1];val tracking=match.groupValues[2]
val fields=match.groupValues[3].toIntOrNull() ?: error("Malformed prototype profile fields")
check(token=="pr1|$recording|$tracking|$fields" && c.isNull(2)&&c.getInt(3)==1 &&
recording==c.getString(4) && tracking==c.getString(5) &&
fields==c.getInt(6) && fields in 0..3 && !(recording=="sets"&&fields!=0) &&
!(recording=="continuous"&&tracking!="duration")) { "Inconsistent prototype profile state" }
prototypes[c.getLong(0)]=Prototype(c.getLong(0),token,recording,tracking,fields)
}}
db.rawQuery(
"SELECT exercise_row_id,revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed "+
"FROM exercise_profile_revisions WHERE revision_id LIKE 'pr1%'", null,
).use { c -> while(c.moveToNext()) {
val prototype=prototypes[c.getLong(0)] ?: error("Orphan prototype profile revision")
check(c.getString(1)==prototype.token && c.isNull(2) && c.getString(3)==prototype.recording &&
c.getString(4)==prototype.tracking && c.getInt(5)==prototype.fields && c.getInt(6)==1) {
"Inconsistent prototype profile revision"
}
}}
for(prototype in prototypes.values) {
val count=db.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",
arrayOf(prototype.rowId.toString())).use { it.moveToFirst();it.getInt(0) }
check(count==1) { "Mixed prototype profile lineage" }
val child=exerciseProfileRevision("pr_legacy_v1",prototype.recording,prototype.tracking,prototype.fields)
db.execSQL("DELETE FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id LIKE 'pr1%'",
arrayOf(prototype.rowId))
db.execSQL("INSERT INTO exercise_profile_revisions VALUES(?,'pr_legacy_v1',NULL,?,?,?,1)",
arrayOf<Any?>(prototype.rowId,prototype.recording,prototype.tracking,prototype.fields))
db.execSQL("INSERT INTO exercise_profile_revisions VALUES(?,?,'pr_legacy_v1',?,?,?,0)",
arrayOf<Any?>(prototype.rowId,child,prototype.recording,prototype.tracking,prototype.fields))
db.execSQL("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id='pr_legacy_v1',legacy_seed=0 WHERE exercise_row_id=?",
arrayOf<Any?>(child,prototype.rowId))
}
}
private fun createExerciseAliasTable(db: SQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS exercise_aliases(" +

View file

@ -124,18 +124,31 @@ internal object TrainlogTimestamp {
/** Shared Android presentation rule; exact instants are parsed before
* subtraction and a missing/negative anchor never produces a fabricated H+. */
fun sessionFollowUpElapsedLabel(endedAt: String?, observedAt: String): String {
val end = endedAt?.let(TrainlogTimestamp::parse) ?: return "H+?"
fun sessionFollowUpElapsedLabel(endedAt: String?, startedAt: String?, observedAt: String): String {
val observed = TrainlogTimestamp.parse(observedAt) ?: return "H+?"
val seconds = observed.utcSecond - end.utcSecond
return if (seconds < 0) "H+?" else "H+${seconds / 3600L}"
val end = endedAt?.let(TrainlogTimestamp::parse)
if (end != null) {
val seconds = observed.utcSecond - end.utcSecond
return if (seconds < 0) "H+?" else "H+${seconds / 3600L}"
}
val start = startedAt?.let(TrainlogTimestamp::parse) ?: return "H+?"
val seconds = observed.utcSecond - start.utcSecond
return if (seconds < 0) "H+?" else "≈H+${seconds / 3600L}"
}
/** Exercise observations may precede finalization and therefore use a
* session-neutral label instead of a negative or misleading H+ value. */
fun exerciseFeedbackElapsedLabel(endedAt: String?, observedAt: String): String {
val end = endedAt?.let(TrainlogTimestamp::parse) ?: return "Ressenti"
val observed = TrainlogTimestamp.parse(observedAt) ?: return "Ressenti"
val seconds = observed.utcSecond - end.utcSecond
return if (seconds < 0) "Pendant la séance" else "H+${seconds / 3600L}"
fun exerciseFeedbackElapsedLabel(endedAt: String?, startedAt: String?, observedAt: String): String {
val observed = TrainlogTimestamp.parse(observedAt) ?: return "H+?"
val end = endedAt?.let(TrainlogTimestamp::parse)
if (end != null) {
val seconds = observed.utcSecond - end.utcSecond
if (seconds >= 0) return "H+${seconds / 3600L}"
val start = startedAt?.let(TrainlogTimestamp::parse)
return if (start != null && observed.utcSecond >= start.utcSecond)
"Pendant la séance" else "H+?"
}
val start = startedAt?.let(TrainlogTimestamp::parse) ?: return "H+?"
val seconds = observed.utcSecond - start.utcSecond
return if (seconds < 0) "H+?" else "≈H+${seconds / 3600L}"
}

View file

@ -18,6 +18,7 @@ sealed interface AppRoute {
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
data object Exercises : AppRoute { override val section = AppSection.EXERCISES }
data class ExerciseDetail(val exerciseId: String) : AppRoute { override val section = AppSection.EXERCISES }
data class ExerciseEdit(val exerciseId: String, val caller: AppRoute) : AppRoute { override val section = caller.section }
@ -158,6 +159,7 @@ internal fun routeTitle(route: AppRoute): String = when (route) {
AppRoute.SessionGenerator -> "Programmer une séance"
AppRoute.CompletedSessions -> "Séances effectuées"
is AppRoute.SessionDetail -> "Détail de la séance"
is AppRoute.SessionCorrection -> "Modifier la séance"
AppRoute.Exercises -> "Exercices"
is AppRoute.ExerciseDetail -> "Fiche exercice"
is AppRoute.ExerciseEdit -> "Modifier l'exercice"

View file

@ -0,0 +1,150 @@
package com.labfytools.trainlog.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import com.labfytools.trainlog.data.CorrectCompletedSessionResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExerciseDetail
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
/**
* WHY: a completed-session correction must be reviewed as one bounded form,
* not persisted field-by-field while the user is still editing.
* CONTRACT: initial values are historical occurrence snapshots; Save performs
* the sole repository mutation and Cancel performs none. Destructive removal
* always passes through an explicit, non-dismissible confirmation.
*/
@Composable
fun CompletedSessionCorrectionScreen(
repository: TrainlogRepository,
sessionId: String,
onFinished: (saved: Boolean) -> Unit,
) {
val colors = LocalTrainlogColors.current
val original = remember(sessionId) { repository.getSessionDetail(sessionId) }
var drafts by remember(sessionId) {
mutableStateOf(original?.exercises?.map(SessionExerciseDetail::correctionDraft).orEmpty())
}
var pendingSetRemoval by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var pendingOccurrenceRemoval by remember { mutableStateOf<Int?>(null) }
var message by remember { mutableStateOf<String?>(null) }
TrainlogScreen("Modifier la séance", scrollKey = "session-correction:$sessionId") {
if (original == null) {
TrainlogInfo("Séance introuvable.", colors.error)
return@TrainlogScreen
}
TrainlogInfo("Les anciennes interprétations de profil sont conservées. Les identifiants de la séance et des occurrences retenues ne changent pas.", colors.muted)
drafts.forEachIndexed { occurrenceIndex, draft ->
TrainlogFrame("${occurrenceIndex + 1}. ${draft.exercise.name}") {
draft.plan?.let { plan ->
TrainlogInputField("Nombre de séries cible", plan.sets.toString(), { raw ->
raw.toIntOrNull()?.let { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(sets = value))) { drafts = it } }
})
TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions cibles" else "Durée cible par série (s)",
(plan.reps ?: plan.durationSeconds ?: 0).toString(), { raw -> raw.toIntOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = if (draft.exercise.trackingMode == TrackingMode.REPS) plan.copy(reps = value, durationSeconds = null) else plan.copy(reps = null, durationSeconds = value))) { drafts = it }
} })
TrainlogInputField("Repos (s)", plan.restSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(restSeconds = value))) { drafts = it }
} })
if (plan.weightKg != null) TrainlogInputField("Charge cible (kg)", plan.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(weightKg = value))) { drafts = it }
} })
}
if (draft.maxWeightKg != null) {
TrainlogInputField("MAX explicite (kg)", draft.maxWeightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(maxWeightKg = value)) { drafts = it }
} })
} else if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) {
TrainlogInputField("Durée continue (s)", draft.continuousDurationSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(continuousDurationSeconds = value)) { drafts = it }
} })
draft.speedKmh?.let { speed -> TrainlogInputField("Vitesse (km/h)", speed.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(speedKmh = value)) { drafts = it }
} }) }
draft.distanceKm?.let { distance -> TrainlogInputField("Distance (km)", distance.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(distanceKm = value)) { drafts = it }
} }) }
} else {
draft.sets.forEachIndexed { setIndex, set ->
TrainlogInfo("Série ${setIndex + 1}", colors.accent)
TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions" else "Durée (s)",
(if (draft.exercise.trackingMode == TrackingMode.REPS) set.reps else set.durationSeconds).toString(), { raw -> raw.toIntOrNull()?.let { value ->
val changed = if (draft.exercise.trackingMode == TrackingMode.REPS) set.copy(reps = value) else set.copy(durationSeconds = value)
replaceSet(drafts, occurrenceIndex, setIndex, changed) { drafts = it }
} })
if (set.weightKg != null) TrainlogInputField("Charge (kg)", set.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it }
} })
TrainlogIconAction(TrainlogIcons.DeleteOutline, "Supprimer cette série",
{ pendingSetRemoval = occurrenceIndex to setIndex },
Modifier.testTag("remove-completed-set-${draft.entryId}-$setIndex"), colors.error)
}
TrainlogAction("+ Ajouter une série", "Ajouter explicitement un fait réalisé.", {
replaceDraft(drafts, occurrenceIndex, draft.copy(sets = draft.sets + SessionSetDraft())) { drafts = it }
}, accent = colors.success)
}
TrainlogIconAction(TrainlogIcons.DeleteOutline, "Supprimer cette occurrence",
{ pendingOccurrenceRemoval = occurrenceIndex },
Modifier.testTag("remove-completed-occurrence-${draft.entryId}"), colors.error)
}
}
message?.let { TrainlogInfo(it, colors.error) }
TrainlogPrimaryAction("Enregistrer", "Appliquer toute la correction dans une transaction.", {
when (val result = repository.correctCompletedSession(sessionId, SessionDraft(drafts, original.summary.sessionType))) {
CorrectCompletedSessionResult.Saved -> onFinished(true)
is CorrectCompletedSessionResult.Invalid -> message = result.message
is CorrectCompletedSessionResult.DatabaseError -> message = result.message
}
})
TrainlogAction("Annuler", "Quitter sans modifier la base.", { onFinished(false) }, accent = colors.muted)
}
pendingSetRemoval?.let { (occurrence, set) ->
DestructiveConfirmationDialog("Supprimer cette série ?", "Ce fait réalisé disparaîtra de la séance corrigée.", "Supprimer", { pendingSetRemoval = null }) {
val draft = drafts[occurrence]
replaceDraft(drafts, occurrence, draft.copy(sets = draft.sets.filterIndexed { index, _ -> index != set })) { drafts = it }
pendingSetRemoval = null
}
}
pendingOccurrenceRemoval?.let { occurrence ->
val draft = drafts[occurrence]
DestructiveConfirmationDialog("Supprimer cette occurrence ?",
"Impact : ${draft.sets.size} série(s) réalisée(s)${if (draft.continuousDurationSeconds > 0) ", activité continue" else ""}${if (draft.maxWeightKg != null) ", MAX" else ""}. Ses ressentis et révisions seront supprimés avec leur parent.",
"Supprimer", { pendingOccurrenceRemoval = null }) {
drafts = drafts.filterIndexed { index, _ -> index != occurrence }
pendingOccurrenceRemoval = null
}
}
}
private fun SessionExerciseDetail.correctionDraft() = SessionExerciseDraft(
entryId = entryId,
exercise = ExerciseProfile(exerciseId, exerciseName, exerciseName, recordingMode, trackingMode, dataFields),
equipmentId = equipmentId,
maxWeightKg = maxWeightKg,
plan = plan,
sets = sets,
continuousDurationSeconds = continuousDurationSeconds,
speedKmh = speedKmh,
distanceKm = distanceKm,
)
private inline fun replaceDraft(items: List<SessionExerciseDraft>, index: Int, value: SessionExerciseDraft, update: (List<SessionExerciseDraft>) -> Unit) =
update(items.toMutableList().apply { this[index] = value })
private inline fun replaceSet(items: List<SessionExerciseDraft>, occurrence: Int, set: Int, value: SessionSetDraft, update: (List<SessionExerciseDraft>) -> Unit) {
val draft = items[occurrence]
replaceDraft(items, occurrence, draft.copy(sets = draft.sets.toMutableList().apply { this[set] = value }), update)
}

View file

@ -8,6 +8,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -111,10 +114,16 @@ fun ExerciseScreen(
var message by state.message
var editedExerciseId by state.editedExerciseId
val profileLocked =
editedExerciseId?.let {
!repository.canEditExerciseProfile(it)
} ?: false
val originalProfile = remember(editedExerciseId) {
editedExerciseId?.let { id -> repository.listExercises().firstOrNull { it.exerciseId == id } }
}
var confirmedIncompatible by remember(editedExerciseId) { mutableStateOf(false) }
var confirmingIncompatible by remember(editedExerciseId) { mutableStateOf(false) }
val incompatibleProfileChange = originalProfile?.let {
it.recordingMode != recordingMode || it.trackingMode != trackingMode ||
it.dataFields != ((if (speed) ExerciseDataFields.SPEED_KMH else 0) or
(if (distance) ExerciseDataFields.DISTANCE_KM else 0))
} == true
fun startEditing(exercise: ExerciseProfile) {
/* WHY: edit state copies catalog metadata for presentation only. The
@ -159,9 +168,8 @@ fun ExerciseScreen(
selected =
recordingMode ==
RecordingMode.SETS,
enabled = !profileLocked,
enabled = true,
onClick = {
if (profileLocked) return@TrainlogChoice
recordingMode =
RecordingMode.SETS
@ -176,9 +184,8 @@ fun ExerciseScreen(
selected =
recordingMode ==
RecordingMode.CONTINUOUS,
enabled = !profileLocked,
enabled = true,
onClick = {
if (profileLocked) return@TrainlogChoice
recordingMode =
RecordingMode.CONTINUOUS
@ -203,9 +210,8 @@ fun ExerciseScreen(
selected =
trackingMode ==
TrackingMode.REPS,
enabled = !profileLocked,
enabled = true,
onClick = {
if (profileLocked) return@TrainlogChoice
trackingMode =
TrackingMode.REPS
@ -219,9 +225,8 @@ fun ExerciseScreen(
selected =
trackingMode ==
TrackingMode.DURATION,
enabled = !profileLocked,
enabled = true,
onClick = {
if (profileLocked) return@TrainlogChoice
trackingMode =
TrackingMode.DURATION
@ -241,9 +246,8 @@ fun ExerciseScreen(
TrainlogChoice(
label = "Vitesse",
selected = speed,
enabled = !profileLocked,
enabled = true,
onClick = {
if (profileLocked) return@TrainlogChoice
speed = !speed
message = null
},
@ -252,9 +256,8 @@ fun ExerciseScreen(
TrainlogChoice(
label = "Distance",
selected = distance,
enabled = !profileLocked,
enabled = true,
onClick = {
if (profileLocked) return@TrainlogChoice
distance =
!distance
@ -344,12 +347,10 @@ fun ExerciseScreen(
color = colors.accent,
)
if (profileLocked) {
if (editedExerciseId != null && repository.canEditExerciseProfile(editedExerciseId!!).not()) {
TrainlogInfo(
text =
"Profil verrouillé : cet exercice est déjà référencé " +
"par une séance terminée ou le brouillon actif. " +
"Le nom reste modifiable.",
"Les séances passées et l'occurrence déjà présente dans le brouillon resteront inchangées. Le nouveau profil s'appliquera aux prochaines utilisations.",
color = colors.warning,
)
}
@ -370,6 +371,10 @@ fun ExerciseScreen(
"Conserver l'identité et mettre à jour le catalogue."
},
onClick = save@{
if (editedExerciseId != null && incompatibleProfileChange && !confirmedIncompatible) {
confirmingIncompatible = true
return@save
}
if (editedExerciseId == null &&
recordingMode == RecordingMode.SETS && primaryZoneId == null) {
message = "Une zone principale est requise pour un nouvel exercice musculaire."
@ -560,6 +565,17 @@ fun ExerciseScreen(
)
}
}
if (confirmingIncompatible) AlertDialog(
onDismissRequest = { confirmingIncompatible = false },
title = { Text("Modifier le mode de suivi ?") },
text = { Text("Les anciennes séances et le brouillon actif resteront inchangés. Les nouvelles utilisations suivront le nouveau profil.") },
confirmButton = { TextButton(onClick = {
confirmedIncompatible = true
confirmingIncompatible = false
message = "Transition confirmée. Enregistrez pour appliquer le profil aux prochaines utilisations."
}) { Text("Modifier") } },
dismissButton = { TextButton(onClick = { confirmingIncompatible = false }) { Text("Annuler") } },
)
}
@Composable

View file

@ -25,7 +25,8 @@ fun SessionDetailScreen(
repository: TrainlogRepository,
sessionId: String?,
onBack: () -> Unit,
onResumeMaxTest: () -> Unit,
onCorrect: () -> Unit = {},
onResumeMaxTest: () -> Unit = {},
) {
val colors =
LocalTrainlogColors.current
@ -103,6 +104,13 @@ fun SessionDetailScreen(
"${detail.summary.exerciseCount} exercice(s)"
)
TrainlogAction(
label = "Modifier la séance",
description = "Corriger les faits enregistrés sans changer l'identité de la séance.",
accent = colors.accent,
onClick = onCorrect,
)
if (detail.summary.sessionType == SessionType.MAX_TEST) {
TrainlogAction(
label = "Reprendre ce Test max",
@ -200,7 +208,7 @@ fun SessionDetailScreen(
)
}
exercise.feedback.forEach { item ->
TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, item.observedAt)} · ${item.rawText}${if (item.modified) " · Modifié" else ""}")
TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt)} · ${item.rawText}${if (item.modified) " · Modifié" else ""}")
TrainlogAction("Modifier", "Corriger le texte sans déplacer l’observation.",
onClick = { editingFeedbackId = item.feedbackId }, accent = colors.muted)
if (editingFeedbackId == item.feedbackId) FeedbackEditor(
@ -232,7 +240,7 @@ fun SessionDetailScreen(
TrainlogFrame(title = "Suivi après séance") {
detail.followUps.forEach { item ->
TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, item.observedAt), color = colors.accent)
TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt), color = colors.accent)
TrainlogInfo(item.rawText + if (item.modified) " · Modifié" else "")
TrainlogAction("Modifier", "Corriger le texte sans changer le repère H+.",
onClick = { editingFollowUpId = item.followupId }, accent = colors.muted)

View file

@ -9,24 +9,58 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.labfytools.trainlog.data.CatalogInboxResult
import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncExportResult
import com.labfytools.trainlog.data.SyncReceiptResult
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.data.logExchangeMediaStore
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/* Package-visible for orchestration tests: this is the production ordering
* boundary, not a duplicate test-only implementation. */
internal suspend fun publishBundleAndRequest(
exporter: SyncExporter,
requestOutbox: SyncRequestOutbox,
): SyncRequestResult = withContext(Dispatchers.IO) {
logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.begin")
when (val opened = exporter.openSafSnapshot()) {
is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Error ->
SyncRequestResult.Error("Préparation Android → PC : ${opened.message}")
is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Ready ->
when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
is SyncExportResult.Error -> SyncRequestResult.Error(
"Préparation Android → PC : ${publication.message}",
)
is SyncExportResult.Exported -> {
logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.success")
logExchangeMediaStore("trainlog-sync-request-v1.json", "coordinator.request.begin")
requestOutbox.requestSync(opened.snapshot)
}
}
}
}
@Composable
fun SyncScreen(
inbox: SyncCatalogInbox,
exporter: SyncExporter,
requestOutbox: SyncRequestOutbox,
onCatalogChanged: () -> Unit,
onBack: () -> Unit,
) {
val colors =
LocalTrainlogColors.current
val coroutineScope = rememberCoroutineScope()
var status by
remember {
@ -54,6 +88,8 @@ fun SyncScreen(
)
}
var syncRunning by remember { mutableStateOf(false) }
LaunchedEffect(
pendingRequestId
) {
@ -63,10 +99,9 @@ fun SyncScreen(
repeat(60) {
when (
val receipt =
inbox.readSyncReceipt(
requestId
)
val receipt = withContext(Dispatchers.IO) {
inbox.readSyncReceipt(requestId)
}
) {
SyncReceiptResult.Pending -> {
delay(1000)
@ -109,8 +144,9 @@ fun SyncScreen(
}
when (
val catalog =
val catalog = withContext(Dispatchers.IO) {
inbox.importPcCatalog()
}
) {
is CatalogInboxResult.Imported -> {
success = true
@ -199,10 +235,8 @@ fun SyncScreen(
}
if (saved) {
when (
val result =
inbox.importPcCatalog()
) {
coroutineScope.launch {
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
is CatalogInboxResult.Imported -> {
success = true
@ -228,6 +262,7 @@ fun SyncScreen(
/* Keep permission state. */
}
}
}
}
}
}
@ -249,8 +284,7 @@ fun SyncScreen(
TrainlogAction(
label =
if (
pendingRequestId !=
null
pendingRequestId != null || syncRunning
) {
"Synchronisation en cours..."
} else {
@ -262,8 +296,7 @@ fun SyncScreen(
colors.success,
onClick = {
if (
pendingRequestId !=
null
pendingRequestId != null || syncRunning
) {
return@TrainlogAction
}
@ -277,33 +310,31 @@ fun SyncScreen(
return@TrainlogAction
}
when (
val result =
requestOutbox
.requestSync()
) {
is SyncRequestResult.Requested -> {
success = true
pendingRequestId =
result.requestId
status =
"Demande envoyée · attente du PC..."
}
SyncRequestResult.Unsupported -> {
success = false
status =
"Android non supporté."
}
is SyncRequestResult.Error -> {
success = false
status =
result.message
/* WHY: the request file is the PC daemon's start signal.
* CONTRACT: every current Android-origin companion must be
* published from one prepared repository snapshot before
* that signal becomes visible; otherwise the PC can mix a
* fresh V3 file with stale or absent causal companions. */
syncRunning = true
coroutineScope.launch {
try {
when (val result = publishBundleAndRequest(exporter, requestOutbox)) {
is SyncRequestResult.Requested -> {
success = true
pendingRequestId = result.requestId
status = "Demande envoyée · attente du PC..."
}
SyncRequestResult.Unsupported -> {
success = false
status = "Android non supporté."
}
is SyncRequestResult.Error -> {
success = false
status = result.message
}
}
} finally {
syncRunning = false
}
}
},
@ -346,10 +377,8 @@ fun SyncScreen(
description =
"Action de récupération manuelle si nécessaire.",
onClick = {
when (
val result =
inbox.importPcCatalog()
) {
coroutineScope.launch {
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
is CatalogInboxResult.Imported -> {
success = true
@ -385,6 +414,7 @@ fun SyncScreen(
result.message
}
}
}
},
)
}

View file

@ -10,6 +10,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
@ -20,6 +21,9 @@ import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.TrainlogRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: SyncCatalogInbox, requestOutbox: SyncRequestOutbox, appState: TrainlogAppState) {
@ -32,16 +36,19 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
var draftRevision by remember { mutableIntStateOf(0) }
var draftMessage by remember { mutableStateOf<String?>(null) }
val navigationController = appState.navigationController
val coroutineScope = rememberCoroutineScope()
fun exportSnapshot() {
coroutineScope.launch { exporter.exportMobileBundle() }
}
/* CONTRACT: automatic exchange work belongs to the application lifecycle.
* Re-entering a route must never trigger a second import or export. */
LaunchedEffect(Unit) {
when (inbox.importPcCatalog()) {
when (withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
is CatalogInboxResult.Imported -> catalogRevision++
CatalogInboxResult.FolderNotAuthorized, CatalogInboxResult.FileNotFound,
is CatalogInboxResult.Error -> Unit
}
exporter.exportMobileBundle()
}
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
@ -88,7 +95,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
)
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exporter.exportMobileBundle(); draftRevision++ })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."
draftRevision++
@ -99,19 +106,23 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
openSection(AppSection.SESSIONS)
})
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }) { draftRevision++; open(AppRoute.SessionEditor) }
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) }
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
if (saved) exportSnapshot()
back()
}
AppRoute.Exercises -> ExerciseCatalogueScreen(repository, exerciseState, { open(AppRoute.ExerciseCreate(AppRoute.Exercises)) }, { open(AppRoute.ExerciseDetail(it)) })
is AppRoute.ExerciseDetail -> ExerciseDetailScreen(repository, route.exerciseId, { open(AppRoute.ExerciseEdit(route.exerciseId, route)) }, { open(AppRoute.LatestMaxima) })
is AppRoute.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exporter.exportMobileBundle(); catalogRevision++; back() }
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exporter.exportMobileBundle(); catalogRevision++; back() }
is AppRoute.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exportSnapshot(); catalogRevision++; back() }
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exportSnapshot(); catalogRevision++; back() }
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exporter.exportMobileBundle(); catalogRevision++; back() }
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
AppRoute.Statistics -> StatisticsDashboard(repository)
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exporter.exportMobileBundle() }, { back() })
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(inbox, requestOutbox, { exporter.exportMobileBundle(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(inbox) { exporter.exportMobileBundle(); catalogRevision++ }
AppRoute.Sync -> SyncScreen(inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(inbox) { exportSnapshot(); catalogRevision++ }
}}
}
@ -134,6 +145,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
private fun AppRoute.stateKey(): String = when (this) {
is AppRoute.SessionDetail -> "session:${sessionId}"
is AppRoute.SessionCorrection -> "session-correction:${sessionId}"
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}"

View file

@ -61,7 +61,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(15, scalar(db, "PRAGMA user_version"))
assertEquals(16, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -64,11 +64,11 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(15, scalar(db, "PRAGMA user_version"))
assertEquals(16, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */
val after = snapshot(db).filterKeys { it != "exercise_aliases" }
before.filterKeys { it !in setOf("exercises", "exercise_body_zones", "exercise_body_zone_sync") }
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
before.filterKeys { it !in setOf("exercises", "exercise_body_zones", "exercise_body_zone_sync", "exercise_profile_state", "exercise_profile_revisions") }
.forEach { (table, rows) -> assertEquals(rows, after[table]) }
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM exercise_aliases"))
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }

View file

@ -323,7 +323,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(15, cursor.getInt(0))
assertTrue(cursor.moveToFirst()); assertEquals(16, cursor.getInt(0))
}
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -0,0 +1,216 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.*
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk=[35])
class ExerciseProfileStateSyncTest {
@Test fun `v16 prototype state normalizes atomically and second open is idempotent`() {
val context=ApplicationProvider.getApplicationContext<Context>()
val name="profile-pr1-${UUID.randomUUID()}.db"
var repository=TrainlogRepository(context,name)
try {
val created=repository.createExercise(NewExerciseProfile("Prototype",RecordingMode.SETS,TrackingMode.REPS,0))
as CreateExerciseResult.Created
repository.close()
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path,null,SQLiteDatabase.OPEN_READWRITE).use { db ->
val rowId=db.rawQuery("SELECT id FROM exercises WHERE exercise_id=?",arrayOf(created.exercise.exerciseId))
.use { c -> assertTrue(c.moveToFirst());c.getLong(0) }
db.execSQL("DELETE FROM exercise_profile_revisions WHERE exercise_row_id=?",arrayOf(rowId))
db.execSQL("UPDATE exercise_profile_state SET revision_id='pr1|sets|reps|0',parent_revision_id=NULL,legacy_seed=1 WHERE exercise_row_id=?",arrayOf(rowId))
db.execSQL("INSERT INTO exercise_profile_revisions VALUES(?,'pr1|sets|reps|0',NULL,'sets','reps',0,1)",arrayOf(rowId))
}
repository=TrainlogRepository(context,name)
var item=JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0)
assertEquals("pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b",item.getString("revision_id"))
assertEquals(listOf("pr_legacy_v1","pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b"),
(0 until item.getJSONArray("history").length()).map { item.getJSONArray("history").getJSONObject(it).getString("revision_id") })
repository.close();repository=TrainlogRepository(context,name)
item=JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0)
assertEquals(2,item.getJSONArray("history").length())
assertEquals(TrackingMode.REPS,repository.listExercises().single().trackingMode)
} finally { repository.close();context.deleteDatabase(name) }
}
@Test fun `new profile revision matches fixed width cross-runtime UUIDv5`() {
val context=ApplicationProvider.getApplicationContext<Context>()
val name="profile-revision-${UUID.randomUUID()}.db"
val repository=TrainlogRepository(context,name)
try {
val created=repository.createExercise(NewExerciseProfile("UUID parity",RecordingMode.SETS,TrackingMode.REPS,0))
assertTrue(created is CreateExerciseResult.Created)
val item=JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0)
assertEquals("pr_legacy_v1",item.getString("parent_revision_id"))
assertEquals("pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b",item.getString("revision_id"))
assertEquals(2,item.getJSONArray("history").length())
} finally { repository.close();context.deleteDatabase(name) }
}
@Test fun `pc legacy Planche profile changes future capture without rewriting old occurrence`() {
val context=ApplicationProvider.getApplicationContext<Context>()
val name="profile-state-${UUID.randomUUID()}.db"
val id="ex_999fd503-ce89-49f9-979d-c19e6adbe80d"
var repository=TrainlogRepository(context,name)
try {
fun catalog(tracking:String)=JSONObject().put("format","trainlog-pc-catalog").put("version",1)
.put("exercises",JSONArray().put(JSONObject().put("exercise_id",id)
.put("name","Planche droite sol").put("recording_mode","sets")
.put("tracking_mode",tracking).put("data_fields",0))).toString()
assertTrue(repository.applyPcCatalogJson(catalog("reps")) is PcCatalogImportResult.Applied)
val reps=repository.listExercises().single{it.exerciseId==id}
val insertedState=JSONObject(repository.buildExerciseProfileStateJson())
.getJSONArray("exercises").let { entries ->
(0 until entries.length()).map { entries.getJSONObject(it) }
.single { it.getString("exercise_id") == id }
}
assertEquals("pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b",insertedState.getString("revision_id"))
val old=repository.saveSession(SessionDraft(listOf(SessionExerciseDraft(
exercise=reps,sets=listOf(SessionSetDraft(reps=3)))))) as SaveSessionResult.Saved
repository.close()
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path,null,SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("UPDATE exercise_profile_state SET revision_id='pr_legacy_v1',parent_revision_id=NULL,legacy_seed=1")
}
repository=TrainlogRepository(context,name)
val state=JSONObject().put("format","trainlog-exercise-profile-state").put("version",1)
.put("generated_at","2026-09-13T10:00:00+02:00").put("exercises",JSONArray().put(
JSONObject().put("exercise_id",id).put("recording_mode","sets")
.put("tracking_mode","duration").put("data_fields",0)
.put("load_semantics",JSONObject.NULL).put("machine_variant",JSONObject.NULL)
.put("machine_provenance",JSONObject.NULL).put("scientific_profile_id",JSONObject.NULL)
.put("science_state","unresolved").put("legacy_equipment_id",JSONObject.NULL)
.put("revision_id","pr_legacy_v1").put("parent_revision_id",JSONObject.NULL)
.put("legacy_seed",true).put("history",JSONArray().put(
JSONObject().put("revision_id","pr_legacy_v1").put("parent_revision_id",JSONObject.NULL)
.put("recording_mode","sets").put("tracking_mode","duration")
.put("data_fields",0).put("legacy_seed",true))))).toString()
assertTrue(repository.applyExerciseProfileStateJson(state) is ExerciseProfileStateImportResult.Applied)
assertTrue(repository.applyExerciseProfileStateJson(state) is ExerciseProfileStateImportResult.Applied)
assertEquals(TrackingMode.REPS,repository.getSessionDetail(old.sessionId)!!.exercises.single().trackingMode)
val current=repository.listExercises().single{it.exerciseId==id}
assertEquals(TrackingMode.DURATION,current.trackingMode)
val future=repository.saveSession(SessionDraft(listOf(SessionExerciseDraft(
exercise=current,sets=listOf(SessionSetDraft(durationSeconds=30)))))) as SaveSessionResult.Saved
assertEquals(TrackingMode.DURATION,repository.getSessionDetail(future.sessionId)!!.exercises.single().trackingMode)
} finally { repository.close();context.deleteDatabase(name) }
}
@Test fun `transitive stale ancestor is retained and coercing scalar types are rejected`() {
val context=ApplicationProvider.getApplicationContext<Context>();val name="profile-chain-${UUID.randomUUID()}.db"
val repository=TrainlogRepository(context,name)
try {
val created=repository.createExercise(NewExerciseProfile("Chain",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created
fun revision(parent:String,tracking:String)=exerciseProfileRevision(parent,"sets",tracking,0)
val root="pr_legacy_v1";val initial=revision(root,"reps");val first=revision(initial,"duration")
val second=revision(first,"reps");val third=revision(second,"duration")
fun h(id:String,parent:String?,tracking:String,legacy:Boolean)=JSONObject().put("revision_id",id)
.put("parent_revision_id",parent?:JSONObject.NULL).put("recording_mode","sets")
.put("tracking_mode",tracking).put("data_fields",0).put("legacy_seed",legacy)
fun state(current:String,parent:String?,tracking:String,history:JSONArray,version:Any=1)=JSONObject()
.put("format","trainlog-exercise-profile-state").put("version",version)
.put("generated_at","2026-09-13T10:00:00+02:00").put("exercises",JSONArray().put(JSONObject()
.put("exercise_id",created.exercise.exerciseId).put("recording_mode","sets").put("tracking_mode",tracking).put("data_fields",0)
.put("load_semantics",JSONObject.NULL).put("machine_variant",JSONObject.NULL).put("machine_provenance",JSONObject.NULL)
.put("scientific_profile_id",JSONObject.NULL).put("science_state","unresolved").put("legacy_equipment_id",JSONObject.NULL)
.put("revision_id",current).put("parent_revision_id",parent?:JSONObject.NULL).put("legacy_seed",false).put("history",history))).toString()
val chain=JSONArray().put(h(root,null,"reps",true)).put(h(initial,root,"reps",false))
.put(h(first,initial,"duration",false)).put(h(second,first,"reps",false)).put(h(third,second,"duration",false))
assertTrue(repository.applyExerciseProfileStateJson(state(third,second,"duration",chain)) is ExerciseProfileStateImportResult.Applied)
val stale=JSONArray().put(h(root,null,"reps",true)).put(h(initial,root,"reps",false)).put(h(first,initial,"duration",false))
assertTrue(repository.applyExerciseProfileStateJson(state(first,initial,"duration",stale)) is ExerciseProfileStateImportResult.Applied)
assertEquals(third,JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0).getString("revision_id"))
assertTrue(repository.applyExerciseProfileStateJson(state(first,initial,"duration",stale,"1")) is ExerciseProfileStateImportResult.Invalid)
val coerced=JSONObject(state(first,initial,"duration",stale)).also { it.getJSONArray("exercises").getJSONObject(0).put("legacy_seed","false") }.toString()
assertTrue(repository.applyExerciseProfileStateJson(coerced) is ExerciseProfileStateImportResult.Invalid)
val oversizedTop=JSONObject(state(first,initial,"duration",stale)).also {
it.getJSONArray("exercises").getJSONObject(0).put("data_fields",4294967296L)
}.toString()
assertTrue(repository.applyExerciseProfileStateJson(oversizedTop) is ExerciseProfileStateImportResult.Invalid)
val oversizedHistory=JSONObject(state(first,initial,"duration",stale)).also {
it.getJSONArray("exercises").getJSONObject(0).getJSONArray("history").getJSONObject(2)
.put("data_fields",4294967296L)
}.toString()
assertTrue(repository.applyExerciseProfileStateJson(oversizedHistory) is ExerciseProfileStateImportResult.Invalid)
} finally { repository.close();context.deleteDatabase(name) }
}
@Test fun `profile history rejects causal integrity matrix A through L`() {
val context=ApplicationProvider.getApplicationContext<Context>()
val name="profile-negative-${UUID.randomUUID()}.db"
val repository=TrainlogRepository(context,name)
try {
val created=repository.createExercise(
NewExerciseProfile("Negative matrix",RecordingMode.SETS,TrackingMode.REPS,0),
) as CreateExerciseResult.Created
val root="pr_legacy_v1"
val child=exerciseProfileRevision(root,"sets","reps",0)
fun record(id:String,parent:String?,tracking:String,legacy:Boolean)=JSONObject()
.put("revision_id",id).put("parent_revision_id",parent?:JSONObject.NULL)
.put("recording_mode","sets").put("tracking_mode",tracking)
.put("data_fields",0).put("legacy_seed",legacy)
fun valid()=JSONObject().put("format","trainlog-exercise-profile-state").put("version",1)
.put("generated_at","2026-09-13T10:00:00+02:00")
.put("exercises",JSONArray().put(JSONObject()
.put("exercise_id",created.exercise.exerciseId).put("recording_mode","sets")
.put("tracking_mode","reps").put("data_fields",0)
.put("load_semantics",JSONObject.NULL).put("machine_variant",JSONObject.NULL)
.put("machine_provenance",JSONObject.NULL).put("scientific_profile_id",JSONObject.NULL)
.put("science_state","unresolved").put("legacy_equipment_id",JSONObject.NULL)
.put("revision_id",child).put("parent_revision_id",root).put("legacy_seed",false)
.put("history",JSONArray().put(record(root,null,"reps",true))
.put(record(child,root,"reps",false)))))
fun exercise(payload:JSONObject)=payload.getJSONArray("exercises").getJSONObject(0)
fun history(payload:JSONObject)=exercise(payload).getJSONArray("history")
fun copy()=JSONObject(valid().toString())
val cases=linkedMapOf<String,JSONObject>()
cases["A-root-omitted"]=copy().also { history(it).remove(0) }
cases["B-root-parent"]=copy().also { history(it).getJSONObject(0).put("parent_revision_id","detached") }
cases["C-root-not-legacy"]=copy().also { history(it).getJSONObject(0).put("legacy_seed",false) }
cases["D-second-legacy"]=copy().also { history(it).getJSONObject(1).put("legacy_seed",true) }
cases["E-middle-missing"]=copy().also {
val grandchild=exerciseProfileRevision(child,"sets","duration",0)
exercise(it).put("revision_id",grandchild).put("parent_revision_id",child)
.put("tracking_mode","duration")
exercise(it).put("history",JSONArray().put(record(root,null,"reps",true))
.put(record(grandchild,child,"duration",false)))
}
cases["F-nonprevious-parent"]=copy().also { history(it).getJSONObject(1).put("parent_revision_id","detached") }
cases["G-wrong-uuid"]=copy().also {
history(it).getJSONObject(1).put("revision_id",exerciseProfileRevision(root,"sets","duration",0))
}
cases["H-current-absent"]=copy().also {
exercise(it).put("revision_id",exerciseProfileRevision(root,"sets","duration",0))
.put("tracking_mode","duration")
}
cases["I-current-not-terminal"]=copy().also {
exercise(it).put("revision_id",root).put("parent_revision_id",JSONObject.NULL)
.put("legacy_seed",true)
}
cases["J-duplicate"]=copy().also { history(it).put(JSONObject(history(it).getJSONObject(1).toString())) }
cases["K-33-revisions"]=copy().also {
val repeated=record(root,null,"reps",true);val oversized=JSONArray()
repeat(33) { oversized.put(JSONObject(repeated.toString())) }
exercise(it).put("history",oversized)
}
cases["L-malformed-pr1"]=copy().also {
exercise(it).put("revision_id","pr1|sets|reps|00")
.put("parent_revision_id",JSONObject.NULL).put("legacy_seed",true)
.put("history",JSONArray().put(record("pr1|sets|reps|00",null,"reps",true)))
}
for ((label,payload) in cases) {
assertTrue(label,repository.applyExerciseProfileStateJson(payload.toString()) is
ExerciseProfileStateImportResult.Invalid)
}
} finally { repository.close();context.deleteDatabase(name) }
}
}

View file

@ -5,19 +5,20 @@ import org.junit.Test
class FeedbackElapsedLabelTest {
@Test fun `whole hours floor and never switch to days`() {
val end="2026-09-01T10:00:00+02:00"
listOf(0 to "2026-09-01T10:59:59+02:00",8 to "2026-09-01T18:00:00+02:00",23 to "2026-09-02T09:59:59+02:00",47 to "2026-09-03T09:00:00+02:00",72 to "2026-09-04T10:00:00+02:00").forEach{(h,t)->assertEquals("H+$h",sessionFollowUpElapsedLabel(end,t))}
listOf(0 to "2026-09-01T10:59:59+02:00",8 to "2026-09-01T18:00:00+02:00",23 to "2026-09-02T09:59:59+02:00",47 to "2026-09-03T09:00:00+02:00",72 to "2026-09-04T10:00:00+02:00").forEach{(h,t)->assertEquals("H+$h",sessionFollowUpElapsedLabel(end,null,t))}
}
@Test fun `unknown followup anchor is explicit`()=assertEquals("H+?",sessionFollowUpElapsedLabel(null,"2026-09-01T18:00:00+02:00"))
@Test fun `unknown followup anchor is explicit`()=assertEquals("H+?",sessionFollowUpElapsedLabel(null,null,"2026-09-01T18:00:00+02:00"))
@Test fun `exercise feedback distinguishes session time after time and unknown`() {
val end="2026-09-01T10:00:00+02:00"
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:20:00+02:00"))
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:59:59+02:00"))
assertEquals("H+0",exerciseFeedbackElapsedLabel(end,end))
assertEquals("H+8",exerciseFeedbackElapsedLabel(end,"2026-09-01T18:00:00+02:00"))
assertEquals("Ressenti",exerciseFeedbackElapsedLabel(null,"2026-09-01T09:20:00+02:00"))
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:20:00+02:00"))
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:59:59+02:00"))
assertEquals("H+?",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:30:00+02:00","2026-09-01T09:20:00+02:00"))
assertEquals("H+0",exerciseFeedbackElapsedLabel(end,null,end))
assertEquals("H+8",exerciseFeedbackElapsedLabel(end,null,"2026-09-01T18:00:00+02:00"))
assertEquals("≈H+10",exerciseFeedbackElapsedLabel(null,"2026-09-01T08:00:00+02:00","2026-09-01T18:00:00+02:00"))
}
@Test fun `helpers never render negative H plus`() {
val labels=listOf(exerciseFeedbackElapsedLabel("2026-09-01T10:00:00+02:00","2026-09-01T09:00:00+02:00"),sessionFollowUpElapsedLabel("2026-09-01T10:00:00+02:00","2026-09-01T09:00:00+02:00"))
val labels=listOf(exerciseFeedbackElapsedLabel("2026-09-01T10:00:00+02:00",null,"2026-09-01T09:00:00+02:00"),sessionFollowUpElapsedLabel("2026-09-01T10:00:00+02:00",null,"2026-09-01T09:00:00+02:00"))
assertTrue(labels.none{it.startsWith("H+-")})
}
}

View file

@ -0,0 +1,151 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class HistoricalSessionProfileImportTest {
private lateinit var context: Context
private lateinit var databaseName: String
private lateinit var repository: TrainlogRepository
@Before fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseName = "historical-profile-${UUID.randomUUID()}.db"
repository = TrainlogRepository(context, databaseName)
}
@After fun tearDown() {
repository.close()
context.deleteDatabase(databaseName)
}
@Test fun `V2 and V3 import historical profiles independently of current catalog`() {
for (version in listOf(2, 3)) {
val same = create("Same $version", RecordingMode.SETS, TrackingMode.REPS, 0)
val tracking = create("Tracking $version", RecordingMode.SETS, TrackingMode.DURATION, 0)
val recording = create("Recording $version", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0)
val fieldsRemoved = create("Fields removed $version", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0)
val fieldsAdded = create("Fields added $version", RecordingMode.CONTINUOUS, TrackingMode.DURATION,
ExerciseDataFields.SPEED_KMH)
val cases = listOf(
Case(same, "sets", "reps", 0, sets = JSONArray().put(JSONObject().put("reps", 8))),
Case(tracking, "sets", "reps", 0, sets = JSONArray().put(JSONObject().put("reps", 9))),
Case(recording, "sets", "reps", 0, sets = JSONArray().put(JSONObject().put("reps", 10))),
Case(fieldsRemoved, "continuous", "duration", ExerciseDataFields.SPEED_KMH,
continuous = JSONObject().put("duration_seconds", 600).put("speed_kmh", 8.5)),
Case(fieldsAdded, "continuous", "duration", 0,
continuous = JSONObject().put("duration_seconds", 700)),
)
val payload = artifact(version, cases, "se_${version}_historical")
val revisionsBefore = revisionCount()
assertEquals(MobileSessionImportResult.Applied(1, 0, 0, 0), import(version, payload))
val detail = repository.getSessionDetail("se_${version}_historical")!!
assertEquals(listOf("reps", "reps", "reps", "duration", "duration"),
detail.exercises.map { it.trackingMode.wireValue })
assertEquals(listOf("sets", "sets", "sets", "continuous", "continuous"),
detail.exercises.map { it.recordingMode.wireValue })
assertEquals(listOf(0, 0, 0, ExerciseDataFields.SPEED_KMH, 0),
detail.exercises.map { it.dataFields })
assertEquals(listOf(8, 9, 10), detail.exercises.take(3).map { it.sets.single().reps })
assertEquals(8.5, detail.exercises[3].speedKmh!!, 0.0)
assertEquals(null, detail.exercises[4].speedKmh)
/* INVARIANT: importing occurrence history neither edits CURRENT
* profiles nor manufactures profile revisions. */
assertEquals(listOf("reps", "duration", "duration", "duration", "duration"),
cases.map { expected -> repository.listExercises()
.single { it.exerciseId == expected.exercise.exerciseId }.trackingMode.wireValue })
assertEquals(revisionsBefore, revisionCount())
assertEquals(MobileSessionImportResult.Applied(0, 1, 0, 0), import(version, payload))
assertEquals(revisionsBefore, revisionCount())
assertEquals(1, repository.listSessions().count { it.sessionId == "se_${version}_historical" })
}
}
@Test fun `invalid historical payload and unknown identity remain rejected`() {
val exercise = create("Strict history", RecordingMode.SETS, TrackingMode.DURATION, 0)
val invalid = Case(exercise, "sets", "reps", 0,
sets = JSONArray().put(JSONObject().put("duration_seconds", 30)))
val invalidResult = import(3, artifact(3, listOf(invalid), "se_invalid_history"))
assertTrue(invalidResult is MobileSessionImportResult.Invalid)
assertTrue((invalidResult as MobileSessionImportResult.Invalid).message.contains("Série V2 invalide"))
val unknown = ExerciseProfile(
"ex_00000000-0000-4000-8000-000000000099", "Unknown", "unknown",
RecordingMode.SETS, TrackingMode.REPS, 0,
)
val unknownResult = import(2, artifact(2, listOf(Case(unknown, "sets", "reps", 0,
sets = JSONArray().put(JSONObject().put("reps", 1)))), "se_unknown_history"))
assertTrue(unknownResult is MobileSessionImportResult.Invalid)
assertTrue((unknownResult as MobileSessionImportResult.Invalid).message.contains("Exercice V2 inconnu"))
assertTrue(repository.listSessions().none { it.sessionId in setOf("se_invalid_history", "se_unknown_history") })
}
private data class Case(
val exercise: ExerciseProfile,
val recording: String,
val tracking: String,
val fields: Int,
val sets: JSONArray? = null,
val continuous: JSONObject? = null,
)
private fun create(name: String, recording: RecordingMode, tracking: TrackingMode, fields: Int): ExerciseProfile =
(repository.createExercise(NewExerciseProfile(name, recording, tracking, fields)) as CreateExerciseResult.Created).exercise
private fun artifact(version: Int, cases: List<Case>, sessionId: String): JSONObject {
val catalog = JSONArray()
val entries = JSONArray()
cases.forEachIndexed { index, case ->
catalog.put(JSONObject().put("exercise_id", case.exercise.exerciseId).put("name", case.exercise.name)
.put("recording_mode", case.exercise.recordingMode.wireValue)
.put("tracking_mode", case.exercise.trackingMode.wireValue).put("data_fields", case.exercise.dataFields))
val entry = JSONObject().put("exercise_id", case.exercise.exerciseId).put("name", case.exercise.name)
.put("recording_mode", case.recording).put("tracking_mode", case.tracking).put("data_fields", case.fields)
.put("load_mode", "none").put("rest_seconds", 0)
.put("entry_id", "sxe_${version}_$index").put("position", index)
.put("equipment_id", JSONObject.NULL)
if (version == 3) entry.put("target", JSONObject.NULL)
case.sets?.let { entry.put("sets", it) }
case.continuous?.let { entry.put("continuous", it) }
entries.put(entry)
}
return JSONObject().put("format", "trainlog-mobile-export").put("version", version)
.put("generated_at", "2026-09-13T20:00:00+02:00").put("exercises", catalog)
.put("sessions", JSONArray().put(JSONObject().put("session_id", sessionId)
.put("started_at", "2026-09-13T19:00:00+02:00").put("session_type", "training")
.put("exercises", entries))).put("body_observations", JSONArray())
}
private fun import(version: Int, payload: JSONObject): MobileSessionImportResult =
if (version == 2) repository.applyPcMobileExportV2Json(payload.toString())
else repository.applyPcMobileExportV3Json(payload.toString())
private fun revisionCount(): Int = SQLiteDatabase.openDatabase(
context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READONLY,
).use { database ->
database.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions", null).use { cursor ->
cursor.moveToFirst(); cursor.getInt(0)
}
}
}

View file

@ -125,7 +125,7 @@ class RealAndroidV12MachineExerciseMigrationTest {
private fun assertValidVersionThirteen(databasePath: File) {
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
assertEquals(15, scalarInt(database, "PRAGMA user_version;"))
assertEquals(16, scalarInt(database, "PRAGMA user_version;"))
assertEquals("ok", scalarString(database, "PRAGMA integrity_check;"))
database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
}

View file

@ -33,7 +33,9 @@ class RealAndroidV14FeedbackRevisionMigrationTest {
}
TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() }
SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db->
assertEquals(15,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())}
assertEquals(16,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())}
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_state WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_revisions WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
assertEquals(before.first,values(db,"SELECT entry_id FROM draft_session_exercises ORDER BY entry_id"));assertEquals(before.second,values(db,"SELECT feedback_id||'|'||observed_at||'|'||raw_text FROM draft_exercise_feedback ORDER BY feedback_id"))
assertEquals(before.second.size,scalar(db,"SELECT COUNT(*) FROM draft_exercise_feedback_revisions"))
assertEquals(0,scalar(db,"SELECT COUNT(*) FROM draft_exercise_feedback f LEFT JOIN draft_exercise_feedback_revisions r ON r.revision_id='fr0_'||f.feedback_id WHERE r.revision_id IS NULL"))

View file

@ -61,8 +61,10 @@ class TrainingFeedbackRepositoryTest {
assertEquals(listOf(first.stableId,later.stableId),completed.map{it.feedbackId})
assertEquals(reopened.map{it.observedAt},completed.map{it.observedAt})
assertEquals(reopened.map{it.rawText},completed.map{it.rawText})
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(
android.getSessionDetail(saved.sessionId)!!.summary.endedAt,completed.first().observedAt))
assertEquals("H+?",exerciseFeedbackElapsedLabel(
android.getSessionDetail(saved.sessionId)!!.summary.endedAt,
android.getSessionDetail(saved.sessionId)!!.summary.startedAt,
completed.first().observedAt))
val exported=JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback")
assertEquals(2,exported.length())
assertEquals(setOf(first.stableId,later.stableId),(0 until exported.length()).map{exported.getJSONObject(it).getString("feedback_id")}.toSet())
@ -122,4 +124,51 @@ class TrainingFeedbackRepositoryTest {
assertEquals(listOf("avant reprise","pendant reprise"),feedback.map{it.rawText})
assertEquals(101.0,android.getSessionDetail(saved.sessionId)!!.exercises.single().maxWeightKg!!,0.0)
}
@Test fun `completed correction is atomic preserves identities feedback revisions and followup`() {
val reps=(android.createExercise(NewExerciseProfile("Correction reps",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
val otherReps=(android.createExercise(NewExerciseProfile("Correction autres reps",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
val timed=(android.createExercise(NewExerciseProfile("Correction durée",RecordingMode.SETS,TrackingMode.DURATION,0)) as CreateExerciseResult.Created).exercise
val first=SessionExerciseDraft(entryId="sxe_00000000-0000-4000-8000-0000000000c1",exercise=reps,
sets=listOf(SessionSetDraft(reps=10,weightKg=32.0),SessionSetDraft(reps=10,weightKg=32.0),SessionSetDraft(reps=10,weightKg=32.0)))
val second=SessionExerciseDraft(entryId="sxe_00000000-0000-4000-8000-0000000000c2",exercise=timed,
sets=listOf(SessionSetDraft(durationSeconds=30)))
val saved=android.saveSession(SessionDraft(listOf(first,second))) as SaveSessionResult.Saved
val feedback=android.saveExerciseFeedback(saved.sessionId,first.entryId,"initial") as SaveFeedbackResult.Saved
assertTrue(android.reviseExerciseFeedback(feedback.stableId,"révisé") is SaveFeedbackResult.Saved)
val followup=android.saveSessionFollowUp(saved.sessionId,"suivi") as SaveFeedbackResult.Saved
val corrected=first.copy(sets=listOf(
SessionSetDraft(reps=10,weightKg=33.0), SessionSetDraft(reps=8,weightKg=32.0),
SessionSetDraft(reps=12,weightKg=34.0)))
val timedCorrected=second.copy(sets=listOf(SessionSetDraft(durationSeconds=35),SessionSetDraft(durationSeconds=30)))
assertEquals(CorrectCompletedSessionResult.Saved,
android.correctCompletedSession(saved.sessionId,SessionDraft(listOf(corrected,timedCorrected))))
var detail=android.getSessionDetail(saved.sessionId)!!
assertEquals(saved.sessionId,detail.summary.sessionId)
assertEquals(listOf(first.entryId,second.entryId),detail.exercises.map{it.entryId})
assertEquals(listOf(10,8,12),detail.exercises[0].sets.map{it.reps})
assertEquals(33.0,detail.exercises[0].sets[0].weightKg!!,0.0)
assertEquals(listOf(35,30),detail.exercises[1].sets.map{it.durationSeconds})
assertEquals(feedback.stableId,detail.exercises[0].feedback.single().feedbackId)
assertTrue(detail.exercises[0].feedback.single().modified)
assertEquals(followup.stableId,detail.followUps.single().followupId)
android.close();android=TrainlogRepository(context,aName)
detail=android.getSessionDetail(saved.sessionId)!!
assertEquals(8,detail.exercises[0].sets[1].reps)
/* CONTRACT: entry identity cannot be silently rebound by the general
* replacement path; rejection occurs before its cascading delete. */
assertTrue(android.correctCompletedSession(saved.sessionId,
SessionDraft(listOf(corrected.copy(exercise=otherReps),timedCorrected))) is CorrectCompletedSessionResult.DatabaseError)
assertEquals(reps.exerciseId,android.getSessionDetail(saved.sessionId)!!.exercises[0].exerciseId)
android.close()
SQLiteDatabase.openDatabase(context.getDatabasePath(aName).path,null,SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("CREATE TRIGGER fail_completed_correction BEFORE INSERT ON performed_sets BEGIN SELECT RAISE(ABORT,'synthetic correction failure'); END;")
}
android=TrainlogRepository(context,aName)
assertTrue(android.correctCompletedSession(saved.sessionId,
SessionDraft(listOf(corrected.copy(sets=listOf(SessionSetDraft(reps=1))),timedCorrected))) is CorrectCompletedSessionResult.DatabaseError)
assertEquals(8,android.getSessionDetail(saved.sessionId)!!.exercises[0].sets[1].reps)
}
}

View file

@ -90,7 +90,7 @@ class TrainlogRepositoryDraftTest {
assertEquals(listOf(target.exerciseId), repo.listExercises().map { it.exerciseId })
assertEquals("Curl canonique", repo.listExercises().single().name)
val canonicalCatalog = JSONObject(staleCatalog.toString())
val canonicalCatalog = JSONObject(staleCatalog)
canonicalCatalog.getJSONArray("exercises").getJSONObject(0)
.put("exercise_id", target.exerciseId)
.put("name", "Curl canonique renommé")
@ -870,10 +870,16 @@ class TrainlogRepositoryDraftTest {
}
@Test
fun renameRejectsDuplicateAndInvalidNamesAndLocksReferencedProfile() {
fun renameRejectsDuplicateAndInvalidNamesAndReferencedProfileEditPreservesSnapshots() {
val repo = openRepository()
val referenced = createExercise(repo, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
createExercise(repo, "Course", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
val completed = repo.saveSession(
SessionDraft(exercises = listOf(SessionExerciseDraft(
exercise = referenced,
continuousDurationSeconds = 30,
))),
) as SaveSessionResult.Saved
assertEquals(
ActiveDraftMutationResult.Saved,
repo.saveActiveSessionDraft(
@ -887,7 +893,9 @@ class TrainlogRepositoryDraftTest {
),
),
)
assertFalse(repo.canEditExerciseProfile(referenced.exerciseId))
/* CONTRACT: references own occurrence snapshots and therefore do not
* lock the mutable CURRENT catalogue profile. */
assertTrue(repo.canEditExerciseProfile(referenced.exerciseId))
assertEquals(
EditExerciseResult.Conflict,
repo.editExercise(
@ -912,8 +920,7 @@ class TrainlogRepositoryDraftTest {
),
),
)
assertEquals(
EditExerciseResult.IncompatibleProfileChange,
assertTrue(
repo.editExercise(
ExerciseEditInput(
referenced.exerciseId,
@ -922,8 +929,20 @@ class TrainlogRepositoryDraftTest {
TrackingMode.REPS,
ExerciseDataFields.NONE,
),
),
) is EditExerciseResult.Saved,
)
val current = repo.listExercises().single { it.exerciseId == referenced.exerciseId }
assertEquals(RecordingMode.SETS, current.recordingMode)
assertEquals(TrackingMode.REPS, current.trackingMode)
val retainedDraft = loadDraft(repo).exercises.single()
val retained = retainedDraft.exercise
assertEquals(RecordingMode.CONTINUOUS, retained.recordingMode)
assertEquals(TrackingMode.DURATION, retained.trackingMode)
assertEquals(30, retainedDraft.continuousDurationSeconds)
val historical = repo.getSessionDetail(completed.sessionId)?.exercises?.single()
assertEquals(RecordingMode.CONTINUOUS, historical?.recordingMode)
assertEquals(TrackingMode.DURATION, historical?.trackingMode)
assertEquals(30, historical?.continuousDurationSeconds)
}
@Test
@ -1528,7 +1547,6 @@ class TrainlogRepositoryDraftTest {
assertTrue(repo.applyPcMobileExportV2Json(changedBody.toString()) is MobileSessionImportResult.Invalid)
val incompatibleProfile = entry("en_incompatible_profile")
.put("tracking_mode", "duration")
.put("sets", org.json.JSONArray().put(JSONObject().put("duration_seconds", 8)))
assertTrue(repo.applyPcMobileExportV2Json(
artifact(org.json.JSONArray().put(session("se_incompatible_profile", incompatibleProfile))).toString(),
@ -1651,7 +1669,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(15, cursor.getInt(0))
assertEquals(16, cursor.getInt(0))
}
db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -1778,7 +1796,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(15, cursor.getInt(0))
assertEquals(16, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
@ -2164,8 +2182,8 @@ class TrainlogRepositoryDraftTest {
}
artifact.getJSONArray("sessions").getJSONObject(0).getJSONArray("exercises")
.getJSONObject(0).getJSONObject("target").put("reps", 7)
assertTrue(source.applyPcMobileExportV3Json(artifact.toString()) is MobileSessionImportResult.Invalid)
assertEquals(6, source.getSessionDetail(sessionId)!!.exercises.single().plan!!.reps)
assertTrue(source.applyPcMobileExportV3Json(artifact.toString()) is MobileSessionImportResult.Applied)
assertEquals(7, source.getSessionDetail(sessionId)!!.exercises.single().plan!!.reps)
source.close(); repository = null
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use {

View file

@ -57,7 +57,7 @@ class DraftFeedbackUiReadbackTest {
assertEquals(ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(ActiveSessionDraft(exercises = listOf(entry))))
assertTrue(repository.saveDraftExerciseFeedback(
entry.entryId, "texte UI durable", "2026-09-01T10:00:00+02:00") is SaveFeedbackResult.Saved)
entry.entryId, "texte UI durable", java.time.OffsetDateTime.now().toString()) is SaveFeedbackResult.Saved)
val completedSessionId = mutableStateOf<String?>(null)
compose.setContent { TrainlogTheme {
@ -69,6 +69,9 @@ class DraftFeedbackUiReadbackTest {
val saved = repository.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
compose.runOnIdle { completedSessionId.value = saved.sessionId }
compose.onNodeWithText("Pendant la séance · texte UI durable").fetchSemanticsNode()
/* Elapsed classification depends on the finalized session clock; this
* wiring assertion owns durable text readback, while label boundaries
* have dedicated deterministic tests. */
compose.onNodeWithText("texte UI durable", substring = true).fetchSemanticsNode()
}
}

View file

@ -0,0 +1,229 @@
package com.labfytools.trainlog.ui
import android.content.Context
import android.net.Uri
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.SaveFeedbackResult
import com.labfytools.trainlog.data.SaveSessionResult
import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.ExchangeSafDirectory
import com.labfytools.trainlog.data.ExchangeSafDocument
import com.labfytools.trainlog.data.EXCHANGE_MEDIASTORE_LOG_TAG
import com.labfytools.trainlog.data.ExchangeSafPublisher
import com.labfytools.trainlog.data.ExchangeSafSnapshotResult
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import java.util.UUID
import java.io.ByteArrayOutputStream
import java.io.OutputStream
import org.json.JSONObject
import org.json.JSONArray
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowLog
import kotlinx.coroutines.runBlocking
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SyncBundlePublicationTest {
private lateinit var context: Context
private lateinit var databaseName: String
private lateinit var repository: TrainlogRepository
private lateinit var directory: FakeSafDirectory
@Before fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseName = "sync-bundle-${UUID.randomUUID()}.db"
repository = TrainlogRepository(context, databaseName)
directory = FakeSafDirectory()
}
@After fun tearDown() {
repository.close()
context.deleteDatabase(databaseName)
}
@Test fun `request publishes and replaces one complete current canonical bundle`() = runBlocking {
val identities = listOf(
"ex_999fd503-ce89-49f9-979d-c19e6adbe80d" to "Planche droite sol",
"ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac" to "Planche face sol",
"ex_e32c40a6-72b4-4233-a0db-41b27e990c96" to "Planche gauche sol",
)
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", JSONArray().also { items -> identities.forEach { (id, name) ->
items.put(JSONObject().put("exercise_id", id).put("name", name)
.put("recording_mode", "sets").put("tracking_mode", "duration")
.put("data_fields", 0))
} })
repository.applyPcCatalogJson(catalog.toString())
val exercises = identities.map { (id, _) ->
repository.listExercises().single { it.exerciseId == id }
}
/* The canonical BODY ZONES fixture exists in the authorized SAF tree;
* no MediaStore candidate is provided, reproducing an ownerless row
* omitted from the application's scoped MediaStore view. */
directory.seed("trainlog-exercise-body-zones-v1.json", "stale canonical")
(1..31).forEach { copy ->
directory.seed("trainlog-exercise-body-zones-v1 ($copy).json", "historical copy")
}
val exporter = SyncExporter(context, repository) { directory }
val outbox = SyncRequestOutbox { directory }
assertTrue(exporter.exportMobileBundle() is com.labfytools.trainlog.data.SyncExportResult.Exported)
assertEquals(1, directory.enumerationCount)
val entry = SessionExerciseDraft(
exercise = exercises.first(),
sets = listOf(SessionSetDraft(durationSeconds = 30)),
)
val saved = repository.saveSession(SessionDraft(listOf(entry))) as SaveSessionResult.Saved
val feedback = repository.saveExerciseFeedback(
saved.sessionId,
entry.entryId,
"ressenti ajouté après la publication précédente",
"2026-09-13T12:59:00+02:00",
)
assertTrue(feedback is SaveFeedbackResult.Saved)
val writesBeforeFirstClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertEquals(2, directory.enumerationCount)
val required = listOf(
"trainlog-mobile-export-v3.json",
"trainlog-mobile-equipment-definitions-v1.json",
"trainlog-equipment-associations-v2.json",
"trainlog-exercise-aliases-v1.json",
"trainlog-exercise-body-zones-v1.json",
"trainlog-training-feedback-v2.json",
"trainlog-exercise-profile-state-v1.json",
"trainlog-sync-request-v1.json",
)
required.forEach { assertEquals(it, 1, directory.canonicalCount(it)) }
required.forEach { name ->
assertEquals("one click must write $name exactly once", 1,
directory.writeCount(name) - (writesBeforeFirstClick[name] ?: 0))
}
val profile = JSONObject(readCanonical("trainlog-exercise-profile-state-v1.json"))
val profileItems = profile.getJSONArray("exercises")
identities.forEach { (id, _) ->
val item = (0 until profileItems.length()).map { profileItems.getJSONObject(it) }
.single { it.getString("exercise_id") == id }
assertEquals("sets", item.getString("recording_mode"))
assertEquals("duration", item.getString("tracking_mode"))
assertEquals(0, item.getInt("data_fields"))
assertEquals("pr_legacy_v1", item.getJSONArray("history").getJSONObject(0).getString("revision_id"))
assertEquals(item.getString("revision_id"), item.getJSONArray("history")
.getJSONObject(item.getJSONArray("history").length() - 1).getString("revision_id"))
}
assertTrue(readCanonical("trainlog-training-feedback-v2.json")
.contains("ressenti ajouté après la publication précédente"))
/* Re-publication updates exact canonical objects instead of creating
* numbered duplicates, and preserves stable feedback/revision IDs. */
val feedbackBefore = JSONObject(readCanonical("trainlog-training-feedback-v2.json"))
ShadowLog.clear()
val bodyCreatesBefore = directory.createCount("trainlog-exercise-body-zones-v1.json")
val writesBeforeSecondClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertEquals(3, directory.enumerationCount)
val phases = ShadowLog.getLogsForTag(EXCHANGE_MEDIASTORE_LOG_TAG).map { it.msg }
assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") })
val snapshotPhases = phases.filter { it.startsWith("SAF_TREE snapshot.success ") }
assertEquals(1, snapshotPhases.size)
assertTrue("SAF snapshot ran on main: $snapshotPhases",
snapshotPhases.none { it.contains("thread=main") })
val writeBegins = phases.filter { it.contains(" saf.write.begin ") }
assertEquals(required.size, writeBegins.size)
assertTrue("SAF write ran on main: $writeBegins",
writeBegins.none { it.contains("thread=main") })
val bodyResolve = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.resolve.existing ") }
val bodyWrite = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.begin ") }
val bodyWritten = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.success ") }
val requestStart = phases.indexOfFirst { it == "SYNC_REQUEST coordinator.request.begin" }
assertTrue("production coordinator did not resolve existing BODY ZONES", bodyResolve >= 0)
assertTrue("BODY ZONES must open only after exact resolution", bodyWrite > bodyResolve)
assertTrue("BODY ZONES write did not complete", bodyWritten > bodyWrite)
assertTrue("request must be published after the complete export", requestStart > bodyWritten)
val beforeBodyOpen = phases.subList(bodyResolve + 1, bodyWrite)
assertTrue("existing BODY ZONES unexpectedly created: $beforeBodyOpen",
beforeBodyOpen.none { it.startsWith("BODY_ZONES saf.create.") })
assertTrue("outbound path must not call MediaStore insert/update/pending: $phases",
phases.none { it.contains(" insert.") || it.contains(" publish.") || it.contains("IS_PENDING") })
assertEquals(bodyCreatesBefore, directory.createCount("trainlog-exercise-body-zones-v1.json"))
assertEquals(0, directory.canonicalCount("trainlog-exercise-body-zones-v1 (32).json"))
(1..31).forEach { copy ->
assertEquals(1, directory.canonicalCount("trainlog-exercise-body-zones-v1 ($copy).json"))
}
required.forEach { assertEquals(it, 1, directory.canonicalCount(it)) }
required.forEach { name ->
assertEquals("repeated click must write $name exactly once", 1,
directory.writeCount(name) - (writesBeforeSecondClick[name] ?: 0))
}
val feedbackAfter = JSONObject(readCanonical("trainlog-training-feedback-v2.json"))
assertEquals(feedbackBefore.getJSONArray("exercise_feedback").toString(),
feedbackAfter.getJSONArray("exercise_feedback").toString())
}
@Test fun `created exact document updates transaction index without rescan`() {
val publisher = ExchangeSafPublisher { directory }
val opened = publisher.snapshot() as ExchangeSafSnapshotResult.Ready
assertEquals(1, directory.enumerationCount)
assertEquals(null, opened.snapshot.writeJson("foo.json", "first"))
assertEquals(null, opened.snapshot.writeJson("foo.json", "second"))
assertEquals(1, directory.enumerationCount)
assertEquals(1, directory.createCount("foo.json"))
assertEquals(2, directory.writeCount("foo.json"))
assertEquals("second", directory.read("foo.json"))
assertEquals(0, directory.canonicalCount("foo (1).json"))
}
private fun readCanonical(name: String): String = directory.read(name)
private class FakeSafDirectory : ExchangeSafDirectory {
private val contents = linkedMapOf<String, ByteArray>()
private val creates = mutableMapOf<String, Int>()
val writeCounts = mutableMapOf<String, Int>()
var enumerationCount = 0
private set
fun seed(name: String, value: String) { contents[name] = value.toByteArray() }
fun read(name: String): String = contents.getValue(name).toString(Charsets.UTF_8)
fun canonicalCount(name: String): Int = if (contents.containsKey(name)) 1 else 0
fun createCount(name: String): Int = creates[name] ?: 0
fun writeCount(name: String): Int = writeCounts[name] ?: 0
override fun listDirectChildren(): List<ExchangeSafDocument> {
enumerationCount++
return contents.keys.map(::document)
}
override fun createJson(displayName: String): ExchangeSafDocument {
check(!contents.containsKey(displayName))
creates[displayName] = createCount(displayName) + 1
contents[displayName] = byteArrayOf()
return document(displayName)
}
override fun openForRewrite(document: ExchangeSafDocument): OutputStream =
object : ByteArrayOutputStream() {
override fun close() {
super.close()
contents[document.displayName] = toByteArray()
writeCounts[document.displayName] = writeCount(document.displayName) + 1
}
}
private fun document(name: String) = ExchangeSafDocument(
Uri.parse("content://trainlog-test/tree/Download%2FTrainlog/document/$name"),
name,
)
}
}

View file

@ -19,10 +19,43 @@ desktop snapshot and imports `trainlog-pc-mobile-export-v3.json` after the PC
catalogue. The artifact preserves occurrence order, continuous metrics, set
weights and equipment. The legacy V1 contract remains separate and readable.
Android schema v16 adds deterministic current exercise-profile ancestry. Its
v15 migration seeds a shared legacy root without changing catalogue or
historical occurrence values; later local edits and peer reconciliation advance
lineage explicitly inside the same repository transaction as the current-profile
change. The insert trigger owns only initial root creation.
The manual synchronization action publishes one prepared Android→PC bundle
before it exposes `trainlog-sync-request-v1.json`. The bundle contains mobile
V3, mobile equipment definitions, equipment associations V2, exercise aliases
V1, BODY ZONES V1, Training Feedback V2, and Exercise Profile State V1. The
request is not written if any required publication fails. The persisted,
user-authorized `Download/Trainlog` SAF tree is the canonical authority for
both inbound and outbound exchange: MediaStore ownership and app-scoped row
visibility are not consulted. Exact display names are rewritten through their
SAF document URI; absence creates the exact name once, and multiple exact names
fail conservatively. Conflict copies such as `name (1).json` are historical
objects, never aliases of `name.json`, and are neither selected nor deleted.
One synchronization enumerates the direct SAF children once, builds a bounded
exact-name multimap, and shares that transaction-local snapshot across all
outbound artifacts and the request. Newly created documents update that map.
The next synchronization enumerates again so PC/MTP changes are never hidden by
a long-lived cache. Snapshot, create and stream operations run on the I/O
dispatcher; Compose only launches the suspend coordinator and applies results.
V3 preserves ordinary plan metadata atomically with occurrence identity,
equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are
never silently rewritten as V3.
Completed Session Detail exposes **Modifier la séance**. Its bounded form is
initialized from occurrence snapshots and writes nothing before Save. Save
replaces all children in one transaction while retaining `session_id`, every
retained `entry_id`, feedback roots/revisions, and the session follow-up parent.
Set and occurrence removal require explicit confirmation; Cancel writes
nothing. Exercise Detail separately exposes **Modifier l'exercice**. A
confirmed incompatible catalogue edit controls future Android occurrences;
completed and active-draft occurrence snapshots are never converted.
## 2. Implemented navigation
```text
@ -505,10 +538,14 @@ Android writes:
trainlog-sync-request-v1.json
```
If MediaStore cannot reopen an older MTP-created canonical object, it may
publish the exact collision sibling `trainlog-sync-request-v1 (N).json`. The
desktop engine selects the newest canonical-or-suffixed request
deterministically, while the stable `request_id` remains the replay boundary.
The request is resolved and rewritten through the same persisted SAF tree as
the bundle. Android does not create a numbered request sibling merely because
MediaStore cannot expose an MTP- or peer-created canonical object. The stable
`request_id` remains the replay boundary.
The application-start catalog import does not publish an outbound bundle.
Exactly one explicit `Synchroniser maintenant` action invokes one export and
one request publication unless a later, explicit retry is initiated.
and waits for a matching:

View file

@ -168,8 +168,13 @@ silently rendered as unclassified.
### Desktop
Desktop SQLite schema v15 is canonical long-term history. Its v14 -> v15
additive step introduces immutable wording-revision tables for the subjective
Desktop SQLite schema v17 is canonical long-term history. Its v15 -> v16
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
one-time backfill copies the linked catalogue mode without changing child rows
or stable identities. The additive v16 -> v17 migration adds deterministic
current-profile ancestry in a 32-record-per-exercise durable chain without
altering those occurrence snapshots. The v14
-> v15 additive step introduces immutable wording-revision tables for the subjective
feedback roots introduced in v14; see [Training feedback](training_feedback.md). Its v9 -> v10
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
be finite `>= 0`; the column already existed and targets/max results retain

View file

@ -67,7 +67,7 @@ TRAINING_KNOWLEDGE_V1=PASS
SESSION_GENERATOR_V1=PASS
APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2
DESKTOP_TESTS=47/47 PASS (normal and ASan/UBSan Meson suites)
DESKTOP_TESTS=52/52 PASS (normal); affected ASan/UBSan tests=6/6 PASS
ANDROID_BUILD=PASS
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
```
@ -81,7 +81,9 @@ Implemented:
F7 actions, and restored focus after overlays/routes);
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
minimum terminal;
- SQLite schema v15, with stable ordered `session_exercises.entry_id`,
- SQLite schema v17, with stable ordered `session_exercises.entry_id`, an
immutable occurrence-owned `tracking_mode` snapshot,
bounded durable current-profile revision ancestry,
occurrence-level equipment identity, and desktop-local custom-equipment
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
rebuilds only `performed_sets` to permit explicit zero actual loads while
@ -187,7 +189,7 @@ The implemented read-only training-knowledge layer loads shared versioned JSON
catalogs as the sole authored scientific source, generates the immutable C
catalog representation, and loads the same assets on Android. It has no
database migration, no auto-seeding, and no synchronization artifact. The
desktop and Android databases are schema v15. Schema v13 is additive: exercise
desktop database is schema v17 and Android is schema v16. Schema v13 is additive: exercise
rows own machine-facing load semantics, optional legacy equipment provenance,
an optional scientific profile, and an explicit resolved/unresolved science
state. Legacy equipment tables and occurrence columns remain intact.
@ -425,9 +427,13 @@ unchanged. Live sync runs `sy_0b00dc46-d899-4865-8718-c95085a380b2` and
`sy_fb0630a8-1938-4d62-9836-b0281955f625` both completed successfully. The
second run reported zero additions/reconciliations, both stores retained the
same 32-relation stable-ID hash, and Android application tables plus companion
exercise states compared equal to the first pass. The engine accepts the exact
MediaStore collision family `trainlog-sync-request-v1 (N).json`; request-ID
replay protection remains authoritative.
exercise states compared equal to the first pass. Android now resolves the
exact request and every outbound artifact through the persisted authorized SAF
tree, independently of MediaStore ownership visibility. Historical numbered
copies remain untouched. One transaction-local exact-name snapshot is shared
by the complete export and request on the I/O dispatcher; each later sync takes
a fresh snapshot. The redundant application-start export was removed, and
request-ID replay protection remains authoritative.
## Current implementation cursor

View file

@ -16,8 +16,8 @@ is a later, separately versioned migration.
## 1. Status
```text
TRAINLOG_DATABASE_SCHEMA_VERSION=15
DATABASE_SCHEMA_V12=PASS
TRAINLOG_DATABASE_SCHEMA_VERSION=17
DATABASE_SCHEMA_V17=PASS
TRAINLOG_FORMAT_V1=FROZEN
```
@ -116,6 +116,24 @@ identity `fr0_<root-id>` with `created_at=observed_at`; corrections use
current-text cache. The authoritative current revision is selected by parsed
`created_at` instant, then bytewise `revision_id` on ties.
Desktop schema v16 transactionally rebuilds only `session_exercises` to add
`tracking_mode TEXT NOT NULL CHECK(tracking_mode IN ('reps','duration'))`.
Each v15 row is backfilled exactly once from its linked exercise before the
version is published. Row counts, foreign keys and source/catalog agreement are
checked before commit; `id`, `entry_id` and every child row remain unchanged.
Desktop schema v17 and Android schema v16 then add `exercise_profile_state`, a
one-row-per-exercise current pointer, plus `exercise_profile_revisions`, the
durable causal chain for mutable current capture profiles. Both migrations seed
`pr_legacy_v1` without rewriting exercises or occurrence snapshots. Local edits
append deterministic revisions transactionally. Each exercise is limited to 32
revision records; a 33rd edit/import is rejected explicitly, never pruned or
ordered by a wall clock.
Already-versioned prototype databases are repaired on open without a version
bump: an exact `pr1|recording_mode|tracking_mode|data_fields` current token is
replaced atomically by `pr_legacy_v1` and its canonical first `pr2_` child.
Malformed, impossible, orphaned, or mixed prototype lineage aborts the open and
rolls back the repair.
## 3. Connection invariants
Every connection enables:
@ -204,6 +222,7 @@ session_row_id
exercise_row_id
entry_id UNIQUE stable occurrence identity
recording_mode
tracking_mode immutable occurrence unit snapshot
data_fields
position
load_mode
@ -216,9 +235,9 @@ equipment_id nullable equipment identity
notes
```
Current desktop history snapshots `recording_mode` and `data_fields` in the
session row. `tracking_mode` remains associated with the referenced exercise
catalog identity.
Desktop history snapshots `recording_mode`, `tracking_mode` and `data_fields`
in the occurrence row. Catalogue profile edits supply defaults only to future
occurrences and never update these snapshots.
An occurrence `equipment_id` resolves to either a supplied manifest definition
or a desktop-local `custom_equipment` definition. An ID that cannot be
@ -661,3 +680,21 @@ findings were repaired, independently verified, and final-validated;
Exercise naming changes update only `exercises.name` and `normalized_name` for
explicitly mapped stable IDs; no schema migration or historical-row rewrite is
required.
## 15. Profile-revision SQLite connection functions
Schema v17 profile-state insert/update triggers use the deterministic
`trainlog_profile_revision(parent, recording_mode, tracking_mode, data_fields)`
UUIDv5 function. The migration-only `trainlog_pr1_valid(...)` recognizer shares
the same connection boundary. These are process-local SQLite functions even
though the triggers that reference them are persisted.
Every production connection capable of preparing Trainlog schema statements
must therefore install the complete function set before executing schema or
data SQL. Native connections do so in
`trainlog_database_open_with_diagnostic()`; Python import/export tools use the
single `tools/trainlog_sqlite.py::connect_database()` factory. SQLite resolves
trigger functions while preparing a statement even when a trigger `WHEN`
condition will suppress its body, so read-mostly importers are not exempt if
they can update or insert an exercise. Raw SQLite connections are limited to
test-fixture construction and read-only inspection.

View file

@ -68,6 +68,14 @@ CONTINUOUS + REPS
UI behavior must never be inferred from an exercise display name.
An explicitly confirmed unweighted static plank uses `SETS + DURATION`, no
supplemental field, bodyweight exercise semantics, and ordinary occurrence
`load_mode=none`. A plan of three 30-second holds stores `target_sets=3` and
`target_duration_seconds=30`; each performed set owns its actual
`duration_seconds`. Trainlog does not infer reps, external kilograms, or a
kilogram MAX. Existing repetition facts remain repetitions until the user
explicitly corrects those completed occurrences.
## 2. Examples
```text

View file

@ -2,7 +2,7 @@
## Machine-exercise Phase 1 compatibility
Desktop and Android schema v15 retain mobile export V3, readable V1/V2 imports,
Desktop schema v17 and Android schema v16 retain mobile export V3, readable V1/V2 imports,
equipment definitions V1, equipment associations V2, exercise aliases V1, and
the BODY ZONES companion without wire-format changes. Machine metadata is not
silently added to a frozen artifact: stable exercise IDs and canonical names
@ -10,6 +10,19 @@ travel through the existing catalog, while v13 metadata stays local until a
dedicated versioned companion is required. Legacy equipment IDs may still be
written for fixed supplied machines as compatibility provenance.
V3 already carries occurrence `tracking_mode`; desktop v16 imports it directly
into `session_exercises` and exports the same snapshot. Catalogue
`tracking_mode` remains the default for future occurrences only, so no wire
format bump is required.
Android and the current desktop importer treat the exercise catalogue link of
a completed V2/V3 entry as an identity constraint only. The entry's own
`recording_mode`, `tracking_mode`, and `data_fields` validate its actual payload
and remain authoritative even after the current catalogue profile evolves.
Import never rewrites historical sets or continuous values to fit the current
profile. Legacy desktop schemas that cannot persist the occurrence tracking
mode retain their explicit compatibility rejection instead of losing its unit.
## 1. Status
```text
@ -34,6 +47,14 @@ BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
```
Mobile export V3 also carries completed-session corrections. For an existing
session with the same immutable header, peers reconcile the complete occurrence
and planning state atomically. A retained `(session_id, entry_id)` cannot be
rebound to another exercise and retains feedback roots/revisions; omitted
occurrences follow the confirmed parent-owned cascade. An identical second
import is a skip. V2 has no complete planning authority and cannot apply a
general correction; its older resumed-MAX compatibility remains bounded.
Synchronization artifacts are separate from the frozen Trainlog session JSON
v1 format.
@ -679,3 +700,34 @@ Training observations use the independent direction-neutral
`trainlog-training-feedback-v2.json`. Its append-only revision union, strict identity
checks, ordering, bounds, and conflicts are specified in
[Training feedback](training_feedback.md).
## Exercise current-profile state v1
`trainlog-exercise-profile-state-v1.json` is a separate, directional-neutral
companion. It does not alter `TRAINLOG_FORMAT_V1`, mobile session V3, PC catalog
v1, feedback V2, equipment, or BODY ZONES. Each record carries a required
oldest-to-current `history` of at most 32 revision records. Equal revisions are
idempotent, an incoming descendant is adopted when its chain proves the local
current revision, any known incoming ancestor is retained, and sibling or
otherwise incomparable descendants conflict. A 33rd revision is rejected; the
chain is never silently truncated. The shared legacy root lets the first
post-upgrade sync repair older profile differences. Machine/science fields are
strictly typed equality guards and remain immutable through this artifact.
For an Android-triggered bidirectional run, Android builds every current
outbound snapshot before publishing any of them and publishes the complete
bundle before writing the sync request. The request therefore signals that the
canonical mobile V3, definitions, associations, aliases, zones, feedback V2,
and profile-state V1 objects have all been refreshed from the same builder
phase. The desktop receives profile state and runs its causal pre-pass before
mobile V3, then runs the strict post-pass only when that companion was actually
received. A V3/V2 same-ID profile mismatch with no received companion reports
`PROFILE_STATE_COMPANION_MISSING`; absence alone remains accepted for older
peers whose mobile snapshot does not require causal reconciliation.
The desktop pre/post-pass importer opens its database through the canonical
Python Trainlog connection factory. That factory registers the same
deterministic profile-revision function as the native database opener before
the importer prepares `UPDATE exercises`; the persisted guarded profile trigger
therefore remains valid even when the authoritative incoming tip makes its body
a no-op. Replaying the same artifact neither changes the current revision nor
adds history rows.

View file

@ -716,3 +716,35 @@ reordering and changing the exercise context of stable entries; it verifies
exact feedback preservation, selective cascade on removal, no inheritance by a
new entry, unchanged follow-ups, and full rollback on injected reattachment
failure. Android additionally covers its bounded resumed-MAX replacement path.
`tests/test_profile_state_sync.py` covers the captured Planche droite sol
legacy `SETS+REPS`/`SETS+DURATION` repair, idempotent replay, direct-descendant
and multi-generation stale-ancestor convergence, sibling conflict, the explicit
32-record resource bound, exact JSON scalar types, immutable machine conflict,
and preservation of the old occurrence snapshot. Android
`ExerciseProfileStateSyncTest` covers the same transitive/type contract in the
reverse PC→Android path and future-occurrence snapshot.
`HistoricalSessionProfileImportTest` exercises the production V2/V3 repository
import with equal and evolved tracking/recording/data-field profiles, strict
snapshot-local payload rejection, unknown identity rejection, unchanged
historical values, unchanged current profile history, and idempotent replay.
`SyncBundlePublicationTest` exercises the production request coordinator and a
synthetic user-authorized SAF directory whose exact BODY ZONES canonical is
deliberately unavailable through MediaStore. The exact SAF object is rewritten
beside 31 preserved historical copies, no MediaStore insert/pending publication
or `(32)` copy occurs, all seven Android-origin companions precede the request
signal, and replay reuses canonical objects without stable-feedback churn.
It also requires one direct-child enumeration for the entire export-plus-request
transaction, one additional enumeration for each later synchronization, index
update after a create without rescan, one write per canonical artifact, and
non-main dispatcher ownership for snapshot and stream operations.
Native `sync_body_zone_wiring` verifies the
profile pre-pass before mobile import, strict post-pass gating, replay, legacy
peer compatibility, and the explicit missing-companion diagnostic even when a
stale local temporary profile artifact exists.
Every Android outbound artifact and the request use the shared exact-name SAF
resolver; app-scoped MediaStore visibility is not part of canonical selection.
`profile_state_sync` includes the production persisted update-trigger dependency
on `trainlog_profile_revision`, imports through the same Python connection path
used by the PC sync worker, and requires first import plus identical replay to
pass without revision-state or history-count churn. It also checks that the
central factory installs both profile-revision schema functions.

View file

@ -56,10 +56,11 @@ resources.
`observed_at` and `sessions.ended_at` are exact offset-aware Trainlog
timestamps. New Android completions persist their explicit finalization instant
as `ended_at`; old NULL anchors are not backfilled or derived from `started_at`.
Session follow-up uses `H+floor((observed-end)/3600)` including after 24 hours,
or `H+?` when the anchor is missing/invalid. Exercise feedback observed before
the end displays `Pendant la séance`; at/after end it uses H+, and without an
anchor it displays the neutral `Ressenti`. No negative H+ is rendered. Ordering
Session follow-up uses `H+floor((observed-end)/3600)` including after 24 hours.
When `ended_at` is unavailable but valid `started_at` gives a nonnegative delta,
both surfaces use `≈H+floor((observed-start)/3600)`; otherwise they use `H+?`.
Exercise feedback uses `Pendant la séance` only when a trustworthy `ended_at`
proves the observation preceded completion. No negative H+ is rendered. Ordering
parses instants, then compares the stable ID bytewise; it never
uses lexical timestamp order or machine-local timezone.

View file

@ -8,6 +8,19 @@ synchronization.
Desktop SQLite is the canonical long-term history.
From completed Session Detail, `e` opens the existing session editor around
`trainlog_database_replace_session_exercises()`. Per-set values, targets, rest,
continuous facts and explicit MAX values are corrected atomically under the
same `session_id`; retained occurrences keep `entry_id`. Destructive set or
occurrence removal uses the global confirmation flow (`Non` by default,
Escape/q cancel).
Desktop schema v16 snapshots `tracking_mode` on every `session_exercises`
occurrence. The exercise editor may therefore change profile axes for future
uses even when history exists. Incompatible changes require an explicit
`Non`-by-default confirmation which states that old sessions remain unchanged;
Escape and `q` cancel.
## 2. APP_SHELL_V1 navigation
The persistent shell has seven root sections:

View file

@ -2,6 +2,7 @@
"""Strict JSON validation regressions for exercise-alias imports."""
import importlib.util
import sys
import json
import sqlite3
import subprocess
@ -10,6 +11,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
SPEC = importlib.util.spec_from_file_location(
"import_exercise_aliases", ROOT / "tools/import_exercise_aliases.py")
MODULE = importlib.util.module_from_spec(SPEC)

View file

@ -0,0 +1,149 @@
#!/usr/bin/env python3
import json,sqlite3,subprocess,sys,tempfile,unittest,uuid
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]; IMPORT=ROOT/"tools/import_exercise_profile_state.py";EXPORT=ROOT/"tools/export_exercise_profile_state.py"
sys.path.insert(0,str(ROOT/"tools"))
from trainlog_sqlite import connect_database
PLANche="ex_999fd503-ce89-49f9-979d-c19e6adbe80d"
NS=uuid.UUID("4f4c8ea6-18f6-5e48-9d65-a54a24889149")
def revision(parent,tracking): return "pr2_"+str(uuid.uuid5(NS,f"{parent}\nsets\n{tracking}\n0"))
def make_db(path,tracking="reps",machine=None):
c=sqlite3.connect(path);c.executescript("""
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,normalized_name TEXT,
tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER,load_semantics TEXT,machine_variant TEXT,
machine_provenance TEXT,scientific_profile_id TEXT,science_state TEXT,legacy_equipment_id TEXT);
CREATE TABLE exercise_profile_state(exercise_row_id INTEGER PRIMARY KEY,revision_id TEXT,parent_revision_id TEXT,legacy_seed INTEGER);
CREATE TABLE exercise_profile_revisions(exercise_row_id INTEGER,revision_id TEXT,parent_revision_id TEXT,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER,legacy_seed INTEGER,PRIMARY KEY(exercise_row_id,revision_id));
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,exercise_row_id INTEGER,tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER);
/* Reproduce the production schema dependency: SQLite resolves this UDF while
preparing UPDATE exercises even when the authoritative-import guard is false. */
CREATE TRIGGER exercise_profile_state_update AFTER UPDATE OF recording_mode,tracking_mode,data_fields ON exercises
WHEN NOT EXISTS(SELECT 1 FROM exercise_profile_state s JOIN exercise_profile_revisions r
ON r.exercise_row_id=s.exercise_row_id AND r.revision_id=s.revision_id
WHERE s.exercise_row_id=NEW.id AND r.recording_mode=NEW.recording_mode
AND r.tracking_mode=NEW.tracking_mode AND r.data_fields=NEW.data_fields)
BEGIN
SELECT trainlog_profile_revision((SELECT revision_id FROM exercise_profile_state WHERE exercise_row_id=NEW.id),
NEW.recording_mode,NEW.tracking_mode,NEW.data_fields);
END;
PRAGMA user_version=17;""")
c.execute("INSERT INTO exercises VALUES(1,?,'Planche droite sol','planche droite sol',?,'sets',0,NULL,?,NULL,NULL,'unresolved',NULL)",(PLANche,tracking,machine))
c.execute("INSERT INTO exercise_profile_state VALUES(1,'pr_legacy_v1',NULL,1)")
c.execute("INSERT INTO exercise_profile_revisions VALUES(1,'pr_legacy_v1',NULL,'sets',?,0,1)",(tracking,))
c.execute("INSERT INTO session_exercises VALUES(1,1,'reps','sets',0)");c.commit();c.close()
def history_record(tracking,revision,parent,legacy=False):
return {"recording_mode":"sets","tracking_mode":tracking,"data_fields":0,"revision_id":revision,"parent_revision_id":parent,"legacy_seed":legacy}
def artifact(tracking="duration",revision="pr_legacy_v1",parent=None,legacy=True,machine=None,history=None):
if history is None:
history=([history_record("duration","pr_legacy_v1",None,True)] if not legacy and parent=="pr_legacy_v1" else [])+[history_record(tracking,revision,parent,legacy)]
return {"format":"trainlog-exercise-profile-state","version":1,"generated_at":"2026-09-13T10:00:00+02:00","exercises":[{
"exercise_id":PLANche,"recording_mode":"sets","tracking_mode":tracking,"data_fields":0,
"load_semantics":None,"machine_variant":machine,"machine_provenance":None,"scientific_profile_id":None,
"science_state":"unresolved","legacy_equipment_id":None,"revision_id":revision,
"parent_revision_id":parent,"legacy_seed":legacy,"history":history}]}
class ProfileStateSyncTest(unittest.TestCase):
def apply(self,db,payload,ok=True):
p=db.with_suffix('.json');p.write_text(json.dumps(payload),encoding='utf-8')
r=subprocess.run(["python3",str(IMPORT),str(p),"--database",str(db)],text=True,capture_output=True)
self.assertEqual(ok,r.returncode==0,r.stdout+r.stderr);return r
def test_canonical_connection_registers_complete_schema_function_set(self):
with tempfile.TemporaryDirectory() as d:
db=connect_database(Path(d)/"configured.db")
expected=revision("pr_legacy_v1","duration")
self.assertEqual(expected,db.execute(
"SELECT trainlog_profile_revision('pr_legacy_v1','sets','duration',0)").fetchone()[0])
self.assertEqual((1,0),db.execute(
"SELECT trainlog_pr1_valid('pr1|sets|duration|0','sets','duration',0),"
"trainlog_pr1_valid('pr1|sets|duration|00','sets','duration',0)").fetchone())
db.close()
def test_exact_planche_legacy_repair_preserves_old_snapshot_and_is_idempotent(self):
with tempfile.TemporaryDirectory() as d:
db=Path(d)/"pc.db";make_db(db);payload=artifact();first=self.apply(db,payload)
self.assertIn("EXERCISE_PROFILE_STATE_IMPORT=PASS",first.stdout)
c=sqlite3.connect(db);before=(
c.execute("SELECT revision_id,parent_revision_id,legacy_seed FROM exercise_profile_state").fetchone(),
c.execute("SELECT COUNT(*) FROM exercise_profile_revisions").fetchone()[0])
second=self.apply(db,payload);self.assertIn("EXERCISE_PROFILE_STATE_IMPORT=PASS",second.stdout)
after=(c.execute("SELECT revision_id,parent_revision_id,legacy_seed FROM exercise_profile_state").fetchone(),
c.execute("SELECT COUNT(*) FROM exercise_profile_revisions").fetchone()[0])
self.assertEqual(before,after)
self.assertEqual(("duration","reps"),(
c.execute("SELECT tracking_mode FROM exercises").fetchone()[0],
c.execute("SELECT tracking_mode FROM session_exercises").fetchone()[0]));c.close()
def test_direct_descendant_converges_and_sibling_conflicts(self):
with tempfile.TemporaryDirectory() as d:
db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact())
child=artifact("reps",revision("pr_legacy_v1","reps"),"pr_legacy_v1",False);self.apply(db,child)
sibling=artifact("duration",revision("pr_legacy_v1","duration"),"pr_legacy_v1",False);self.apply(db,sibling,False)
def test_same_profile_from_different_parents_is_not_equal(self):
with tempfile.TemporaryDirectory() as d:
db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact())
first=revision("pr_legacy_v1","reps");first_history=[history_record("duration","pr_legacy_v1",None,True),history_record("reps",first,"pr_legacy_v1")]
self.apply(db,artifact("reps",first,"pr_legacy_v1",False,history=first_history))
other_parent=revision("pr_legacy_v1","duration")
other=revision(other_parent,"reps")
self.apply(db,artifact("reps",other,other_parent,False,history=[history_record("duration","pr_legacy_v1",None,True),history_record("duration",other_parent,"pr_legacy_v1"),history_record("reps",other,other_parent)]),False)
def test_transitive_stale_ancestor_is_retained(self):
with tempfile.TemporaryDirectory() as d:
db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact())
first=revision("pr_legacy_v1","reps");second=revision(first,"duration")
chain=[history_record("duration","pr_legacy_v1",None,True),history_record("reps",first,"pr_legacy_v1"),history_record("duration",second,first)]
self.apply(db,artifact("duration",second,first,False,history=chain))
self.apply(db,artifact("reps",first,"pr_legacy_v1",False,history=chain[:2]))
self.assertEqual(second,sqlite3.connect(db).execute("SELECT revision_id FROM exercise_profile_state").fetchone()[0])
def test_coerced_scalar_types_and_oversized_history_are_rejected(self):
with tempfile.TemporaryDirectory() as d:
for key,value in (("version","1"),("data_fields","0"),("legacy_seed","true"),("science_state",True)):
db=Path(d)/(key+".db");make_db(db);bad=artifact()
if key=="version": bad[key]=value
else: bad["exercises"][0][key]=value
self.apply(db,bad,False)
db=Path(d)/"bound.db";make_db(db);bad=artifact();bad["exercises"][0]["history"]*=33;self.apply(db,bad,False)
def test_generated_at_uses_canonical_timestamp_grammar(self):
with tempfile.TemporaryDirectory() as d:
db=Path(d)/"pc.db";make_db(db);bad=artifact();bad["generated_at"]="2026-09-13 10:00:00Z"
self.apply(db,bad,False)
def test_history_must_be_canonical_root_to_exact_tip(self):
with tempfile.TemporaryDirectory() as d:
root="pr_legacy_v1";child=revision(root,"reps")
valid=[history_record("duration",root,None,True),history_record("reps",child,root)]
def changed_history(mutator):
history=[dict(entry) for entry in valid];mutator(history)
return artifact("reps",child,root,False,history=history)
other=revision(root,"duration")
cases={
"A-root-omitted":artifact("reps",child,root,False,history=valid[1:]),
"B-root-parent":changed_history(lambda h:h[0].update(parent_revision_id="detached")),
"C-root-not-legacy":changed_history(lambda h:h[0].update(legacy_seed=False)),
"D-second-legacy":changed_history(lambda h:h[1].update(legacy_seed=True)),
"E-middle-missing":artifact("duration",revision(child,"duration"),child,False,
history=[valid[0],history_record("duration",revision(child,"duration"),child)]),
"F-nonprevious-parent":changed_history(lambda h:h[1].update(parent_revision_id="detached")),
"G-wrong-uuid":changed_history(lambda h:h[1].update(revision_id=other)),
"H-current-absent":artifact("duration",other,root,False,history=valid),
"I-current-not-terminal":artifact("duration",root,None,True,history=valid),
"J-duplicate":artifact("reps",child,root,False,history=valid+[dict(valid[1])]),
"K-33-revisions":artifact("reps",child,root,False,history=[dict(valid[0])]*33),
"L-malformed-pr1":artifact("reps","pr1|sets|reps|00",None,True,
history=[history_record("reps","pr1|sets|reps|00",None,True)]),
}
for name,bad in cases.items():
db=Path(d)/(name+".db");make_db(db);self.apply(db,bad,False)
def test_export_carries_oldest_to_current_bounded_chain(self):
with tempfile.TemporaryDirectory() as d:
db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact())
child=revision("pr_legacy_v1","reps");self.apply(db,artifact("reps",child,"pr_legacy_v1",False))
output=Path(d)/"state.json";result=subprocess.run(["python3",str(EXPORT),str(output),"--database",str(db)],text=True,capture_output=True)
self.assertEqual(0,result.returncode,result.stdout+result.stderr)
history=json.loads(output.read_text())["exercises"][0]["history"]
self.assertEqual(["pr_legacy_v1",child],[entry["revision_id"] for entry in history])
def test_immutable_machine_difference_is_hard_conflict(self):
with tempfile.TemporaryDirectory() as d:
db=Path(d)/"pc.db";make_db(db);self.apply(db,artifact(machine="selectorized"),False)
if __name__=="__main__": unittest.main()

View file

@ -24,6 +24,28 @@ CREATE TABLE exercise_body_zone_sync(
PRAGMA user_version=11;
"""
V16_SCHEMA = V11_SCHEMA.replace("PRAGMA user_version=11;", "") + """
ALTER TABLE exercises ADD COLUMN load_semantics TEXT;
ALTER TABLE exercises ADD COLUMN machine_variant TEXT;
ALTER TABLE exercises ADD COLUMN machine_provenance TEXT;
ALTER TABLE exercises ADD COLUMN scientific_profile_id TEXT;
ALTER TABLE exercises ADD COLUMN science_state TEXT NOT NULL DEFAULT 'unresolved';
ALTER TABLE exercises ADD COLUMN legacy_equipment_id TEXT;
ALTER TABLE session_exercises ADD COLUMN tracking_mode TEXT NOT NULL DEFAULT 'reps';
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT NOT NULL);
CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE,
session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,
observed_at TEXT NOT NULL,raw_text TEXT NOT NULL);
CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL,
created_at TEXT NOT NULL,raw_text TEXT NOT NULL);
CREATE TABLE session_followups(id INTEGER PRIMARY KEY,followup_id TEXT NOT NULL UNIQUE,
session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
observed_at TEXT NOT NULL,raw_text TEXT NOT NULL);
CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,followup_id TEXT NOT NULL,
created_at TEXT NOT NULL,raw_text TEXT NOT NULL);
PRAGMA user_version=16;
"""
def run(command, ok=True):
result = subprocess.run(command, text=True, capture_output=True)
@ -32,6 +54,11 @@ def run(command, ok=True):
return result
def db_value(path, sql):
with sqlite3.connect(path) as connection:
return connection.execute(sql).fetchone()[0]
def payload():
exercises = [
{"exercise_id": "ex_reps", "name": "Reps", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
@ -122,8 +149,12 @@ def main():
divergent = copy.deepcopy(payload()); divergent["sessions"][0]["exercises"][0]["target"]["reps"] = 9
divergent_path = root / "divergent.json"; divergent_path.write_text(json.dumps(divergent), encoding="utf-8")
run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)], ok=False)
assert snapshot(db) == before
corrected = run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)])
assert "sessions_reconciled=1" in corrected.stdout
replay = run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)])
assert "sessions_reconciled=0" in replay.stdout
assert db_value(db, "SELECT target_reps FROM session_exercises WHERE entry_id='sxe_reps'") == 9
before = snapshot(db)
mutations = [
lambda e: e["target"].update(sets=0),
@ -205,6 +236,72 @@ def main():
shared_entry = json.loads(shared_out.read_text())["sessions"][0]["exercises"][0]
assert shared_entry["target"] == {"sets": 3, "reps": 9, "weight_kg": 55.5}
assert shared_entry["sets"] == [{"reps": 9, "weight_kg": 52.5}, {"reps": 8}, {"reps": 7, "weight_kg": 0.0}]
# Desktop v16 stores the unit on the occurrence. A later catalogue
# profile change therefore cannot reinterpret or rewrite old facts.
v16_db = root / "v16.sqlite"
with sqlite3.connect(v16_db) as con:
con.executescript(V16_SCHEMA)
run([sys.executable, str(IMPORTER), str(artifact), "--database", str(v16_db)])
with sqlite3.connect(v16_db) as con:
assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_reps'").fetchone()[0] == "reps"
con.execute("UPDATE exercises SET tracking_mode='duration' WHERE exercise_id='ex_reps'")
# V2 has the same completed-occurrence ownership contract: catalogue
# membership proves identity, while the entry's own profile validates
# and preserves the historical payload.
historical_v2 = payload()
historical_v2["version"] = 2
historical_v2["exercises"][0]["tracking_mode"] = "duration"
historical_entry = copy.deepcopy(historical_v2["sessions"][0]["exercises"][0])
historical_entry.pop("target")
historical_entry["entry_id"] = "sxe_v16_historical_v2"
historical_entry["load_mode"] = "none"
historical_entry["rest_seconds"] = 0
historical_v2["sessions"] = [{
"session_id": "se_v16_historical_v2",
"started_at": "2026-09-09T13:00:00+02:00",
"session_type": "training",
"exercises": [historical_entry],
}]
historical_v2["body_observations"] = []
historical_v2_path = root / "v16-historical-v2.json"
historical_v2_path.write_text(json.dumps(historical_v2), encoding="utf-8")
run([sys.executable, str(IMPORTER), str(historical_v2_path), "--database", str(v16_db)])
replay_v2 = run([sys.executable, str(IMPORTER), str(historical_v2_path), "--database", str(v16_db)])
assert "sessions_skipped=1" in replay_v2.stdout
with sqlite3.connect(v16_db) as con:
assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_v16_historical_v2'").fetchone()[0] == "reps"
assert con.execute("SELECT reps FROM performed_sets ps JOIN session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id='sxe_v16_historical_v2' ORDER BY ps.position LIMIT 1").fetchone()[0] == 8
v16_old_export = root / "v16-old.json"
run([sys.executable, str(EXPORTER), str(v16_old_export), "--database", str(v16_db)])
old_entry = json.loads(v16_old_export.read_text())["sessions"][0]["exercises"][0]
assert old_entry["tracking_mode"] == "reps"
assert old_entry["sets"][0]["reps"] == 8
future = payload()
future["exercises"][0]["tracking_mode"] = "duration"
old_after_profile_change = copy.deepcopy(payload()["sessions"][0])
future["sessions"] = [old_after_profile_change, {
"session_id": "se_v16_future", "started_at": "2026-09-10T12:00:00+02:00",
"session_type": "training", "exercises": [{
"entry_id": "sxe_v16_future", "position": 0, "exercise_id": "ex_reps",
"name": "Reps", "recording_mode": "sets", "tracking_mode": "duration",
"data_fields": 0, "load_mode": "none", "rest_seconds": 0,
"equipment_id": None, "target": {"sets": 3, "duration_seconds": 30},
"sets": [{"duration_seconds": 30}, {"duration_seconds": 30}, {"duration_seconds": 30}],
}],
}]
future["body_observations"] = []
future_path = root / "v16-future.json"
future_path.write_text(json.dumps(future), encoding="utf-8")
run([sys.executable, str(IMPORTER), str(future_path), "--database", str(v16_db)])
idempotent = run([sys.executable, str(IMPORTER), str(future_path), "--database", str(v16_db)])
assert "sessions_reconciled=0" in idempotent.stdout
with sqlite3.connect(v16_db) as con:
assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_reps'").fetchone()[0] == "reps"
assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_v16_future'").fetchone()[0] == "duration"
assert con.execute("SELECT group_concat(duration_seconds, ',') FROM performed_sets ps JOIN session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id='sxe_v16_future' ORDER BY ps.position").fetchone()[0] == "30,30,30"
print("PASS session_exchange_v3")

View file

@ -6,6 +6,7 @@ import sqlite3
import os
from datetime import datetime
from pathlib import Path
from trainlog_sqlite import connect_database
DEFAULT_CATALOG = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
@ -25,10 +26,10 @@ def main():
default=Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db")
parser.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
args = parser.parse_args()
connection = sqlite3.connect(args.database)
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
raise ValueError("schema desktop v8 à v13 requis")
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
raise ValueError("schema desktop v8 à v16 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"))

View file

@ -6,6 +6,7 @@ import os
import sqlite3
from datetime import datetime
from pathlib import Path
from trainlog_sqlite import connect_database
FORMAT = "trainlog-equipment-definitions"
VERSION = 1
@ -20,11 +21,11 @@ def main():
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 = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
raise ValueError("schema desktop v8 à v13 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
raise ValueError("schema desktop v8 à v16 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")]

View file

@ -4,6 +4,7 @@ import argparse
import json
import sqlite3
from pathlib import Path
from trainlog_sqlite import connect_database
MAX_ALIASES = 4096
@ -13,10 +14,10 @@ def main():
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
con = sqlite3.connect(args.database)
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15):
raise ValueError("schema desktop v12/v13 requis")
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17):
raise ValueError("schema desktop v12-v16 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -8,6 +8,7 @@ import re
import sqlite3
from datetime import datetime
from pathlib import Path
from trainlog_sqlite import connect_database
ROOT = Path(__file__).resolve().parents[1]
CATALOG = ROOT / "catalog/body-zones-v1.json"
@ -37,11 +38,11 @@ def main():
parser.add_argument("--database", type=Path, default=default_database())
args = parser.parse_args()
assignable = assignable_zone_ids()
connection = sqlite3.connect(args.database)
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15):
raise ValueError("schema desktop v11/v12/v13 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17):
raise ValueError("schema desktop v11-v16 requis")
exercises = []
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None:

View file

@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Export mutable current exercise profiles with deterministic ancestry."""
import argparse
import json
import sqlite3
import re
from datetime import datetime
from pathlib import Path
from trainlog_sqlite import connect_database, profile_revision
REVISION_RE=re.compile(r"^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
def valid_profile(recording, tracking, fields):
return recording in ("sets","continuous") and tracking in ("reps","duration") and \
not (recording=="continuous" and tracking!="duration") and type(fields) is int and \
0<=fields<=3 and not (recording=="sets" and fields!=0)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
con = connect_database(args.database)
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version != 17:
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
e.load_semantics,e.machine_variant,e.machine_provenance,
e.scientific_profile_id,e.science_state,e.legacy_equipment_id,
s.revision_id,s.parent_revision_id,s.legacy_seed
FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id
ORDER BY e.exercise_id
""").fetchall()
exercises=[]
for row in rows:
# exercise_row_id is numeric; resolve it without coupling JSON identity to SQLite row IDs.
exercise_row_id=con.execute("SELECT id FROM exercises WHERE exercise_id=?",(row["exercise_id"],)).fetchone()[0]
history=con.execute("""WITH RECURSIVE lineage(revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,level) AS (
SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,0 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?
UNION ALL SELECT r.revision_id,r.parent_revision_id,r.recording_mode,r.tracking_mode,r.data_fields,r.legacy_seed,lineage.level+1 FROM exercise_profile_revisions r JOIN lineage ON r.revision_id=lineage.parent_revision_id WHERE r.exercise_row_id=? AND lineage.level<31)
SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed FROM lineage ORDER BY level DESC""",
(exercise_row_id,row["revision_id"],exercise_row_id)).fetchall()
if not history or history[-1]["revision_id"] != row["revision_id"] or len(history)>32:
raise SystemExit("EXERCISE_PROFILE_STATE_EXPORT=FAIL invalid/bounded history")
previous=None
for index, revision in enumerate(history):
rid=revision["revision_id"];parent=revision["parent_revision_id"]
recording=revision["recording_mode"];tracking=revision["tracking_mode"]
fields=revision["data_fields"];legacy=revision["legacy_seed"]
valid = valid_profile(recording,tracking,fields) and (
index==0 and legacy==1 and rid=="pr_legacy_v1" and parent is None or
index>0 and legacy==0 and isinstance(parent,str) and parent==previous and
isinstance(rid,str) and REVISION_RE.fullmatch(rid) and
rid==profile_revision(parent,recording,tracking,fields))
if not valid:
raise SystemExit("EXERCISE_PROFILE_STATE_EXPORT=FAIL invalid rooted history")
previous=rid
if (history[-1]["parent_revision_id"]!=row["parent_revision_id"] or
history[-1]["legacy_seed"]!=row["legacy_seed"] or
history[-1]["recording_mode"]!=row["recording_mode"] or
history[-1]["tracking_mode"]!=row["tracking_mode"] or
history[-1]["data_fields"]!=row["data_fields"]):
raise SystemExit("EXERCISE_PROFILE_STATE_EXPORT=FAIL history tip mismatch")
exercises.append(dict(row) | {"legacy_seed":bool(row["legacy_seed"]),"history":[dict(h)|{"legacy_seed":bool(h["legacy_seed"])} for h in history]})
payload = {"format":"trainlog-exercise-profile-state","version":1,
"generated_at":datetime.now().astimezone().isoformat(),"exercises":exercises}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(payload,ensure_ascii=False,separators=(",",":")),encoding="utf-8")
print(f"EXERCISE_PROFILE_STATE_EXPORT=PASS exercises={len(rows)}")
finally:
con.close()
if __name__ == "__main__": main()

View file

@ -7,6 +7,7 @@ from datetime import datetime
from pathlib import Path
from exercise_names import load_exercise_names
from trainlog_sqlite import connect_database
def default_database_path():
@ -54,7 +55,7 @@ def main():
"PC_CATALOG_EXPORT=FAIL database not found"
)
connection = sqlite3.connect(
connection = connect_database(
args.database
)
@ -67,7 +68,7 @@ def main():
# 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 not in (8, 9, 10, 11, 12, 13, 14, 15):
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -10,6 +10,7 @@ from pathlib import Path
from validate_json import TrainlogSemanticError, parse_timestamp
from exercise_names import load_exercise_names
from trainlog_sqlite import connect_database
CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
@ -64,12 +65,12 @@ def main():
parser.add_argument("--database", type=Path, default=default_database())
parser.add_argument("--version", type=int, choices=(2, 3), default=3)
args = parser.parse_args()
con = sqlite3.connect(args.database)
con = connect_database(args.database)
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in (11, 12, 13, 14, 15) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11/v12/v13 requis (v10 accepté pour export V2 explicite)")
if schema_version not in (11, 12, 13, 14, 15, 16, 17) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
known_equipment = supplied_equipment_ids()
canonical_names = load_exercise_names()
known_equipment.update(row[0] for row in con.execute(
@ -89,9 +90,10 @@ def main():
f"session_id={session['session_id']} started_at")
payload = {key: session[key] for key in ("session_id", "started_at", "session_type")}
payload["exercises"] = []
# INVARIANT: tracking mode is catalogue metadata. v7 occurrences
# 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,mr.max_weight_kg,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position"
# INVARIANT: v16 exports the occurrence-owned tracking snapshot;
# older readable schemas retain their bounded catalogue fallback.
tracking_sql = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode"
sql = f"SELECT se.id,se.entry_id,se.position,se.recording_mode,{tracking_sql},se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position"
for entry in con.execute(sql, (session["id"],)):
validate_plan(entry)
if entry["recording_mode"] == "continuous" or entry["max_weight_kg"] is not None:

View file

@ -1,15 +1,16 @@
#!/usr/bin/env python3
"""Export the bounded, direction-neutral TRAINING_FEEDBACK_V2 companion."""
import argparse, datetime, json, sqlite3
from trainlog_sqlite import connect_database
from pathlib import Path
def instant(value): return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
def main():
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=sqlite3.connect(args.database)
db=connect_database(args.database)
try:
if db.execute("PRAGMA user_version").fetchone()[0] != 15: raise ValueError("schema desktop v15 requis")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17): raise ValueError("schema desktop v15/v16/v17 requis")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations")

View file

@ -4,6 +4,7 @@ import argparse
import json
import sqlite3
from pathlib import Path
from trainlog_sqlite import connect_database
FORMAT = "trainlog-equipment-associations"
VERSION = 2
@ -170,10 +171,10 @@ def main():
fail("extension équipement non supportée")
if set(payload) != {"format", "version", "generated_at", "associations"}:
fail("clés extension équipement invalides")
connection = sqlite3.connect(args.database)
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
fail("schema desktop v8 à v13 requis")
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
fail("schema desktop v8 à v16 requis")
known = load_catalog(args.catalog)
known.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))

View file

@ -4,6 +4,7 @@ import argparse
import json
import sqlite3
from pathlib import Path
from trainlog_sqlite import connect_database
FORMAT = "trainlog-equipment-definitions"
VERSION = 1
@ -58,10 +59,10 @@ def main():
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)
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
fail("schema desktop v8 à v13 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
fail("schema desktop v8 à v16 requis")
imported = skipped = 0
# Validate every same-ID row before inserting any definition.
for definition in definitions:

View file

@ -5,6 +5,7 @@ import json
import re
import sqlite3
from pathlib import Path
from trainlog_sqlite import connect_database
MAX_BYTES = 1024 * 1024
MAX_ALIASES = 4096
@ -59,11 +60,11 @@ def main():
parser.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
aliases = load(args.artifact)
con = sqlite3.connect(args.database)
con = connect_database(args.database)
try:
con.execute("PRAGMA foreign_keys=ON")
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15):
fail("schema desktop v12/v13/v14/v15 requis")
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17):
fail("schema desktop v12-v16 requis")
con.execute("BEGIN IMMEDIATE")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()

View file

@ -9,6 +9,7 @@ import sqlite3
import unicodedata
from datetime import datetime
from pathlib import Path
from trainlog_sqlite import connect_database
ROOT = Path(__file__).resolve().parents[1]
@ -201,11 +202,11 @@ def main():
mobile_proof = (load_mobile_exercise_proof(args.mobile_export)
if args.mobile_export is not None else {})
connection = sqlite3.connect(args.database)
connection = connect_database(args.database)
updated = skipped = kept_local = 0
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15):
raise ImportFailure("schema desktop v11/v12/v13 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17):
raise ImportFailure("schema desktop v11-v16 requis")
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("BEGIN IMMEDIATE")
grouped = {}

View file

@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Apply direct-descendant current profiles without reinterpreting occurrences."""
import argparse
import json
import re
import sqlite3
from pathlib import Path
from validate_json import parse_timestamp
from trainlog_sqlite import connect_database, profile_revision
EX_ID=re.compile(r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
HISTORY_MAX=32
ADJ=("load_semantics","machine_variant","machine_provenance","scientific_profile_id","science_state","legacy_equipment_id")
REVISION_RE=re.compile(r"^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
def fail(message): raise ValueError(message)
def validate(item):
required={"exercise_id","recording_mode","tracking_mode","data_fields",*ADJ,
"revision_id","parent_revision_id","legacy_seed","history"}
if set(item)!=required or not isinstance(item["exercise_id"],str) or not EX_ID.fullmatch(item["exercise_id"]): fail("record profile invalide")
r,t,f=item["recording_mode"],item["tracking_mode"],item["data_fields"]
if r not in ("sets","continuous") or t not in ("reps","duration") or r=="continuous" and t!="duration" or type(f) is not int or f<0 or f&~3 or r=="sets" and f: fail("profil entrant invalide")
if item["science_state"] not in ("resolved","unresolved") or any(item[k] is not None and not isinstance(item[k],str) for k in ADJ if k!="science_state") or type(item["legacy_seed"]) is not bool: fail("état profil invalide")
if item["legacy_seed"]:
if item["revision_id"]!="pr_legacy_v1" or item["parent_revision_id"] is not None: fail("racine legacy invalide")
elif not isinstance(item["parent_revision_id"],str) or not isinstance(item["revision_id"],str) or not REVISION_RE.fullmatch(item["revision_id"]) or item["revision_id"]!=profile_revision(item["parent_revision_id"],r,t,f): fail("révision profil invalide")
history=item["history"]
keys={"revision_id","parent_revision_id","recording_mode","tracking_mode","data_fields","legacy_seed"}
if not isinstance(history,list) or not 1<=len(history)<=HISTORY_MAX: fail("historique profil hors limite")
previous=None;revision_ids=set()
for index,revision in enumerate(history):
if not isinstance(revision,dict) or set(revision)!=keys: fail("révision historique invalide")
rr,rt,rf=revision["recording_mode"],revision["tracking_mode"],revision["data_fields"]
legacy=revision["legacy_seed"];rid=revision["revision_id"];parent=revision["parent_revision_id"]
if rr not in ("sets","continuous") or rt not in ("reps","duration") or rr=="continuous" and rt!="duration" or type(rf) is not int or rf<0 or rf&~3 or rr=="sets" and rf or type(legacy) is not bool: fail("profil historique invalide")
if not isinstance(rid,str) or rid in revision_ids or index and parent!=previous: fail("chaîne historique invalide")
if index == 0:
if not legacy or rid!="pr_legacy_v1" or parent is not None: fail("racine historique invalide")
elif legacy:
fail("marqueur legacy hors racine")
if legacy:
if rid!="pr_legacy_v1" or parent is not None: fail("racine historique invalide")
elif not isinstance(parent,str) or not isinstance(rid,str) or not REVISION_RE.fullmatch(rid) or rid!=profile_revision(parent,rr,rt,rf): fail("révision historique invalide")
revision_ids.add(rid);previous=rid
if any(history[-1][key]!=item[key] for key in keys): fail("pointe historique incohérente")
def main():
p=argparse.ArgumentParser();p.add_argument("artifact",type=Path);p.add_argument("--database",required=True,type=Path);p.add_argument("--allow-pending",action="store_true");p.add_argument("--mobile-export",type=Path,help="sync pre-pass identity proof; permits identities introduced by the following catalog import");a=p.parse_args()
try:
root=json.loads(a.artifact.read_text(encoding="utf-8"))
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at")
con=connect_database(a.database);con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0]!=17: fail("schema desktop v17 requis")
applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE")
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]
if eid in seen: fail("exercise_id profile-state dupliqué")
seen.add(eid)
row=con.execute("""SELECT e.id,e.recording_mode,e.tracking_mode,e.data_fields,e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id,e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?""",(eid,)).fetchone()
if row is None:
if a.allow_pending or a.mobile_export is not None: pending+=1;continue
fail(f"identité exercice inconnue: {eid}")
if any(row[k]!=item[k] for k in ADJ): fail(f"conflit identité/machine immutable: {eid}")
local_profile=(row["recording_mode"],row["tracking_mode"],row["data_fields"]);incoming=(item["recording_mode"],item["tracking_mode"],item["data_fields"])
incoming_ids={revision["revision_id"] for revision in item["history"]}
known={r[0] for r in con.execute("SELECT revision_id FROM exercise_profile_revisions WHERE exercise_row_id=?",(row["id"],))}
if row["revision_id"]==item["revision_id"]:
if local_profile==incoming: equal+=1;continue
if not (row["legacy_seed"] and item["legacy_seed"]): fail(f"révision identique avec profil différent: {eid}")
elif row["revision_id"] in incoming_ids:
pass
elif item["revision_id"] in known:
ancestor+=1;continue
else: fail(f"conflit de descendants concurrents: {eid}")
existing_count=con.execute("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",(row["id"],)).fetchone()[0]
additions=sum(1 for revision in item["history"] if revision["revision_id"] not in known)
if existing_count+additions>HISTORY_MAX: fail(f"historique profil dépasse {HISTORY_MAX}: {eid}")
for revision in item["history"]:
con.execute("INSERT OR IGNORE INTO exercise_profile_revisions(exercise_row_id,revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed) VALUES(?,?,?,?,?,?,?)",
(row["id"],revision["revision_id"],revision["parent_revision_id"],revision["recording_mode"],revision["tracking_mode"],revision["data_fields"],int(revision["legacy_seed"])))
if item["legacy_seed"]:
# The shared migration root deliberately permits the one initial
# cross-peer repair; keep its stored tuple equal to the adopted tip.
con.execute("UPDATE exercise_profile_revisions SET recording_mode=?,tracking_mode=?,data_fields=? WHERE exercise_row_id=? AND revision_id='pr_legacy_v1'",(*incoming,row["id"]))
# CONTRACT: select the proven incoming tip before changing the catalogue;
# the guarded local-edit trigger then recognizes an authoritative import.
con.execute("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=? WHERE exercise_row_id=?",(item["revision_id"],item["parent_revision_id"],int(item["legacy_seed"]),row["id"]))
con.execute("UPDATE exercises SET recording_mode=?,tracking_mode=?,data_fields=? WHERE id=?",(*incoming,row["id"]))
applied+=1
con.commit();print(f"EXERCISE_PROFILE_STATE_IMPORT=PASS applied={applied} equal={equal} ancestor={ancestor} pending={pending}")
except (OSError,json.JSONDecodeError,sqlite3.Error,ValueError) as e:
try:
if 'con' in locals(): con.rollback()
except sqlite3.Error: pass
raise SystemExit(f"EXERCISE_PROFILE_STATE_IMPORT=FAIL {e}")
finally:
if 'con' in locals(): con.close()
if __name__=="__main__": main()

View file

@ -10,6 +10,7 @@ from pathlib import Path
from validate_json import TrainlogSemanticError, parse_timestamp
from exercise_names import ExerciseNameCatalogError, load_exercise_names
from trainlog_sqlite import connect_database
FORMAT = "trainlog-mobile-export"
@ -492,18 +493,10 @@ def validate_session_exercise(
label,
)
catalog_profile = known_exercise_ids[exercise_id]
if (
recording_mode != catalog_profile["recording_mode"]
or tracking_mode != catalog_profile["tracking_mode"]
or item["data_fields"] & ~catalog_profile["data_fields"]
):
# CONTRACT: an occurrence snapshots the fields that actually existed
# when it was recorded. It may omit later optional catalog fields, but
# it may never claim a field absent from the catalog profile.
raise ImportFailure(
f"{label}: snapshot incompatible avec le profil catalogue"
)
# CONTRACT: catalogue membership proves stable identity only. Every
# completed occurrence owns its recording/tracking/data-field snapshot in
# every readable mobile version; later CURRENT profile evolution cannot
# invalidate or reinterpret historical actuals.
if version < 3:
if item["load_mode"] != "none" or item["rest_seconds"] != 0:
@ -820,9 +813,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15):
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
raise ImportFailure(
f"base desktop schema v5 à v15 attendue, version trouvée: {version}"
f"base desktop schema v5 à v17 attendue, version trouvée: {version}"
)
@ -1290,7 +1283,12 @@ def import_set_session_exercise(
values = "?, " if entry_id is not None else ""
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
equipment_values = ", ?" if schema_version >= 6 else ""
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position]
tracking_columns = ", tracking_mode" if schema_version >= 16 else ""
tracking_values = ", ?" if schema_version >= 16 else ""
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row]
if schema_version >= 16:
arguments.append(tracking)
arguments.extend([item["data_fields"], position])
arguments.extend(occurrence_plan_values(item))
if schema_version >= 6:
arguments.append(item.get("equipment_id"))
@ -1299,7 +1297,7 @@ def import_set_session_exercise(
INSERT INTO session_exercises(
""" + columns + """session_row_id,
exercise_row_id,
recording_mode,
recording_mode""" + tracking_columns + """,
data_fields,
position,
load_mode,
@ -1309,7 +1307,7 @@ def import_set_session_exercise(
target_duration_seconds,
target_weight_kg, notes""" + equipment_columns + """
) VALUES(
""" + values + """?, ?, 'sets', ?, ?, ?, ?,
""" + values + """?, ?, 'sets'""" + tracking_values + """, ?, ?, ?, ?,
?, ?, ?, ?, NULL""" + equipment_values + """
);
""",
@ -1363,7 +1361,12 @@ def import_continuous_session_exercise(
values = "?, " if entry_id is not None else ""
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
equipment_values = ", ?" if schema_version >= 6 else ""
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position]
tracking_columns = ", tracking_mode" if schema_version >= 16 else ""
tracking_values = ", ?" if schema_version >= 16 else ""
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row]
if schema_version >= 16:
arguments.append(item["tracking_mode"])
arguments.extend([item["data_fields"], position])
arguments.extend(occurrence_plan_values(item))
if schema_version >= 6:
arguments.append(item.get("equipment_id"))
@ -1372,7 +1375,7 @@ def import_continuous_session_exercise(
INSERT INTO session_exercises(
""" + columns + """session_row_id,
exercise_row_id,
recording_mode,
recording_mode""" + tracking_columns + """,
data_fields,
position,
load_mode,
@ -1382,7 +1385,7 @@ def import_continuous_session_exercise(
target_duration_seconds,
target_weight_kg, notes""" + equipment_columns + """
) VALUES(
""" + values + """?, ?, 'continuous', ?, ?, ?, ?,
""" + values + """?, ?, 'continuous'""" + tracking_values + """, ?, ?, ?, ?,
?, ?, ?, ?, NULL""" + equipment_values + """
);
""",
@ -1422,25 +1425,24 @@ def import_max_session_exercise(
raise ImportFailure(
"max_weight_kg exige le schéma desktop v9"
)
tracking_column = ", tracking_mode" if schema_version >= 16 else ""
tracking_value = ", ?" if schema_version >= 16 else ""
arguments = [item["entry_id"], session_row_id, exercise_row,
item["recording_mode"]]
if schema_version >= 16:
arguments.append(item["tracking_mode"])
arguments.extend([item["data_fields"], position, *occurrence_plan_values(item),
item.get("equipment_id")])
cursor = connection.execute(
"""
INSERT INTO session_exercises(
entry_id, session_row_id, exercise_row_id, recording_mode,
data_fields, position, load_mode, rest_seconds,
""" + tracking_column.lstrip(", ") + (", " if tracking_column else "") + """data_fields, position, load_mode, rest_seconds,
target_sets, target_reps, target_duration_seconds,
target_weight_kg, notes, equipment_id
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?);
) VALUES(?, ?, ?, ?""" + tracking_value + """, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?);
""",
(
item["entry_id"],
session_row_id,
exercise_row,
item["recording_mode"],
item["data_fields"],
position,
*occurrence_plan_values(item),
item.get("equipment_id"),
),
arguments,
)
connection.execute(
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) "
@ -1456,6 +1458,8 @@ def import_sessions(
report,
):
for session in payload["sessions"]:
retained_feedback = []
retained_feedback_revisions = []
if session_exists(
connection,
session["session_id"],
@ -1495,11 +1499,21 @@ def import_sessions(
and all(current[index] == incoming[index]
for index in range(len(current)))
)
incoming_by_id = dict(incoming)
correction = (
payload["version"] == 3
and header is not None
and tuple(header) == (session["started_at"], session["session_type"])
and len(incoming_by_id) == len(incoming)
and all(entry_id not in dict(current)
or dict(current)[entry_id] == exercise_id
for entry_id, exercise_id in incoming)
)
# A v1 history may be upgraded only when exercise/order mapping is
# unique. Any other identity disagreement is an explicit conflict.
if current != incoming and not (
(legacy and [x[1] for x in current] == [x[1] for x in incoming])
or resumable_max
or resumable_max or correction
):
raise ImportFailure("conflit d'identités d'entrées pour " + session["session_id"])
if not legacy and session_semantically_matches(
@ -1510,12 +1524,25 @@ def import_sessions(
):
report["sessions_skipped"] += 1
continue
if not legacy and not resumable_max:
if not legacy and not (resumable_max or correction):
raise ImportFailure("conflit de contenu pour " + session["session_id"])
# CONTRACT: a resumed max_test may edit existing max values and
# append occurrences, but cannot remove/reorder/rebind any stable
# entry. This bounded replacement makes tomorrow's continuation
# idempotent without turning arbitrary session conflicts into wins.
# CONTRACT: V3 carries the complete occurrence and planning state.
# A correction may alter facts and remove explicitly confirmed
# parents, but a retained entry_id cannot be rebound to another
# exercise. Feedback follows retained session_id + entry_id.
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 14:
retained_feedback = connection.execute(
"SELECT se.entry_id,f.feedback_id,f.observed_at,f.raw_text "
"FROM exercise_feedback f JOIN session_exercises se "
"ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?;",
(session_row_id,),).fetchall()
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 15:
retained_feedback_revisions = connection.execute(
"SELECT r.feedback_id,r.revision_id,r.created_at,r.raw_text "
"FROM exercise_feedback_revisions r JOIN exercise_feedback f "
"ON f.feedback_id=r.feedback_id JOIN session_exercises se "
"ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?;",
(session_row_id,),).fetchall()
# Explicit child deletion makes reconciliation safe even for old
# databases which were created without enforced foreign keys.
connection.execute("DELETE FROM performed_sets WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
@ -1569,9 +1596,9 @@ def import_sessions(
f"exercice desktop absent: {desktop_id}"
)
if (
row["tracking_mode"]
!= item["tracking_mode"]
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if schema_version < 16 and (
row["tracking_mode"] != item["tracking_mode"]
or row["recording_mode"]
!= item["recording_mode"]
or item["data_fields"] & ~row["data_fields"]
@ -1610,6 +1637,25 @@ def import_sessions(
row_id,
)
retained_ids = {item["entry_id"] for item in session["exercises"]}
for entry_id, feedback_id, observed_at, raw_text in retained_feedback:
if entry_id not in retained_ids:
continue
occurrence = connection.execute(
"SELECT id FROM session_exercises WHERE session_row_id=? AND entry_id=?;",
(session_row_id, entry_id),).fetchone()
if occurrence is None:
raise ImportFailure("rattachement feedback impossible: " + entry_id)
connection.execute(
"INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) VALUES(?,?,?,?);",
(feedback_id, occurrence[0], observed_at, raw_text),)
retained_feedback_ids = {row[1] for row in retained_feedback if row[0] in retained_ids}
for feedback_id, revision_id, created_at, raw_text in retained_feedback_revisions:
if feedback_id in retained_feedback_ids:
connection.execute(
"INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) VALUES(?,?,?,?);",
(revision_id, feedback_id, created_at, raw_text),)
report["sessions_imported"] += 1
@ -1623,8 +1669,10 @@ def session_semantically_matches(
"SELECT started_at,session_type FROM sessions WHERE id=?", (session_row_id,)).fetchone()
if header is None or (header[0], header[1]) != (incoming["started_at"], incoming["session_type"]):
return False
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
tracking_column = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode"
rows = connection.execute(
"SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode,e.tracking_mode,"
"SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode," + tracking_column + ","
"se.data_fields,se.equipment_id,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,"
"se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e "
"ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position", (session_row_id,)).fetchall()
@ -1772,7 +1820,7 @@ def run_import(
f"base desktop introuvable: {database_path}"
)
connection = sqlite3.connect(
connection = connect_database(
database_path
)
@ -1927,7 +1975,7 @@ def main():
known_equipment_ids = load_supplied_equipment_ids(args.catalog)
if args.database.exists():
with sqlite3.connect(args.database) as connection:
with connect_database(args.database) as connection:
if connection.execute("PRAGMA user_version").fetchone()[0] >= 8:
known_equipment_ids.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))

View file

@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Strict V1-compatible, append-only TRAINING_FEEDBACK_V2 desktop merger."""
import argparse,json,re,sqlite3
from trainlog_sqlite import connect_database
from datetime import datetime
from pathlib import Path
ID=lambda p:re.compile(r"^"+p+r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
@ -46,11 +47,11 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
current=max(db.execute(f"SELECT revision_id,created_at,raw_text FROM {table} WHERE {parent_column}=?",(parent_id,)).fetchall(),key=lambda r:(instant(r[1]),r[0].encode()))
return added,skipped,current[2]
def main():
parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=sqlite3.connect(args.database)
parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=connect_database(args.database)
roots_added=roots_skipped=revisions_added=revisions_skipped=0
try:
db.execute("PRAGMA foreign_keys=ON")
if db.execute("PRAGMA user_version").fetchone()[0]!=15:fail("schema desktop v15 requis")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17):fail("schema desktop v15/v16/v17 requis")
db.execute("BEGIN IMMEDIATE");seen=set()
for index,item in enumerate(root["exercise_feedback"]):
keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"}

View file

@ -13,6 +13,7 @@ import sqlite3
from pathlib import Path
from exercise_names import normalize_catalog_name
from trainlog_sqlite import connect_database
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MANIFEST = Path("/tmp/trainlog-machine-exercise-manifest/migration-manifest-v1.json")
@ -167,7 +168,7 @@ def main():
if database == LIVE_DB.resolve():
fail("refusing to migrate the canonical live desktop database")
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
connection = sqlite3.connect(database)
connection = connect_database(database)
connection.execute("PRAGMA foreign_keys=ON")
changed = apply(connection, manifest)
integrity = connection.execute("PRAGMA integrity_check").fetchone()[0]

49
tools/trainlog_sqlite.py Normal file
View file

@ -0,0 +1,49 @@
"""Canonical SQLite connection configuration for Trainlog Python tools."""
import sqlite3
import uuid
PROFILE_NAMESPACE = uuid.UUID("4f4c8ea6-18f6-5e48-9d65-a54a24889149")
def profile_revision(parent, recording, tracking, fields):
"""Return the frozen deterministic profile revision shared with C/Android."""
if not all(isinstance(value, str) for value in (parent, recording, tracking)):
raise ValueError("invalid profile revision")
if type(fields) is not int:
raise ValueError("invalid profile revision")
name = f"{parent}\n{recording}\n{tracking}\n{fields}"
return "pr2_" + str(uuid.uuid5(PROFILE_NAMESPACE, name))
def prototype_profile_valid(token, recording, tracking, fields):
"""Recognize only the unpublished pr1 prototype accepted by migration v17."""
if not isinstance(token, str) or not isinstance(recording, str) or \
not isinstance(tracking, str) or type(fields) is not int:
return 0
valid = recording in ("sets", "continuous") and tracking in ("reps", "duration") and \
not (recording == "continuous" and tracking != "duration") and \
fields >= 0 and fields & ~3 == 0 and not (recording == "sets" and fields != 0)
return int(valid and token == f"pr1|{recording}|{tracking}|{fields}")
def configure_connection(connection):
"""Install every process-local function referenced by Trainlog schema SQL.
CONTRACT: callers must use this before preparing statements against a
Trainlog database. Persisted triggers resolve UDF names at statement
preparation even when their WHEN clause suppresses the trigger body.
"""
connection.create_function(
"trainlog_profile_revision", 4, profile_revision, deterministic=True,
)
connection.create_function(
"trainlog_pr1_valid", 4, prototype_profile_valid, deterministic=True,
)
return connection
def connect_database(database, *args, **kwargs):
"""Open and canonically configure one production Trainlog connection."""
return configure_connection(sqlite3.connect(database, *args, **kwargs))

View file

@ -10,6 +10,7 @@ import math
import re
import sys
import unicodedata
import uuid
from pathlib import Path
from typing import Any
@ -29,12 +30,88 @@ SCHEMA_PATH = ROOT / "format" / "trainlog-v1.schema.json"
VALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "valid"
INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid"
BODY_ZONE_CATALOG_PATH = ROOT / "catalog" / "body-zones-v1.json"
PROFILE_EXERCISE_ID = re.compile(r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
PROFILE_NAMESPACE = uuid.UUID("4f4c8ea6-18f6-5e48-9d65-a54a24889149")
PROFILE_REVISION_ID = re.compile(r"^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
PROFILE_HISTORY_MAX = 32
def exercise_profile_revision(parent: str, recording: str, tracking: str, fields: int) -> str:
return "pr2_" + str(uuid.uuid5(PROFILE_NAMESPACE, f"{parent}\n{recording}\n{tracking}\n{fields}"))
class TrainlogSemanticError(ValueError):
"""Raised when structurally valid JSON violates its format semantics."""
def validate_exercise_profile_state(document: Any) -> None:
"""Validate the separately versioned deterministic ancestry companion."""
if not isinstance(document, dict) or set(document) != {
"format", "version", "generated_at", "exercises",
} or document.get("format") != "trainlog-exercise-profile-state" or \
type(document.get("version")) is not int or document.get("version") != 1 or not isinstance(document.get("generated_at"), str) or \
not document.get("generated_at") or not isinstance(document.get("exercises"), list):
raise TrainlogSemanticError("exercise profile-state v1: invalid root")
parse_timestamp(document["generated_at"], "generated_at")
seen = set()
adjacent = {"load_semantics", "machine_variant", "machine_provenance",
"scientific_profile_id", "science_state", "legacy_equipment_id"}
required = {"exercise_id", "recording_mode", "tracking_mode", "data_fields",
"revision_id", "parent_revision_id", "legacy_seed", "history"} | adjacent
revision_keys = {"revision_id", "parent_revision_id", "recording_mode",
"tracking_mode", "data_fields", "legacy_seed"}
for item in document["exercises"]:
if not isinstance(item, dict) or set(item) != required:
raise TrainlogSemanticError("exercise profile-state v1: invalid record")
exercise_id = item["exercise_id"]
if not isinstance(exercise_id, str) or not PROFILE_EXERCISE_ID.fullmatch(exercise_id) or exercise_id in seen:
raise TrainlogSemanticError("exercise profile-state v1: invalid/duplicate identity")
seen.add(exercise_id)
recording, tracking, fields = item["recording_mode"], item["tracking_mode"], item["data_fields"]
if not isinstance(recording, str) or not isinstance(tracking, str) or \
recording not in {"sets", "continuous"} or tracking not in {"reps", "duration"} or \
recording == "continuous" and tracking != "duration" or type(fields) is not int or \
fields < 0 or fields & ~3 or recording == "sets" and fields != 0:
raise TrainlogSemanticError("exercise profile-state v1: invalid profile")
if not isinstance(item["science_state"], str) or item["science_state"] not in {"resolved", "unresolved"} or any(
item[key] is not None and not isinstance(item[key], str)
for key in adjacent - {"science_state"}):
raise TrainlogSemanticError("exercise profile-state v1: invalid adjacent metadata")
legacy = item["legacy_seed"]
if type(legacy) is not bool or (legacy and (item["revision_id"] != "pr_legacy_v1" or item["parent_revision_id"] is not None)) or \
(not legacy and (not isinstance(item["parent_revision_id"], str) or not isinstance(item["revision_id"], str) or
not PROFILE_REVISION_ID.fullmatch(item["revision_id"]) or item["revision_id"] !=
exercise_profile_revision(item["parent_revision_id"], recording, tracking, fields))):
raise TrainlogSemanticError("exercise profile-state v1: invalid ancestry")
history = item["history"]
if not isinstance(history, list) or not 1 <= len(history) <= PROFILE_HISTORY_MAX:
raise TrainlogSemanticError("exercise profile-state v1: invalid history bound")
previous = None
revision_ids = set()
for index, revision in enumerate(history):
if not isinstance(revision, dict) or set(revision) != revision_keys:
raise TrainlogSemanticError("exercise profile-state v1: invalid history record")
rr, rt, rf = revision["recording_mode"], revision["tracking_mode"], revision["data_fields"]
rlegacy = revision["legacy_seed"]
if not isinstance(rr, str) or not isinstance(rt, str) or rr not in {"sets", "continuous"} or rt not in {"reps", "duration"} or \
rr == "continuous" and rt != "duration" or type(rf) is not int or rf < 0 or \
rf & ~3 or rr == "sets" and rf != 0 or type(rlegacy) is not bool:
raise TrainlogSemanticError("exercise profile-state v1: invalid history profile")
rid, parent = revision["revision_id"], revision["parent_revision_id"]
if not isinstance(rid, str) or rid in revision_ids or (index > 0 and parent != previous) or \
(index == 0 and (not rlegacy or rid != "pr_legacy_v1" or parent is not None)) or \
(index > 0 and rlegacy) or \
(rlegacy and (rid != "pr_legacy_v1" or parent is not None)) or \
(not rlegacy and (not isinstance(parent, str) or not isinstance(rid, str) or
not PROFILE_REVISION_ID.fullmatch(rid) or rid != exercise_profile_revision(parent, rr, rt, rf))):
raise TrainlogSemanticError("exercise profile-state v1: invalid history chain")
revision_ids.add(rid)
previous = rid
tip = history[-1]
if any(tip[key] != item[key] for key in revision_keys):
raise TrainlogSemanticError("exercise profile-state v1: history tip mismatch")
def validate_body_zone_catalog(document: Any) -> None:
"""Validate the one canonical taxonomy, hierarchy and initial mappings."""
if not isinstance(document, dict) or set(document) != {
@ -443,6 +520,7 @@ def validate_document(
is_body_zones = isinstance(document, dict) and document.get("format") == "trainlog-body-zone-catalog"
is_exercise_names = isinstance(document, dict) and document.get("format") == "trainlog-exercise-names-v1"
is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2
is_profile_state = isinstance(document, dict) and document.get("format") == "trainlog-exercise-profile-state"
if is_body_zones:
try:
validate_body_zone_catalog(document)
@ -456,6 +534,12 @@ def validate_document(
except ValueError as exc:
return [str(exc)]
return []
if is_profile_state:
try:
validate_exercise_profile_state(document)
except TrainlogSemanticError as exc:
return [str(exc)]
return []
if not is_mobile_v2:
errors = structural_errors(validator, document)
if errors:

View file

@ -11,7 +11,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 15
#define TRAINLOG_DATABASE_SCHEMA_VERSION 17
typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -80,6 +80,8 @@ typedef struct TrainlogSessionExerciseInput {
/* Optional canonical ID from equipment-v1.json, never a SQLite row ID. */
char equipment_id[TRAINLOG_ID_MAX + 1U];
TrainlogRecordingMode recording_mode;
/** Occurrence-owned unit snapshot; catalogue edits affect future rows only. */
TrainlogTrackingMode tracking_mode;
TrainlogExerciseDataFields data_fields;
TrainlogLoadMode load_mode;
int rest_seconds;

View file

@ -21,11 +21,12 @@ TrainlogStatus trainlog_time_now_rfc3339(
size_t output_size
);
/* CONTRACT: exercise observations before end use "Pendant la séance" and a
* missing anchor uses "Ressenti"; follow-ups use H+? in both cases. No caller
* can receive a negative H+ label. */
/* CONTRACT: ended_at yields exact H+ and alone can prove "Pendant la séance".
* If unavailable, a valid nonnegative started_at delta is visibly approximate
* (≈H+); otherwise both feedback kinds use H+?. */
TrainlogStatus trainlog_feedback_relative_label(
const char *ended_at, const char *observed_at, bool exercise_feedback,
const char *ended_at, const char *started_at, const char *observed_at,
bool exercise_feedback,
char *output, size_t output_size);
#endif

View file

@ -674,6 +674,22 @@ test_feedback_correction = executable(
)
test('feedback_correction', test_feedback_correction)
test_schema_v16_migration = executable(
'test_schema_v16_migration',
'tests/test_schema_v16_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v16_migration', test_schema_v16_migration)
python3 = find_program('python3')
test(
'profile_state_sync',
python3,
args: [meson.project_source_root() / 'tests/test_profile_state_sync.py'],
workdir: meson.project_source_root(),
)
test_app_shell = executable(
'test_app_shell',
'tests/test_app_shell.c',

View file

@ -20,6 +20,59 @@
#include <string.h>
#include <sqlite3.h>
#include <uuid/uuid.h>
/* CONTRACT: exact parity with Android/Python UUIDv5 over parent, profile tuple. */
static void sqlite_profile_revision(sqlite3_context *context, int argc, sqlite3_value **argv)
{
uuid_t namespace_id, revision;
char name[256], uuid_text[37], output[41];
const char *parent, *recording, *tracking;
int length;
if (argc != 4 || sqlite3_value_type(argv[0]) != SQLITE_TEXT ||
sqlite3_value_type(argv[1]) != SQLITE_TEXT || sqlite3_value_type(argv[2]) != SQLITE_TEXT ||
sqlite3_value_type(argv[3]) != SQLITE_INTEGER) { sqlite3_result_error(context, "invalid profile revision", -1); return; }
parent = (const char *)sqlite3_value_text(argv[0]); recording = (const char *)sqlite3_value_text(argv[1]);
tracking = (const char *)sqlite3_value_text(argv[2]);
length = snprintf(name, sizeof(name), "%s\n%s\n%s\n%d", parent, recording, tracking, sqlite3_value_int(argv[3]));
if (length < 0 || (size_t)length >= sizeof(name) || uuid_parse("4f4c8ea6-18f6-5e48-9d65-a54a24889149", namespace_id) != 0) {
sqlite3_result_error(context, "profile revision overflow", -1); return;
}
uuid_generate_sha1(revision, namespace_id, name, (size_t)length);
uuid_unparse_lower(revision, uuid_text);
(void)snprintf(output, sizeof(output), "pr2_%s", uuid_text);
sqlite3_result_text(context, output, -1, SQLITE_TRANSIENT);
}
/* Exact recognizer for the unpublished variable-width prototype token. */
static void sqlite_prototype_profile_valid(sqlite3_context *context, int argc,
sqlite3_value **argv)
{
const char *token, *recording, *tracking;
char expected[96];
int fields, length;
if (argc != 4 || sqlite3_value_type(argv[0]) != SQLITE_TEXT ||
sqlite3_value_type(argv[1]) != SQLITE_TEXT ||
sqlite3_value_type(argv[2]) != SQLITE_TEXT ||
sqlite3_value_type(argv[3]) != SQLITE_INTEGER) {
sqlite3_result_int(context, 0); return;
}
token = (const char *)sqlite3_value_text(argv[0]);
recording = (const char *)sqlite3_value_text(argv[1]);
tracking = (const char *)sqlite3_value_text(argv[2]);
fields = sqlite3_value_int(argv[3]);
if ((strcmp(recording, "sets") != 0 && strcmp(recording, "continuous") != 0) ||
(strcmp(tracking, "reps") != 0 && strcmp(tracking, "duration") != 0) ||
(strcmp(recording, "continuous") == 0 && strcmp(tracking, "duration") != 0) ||
fields < 0 || (fields & ~3) != 0 ||
(strcmp(recording, "sets") == 0 && fields != 0)) {
sqlite3_result_int(context, 0); return;
}
length = snprintf(expected, sizeof(expected), "pr1|%s|%s|%d",
recording, tracking, fields);
sqlite3_result_int(context, length >= 0 && (size_t)length < sizeof(expected) &&
strcmp(token, expected) == 0);
}
TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database)
{
@ -55,7 +108,7 @@ TrainlogStatus trainlog_database_scan_generation_history(
{
static const char *const SQL =
"SELECT s.session_id,s.started_at,se.entry_id,e.exercise_id,e.recording_mode,"
"e.tracking_mode,se.equipment_id,se.load_mode,se.rest_seconds,"
"se.tracking_mode,se.equipment_id,se.load_mode,se.rest_seconds,"
"se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,"
"ps.position,ps.reps,ps.weight_kg,mr.max_weight_kg "
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id "
@ -607,6 +660,187 @@ static const char *const MIGRATE_V14_TO_V15_SQL =
"SELECT 'fr0_'||followup_id,followup_id,observed_at,raw_text FROM session_followups;"
"PRAGMA user_version=15;COMMIT;";
static TrainlogStatus read_single_int_pragma(
TrainlogDatabase *database, const char *sql, int *output);
static TrainlogStatus execute_sql(TrainlogDatabase *database, const char *sql);
/* WHY: catalogue profiles describe future capture and may change, while an
* occurrence's performed values must retain their original unit forever.
* CONTRACT: v15 is the sole source schema; backfill copies the linked exercise
* mode exactly once before any later profile edit. INVARIANT: every existing
* row/child/stable identity remains untouched and version 16 is published only
* after all snapshots are valid and foreign keys remain satisfied. */
static TrainlogStatus migrate_v15_to_v16(TrainlogDatabase *database)
{
sqlite3_stmt *statement = NULL;
int version = -1;
int invalid = -1;
int source_count = -1;
int migrated_count = -1;
int rc;
if (read_single_int_pragma(database, "PRAGMA user_version;", &version) !=
TRAINLOG_STATUS_OK || version != 15)
return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
if (read_single_int_pragma(database,
"SELECT COUNT(*) FROM session_exercises;", &source_count) !=
TRAINLOG_STATUS_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
/* SQLite cannot add a NOT NULL column without a lasting default. Rebuild
* the parent table with FK enforcement temporarily disabled so child rows
* are neither cascaded nor retargeted to a temporary table name. The
* transaction plus the explicit foreign_key_check preserves atomicity. */
if (execute_sql(database, "PRAGMA foreign_keys=OFF;") != TRAINLOG_STATUS_OK ||
execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK ||
execute_sql(database,
"CREATE TABLE session_exercises_v16("
"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,"
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),"
"UNIQUE(session_row_id,position));"
"INSERT INTO session_exercises_v16("
"id,entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,"
"load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,"
"target_weight_kg,equipment_id,notes,tracking_mode) "
"SELECT se.id,se.entry_id,se.session_row_id,se.exercise_row_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,e.tracking_mode "
"FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id;"
"DROP TABLE session_exercises;"
"ALTER TABLE session_exercises_v16 RENAME TO session_exercises;") !=
TRAINLOG_STATUS_OK) goto rollback;
rc = sqlite3_prepare_v2(database->connection,
"SELECT COUNT(*) FROM session_exercises se LEFT JOIN exercises e "
"ON e.id=se.exercise_row_id WHERE e.id IS NULL OR "
"se.tracking_mode NOT IN('reps','duration') OR "
"se.tracking_mode<>e.tracking_mode;", -1, &statement, NULL);
if (rc != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) goto rollback;
invalid = sqlite3_column_int(statement, 0);
if (sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto rollback; }
statement = NULL;
if (read_single_int_pragma(database,
"SELECT COUNT(*) FROM session_exercises;", &migrated_count) !=
TRAINLOG_STATUS_OK || migrated_count != source_count || invalid != 0 ||
read_single_int_pragma(database, "SELECT COUNT(*) FROM pragma_foreign_key_check;", &invalid) != TRAINLOG_STATUS_OK ||
invalid != 0 || execute_sql(database,
"PRAGMA user_version=16;COMMIT;") != TRAINLOG_STATUS_OK) goto rollback;
if (execute_sql(database, "PRAGMA foreign_keys=ON;") != TRAINLOG_STATUS_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
return TRAINLOG_STATUS_OK;
rollback:
if (statement != NULL) (void)sqlite3_finalize(statement);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
(void)sqlite3_exec(database->connection, "PRAGMA foreign_keys=ON;", NULL, NULL, NULL);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
/* WHY: a current catalogue profile is mutable, but timestamps cannot establish
* ancestry across offline peers. CONTRACT: every v16 exercise starts at the
* same explicit legacy root; the first exchanged snapshot may therefore repair
* a pre-contract difference. Later edits store a deterministic child and its
* bounded revision chain, so transitive ancestor and sibling cases are unambiguous.
* INVARIANT: this migration changes no exercise or occurrence value. */
static const char *const MIGRATE_V16_TO_V17_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE exercise_profile_state("
"exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE,"
"revision_id TEXT NOT NULL,parent_revision_id TEXT,"
"legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1)));"
"INSERT INTO exercise_profile_state(exercise_row_id,revision_id,parent_revision_id,legacy_seed) "
"SELECT id,'pr_legacy_v1',NULL,1 FROM exercises;"
"CREATE TABLE exercise_profile_revisions("
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,"
"revision_id TEXT NOT NULL,parent_revision_id TEXT,recording_mode TEXT NOT NULL,"
"tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1)),"
"PRIMARY KEY(exercise_row_id,revision_id));"
"INSERT INTO exercise_profile_revisions SELECT id,'pr_legacy_v1',NULL,recording_mode,tracking_mode,data_fields,1 FROM exercises;"
"CREATE TRIGGER exercise_profile_state_insert AFTER INSERT ON exercises BEGIN "
"INSERT INTO exercise_profile_state(exercise_row_id,revision_id,parent_revision_id,legacy_seed) "
"VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',0);"
"INSERT INTO exercise_profile_revisions VALUES(NEW.id,'pr_legacy_v1',NULL,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,1);"
"INSERT INTO exercise_profile_revisions VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0);END;"
"CREATE TRIGGER exercise_profile_state_update AFTER UPDATE OF recording_mode,tracking_mode,data_fields ON exercises "
"WHEN (OLD.recording_mode<>NEW.recording_mode OR OLD.tracking_mode<>NEW.tracking_mode OR OLD.data_fields<>NEW.data_fields) "
"AND NOT EXISTS(SELECT 1 FROM exercise_profile_state s JOIN exercise_profile_revisions r ON r.exercise_row_id=s.exercise_row_id AND r.revision_id=s.revision_id WHERE s.exercise_row_id=NEW.id AND r.recording_mode=NEW.recording_mode AND r.tracking_mode=NEW.tracking_mode AND r.data_fields=NEW.data_fields) BEGIN "
"SELECT CASE WHEN (SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=NEW.id)>=32 THEN RAISE(ABORT,'profile revision history limit') END;"
"INSERT OR IGNORE INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,OLD.recording_mode,OLD.tracking_mode,OLD.data_fields,s.legacy_seed FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;"
"UPDATE exercise_profile_state SET parent_revision_id=revision_id,revision_id=trainlog_profile_revision(revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),legacy_seed=0 WHERE exercise_row_id=NEW.id;"
"INSERT INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;"
"PRAGMA user_version=17;COMMIT;";
/* WHY: the published-unreleased v17 prototype may already exist locally.
* CONTRACT: opening it adds the bounded durable lineage table without changing
* user_version or domain rows. INVARIANT: the guarded trigger records local
* edits, while an importer-selected known tip makes the trigger a no-op. */
static const char *const ENSURE_V17_PROFILE_REVISIONS_SQL =
"DROP TRIGGER IF EXISTS exercise_profile_state_insert;"
"DROP TRIGGER IF EXISTS exercise_profile_state_update;"
"CREATE TABLE IF NOT EXISTS exercise_profile_revisions("
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,"
"revision_id TEXT NOT NULL,parent_revision_id TEXT,recording_mode TEXT NOT NULL,"
"tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1)),"
"PRIMARY KEY(exercise_row_id,revision_id));"
"INSERT OR IGNORE INTO exercise_profile_revisions "
"SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,s.legacy_seed "
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id;"
"CREATE TRIGGER exercise_profile_state_insert AFTER INSERT ON exercises BEGIN "
"INSERT INTO exercise_profile_state VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',0);"
"INSERT INTO exercise_profile_revisions VALUES(NEW.id,'pr_legacy_v1',NULL,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,1);"
"INSERT INTO exercise_profile_revisions VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0);END;"
"CREATE TRIGGER exercise_profile_state_update AFTER UPDATE OF recording_mode,tracking_mode,data_fields ON exercises "
"WHEN (OLD.recording_mode<>NEW.recording_mode OR OLD.tracking_mode<>NEW.tracking_mode OR OLD.data_fields<>NEW.data_fields) "
"AND NOT EXISTS(SELECT 1 FROM exercise_profile_state s JOIN exercise_profile_revisions r ON r.exercise_row_id=s.exercise_row_id AND r.revision_id=s.revision_id WHERE s.exercise_row_id=NEW.id AND r.recording_mode=NEW.recording_mode AND r.tracking_mode=NEW.tracking_mode AND r.data_fields=NEW.data_fields) BEGIN "
"SELECT CASE WHEN (SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=NEW.id)>=32 THEN RAISE(ABORT,'profile revision history limit') END;"
"INSERT OR IGNORE INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,OLD.recording_mode,OLD.tracking_mode,OLD.data_fields,s.legacy_seed FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;"
"UPDATE exercise_profile_state SET parent_revision_id=revision_id,revision_id=trainlog_profile_revision(revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),legacy_seed=0 WHERE exercise_row_id=NEW.id;"
"INSERT INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;";
/* CONTRACT: normalize only the exact unpublished pr1 current-state shape.
* Validation precedes every mutation, and the caller owns one transaction
* covering compatibility-table setup, conversion, and trigger replacement. */
static TrainlogStatus normalize_v17_prototype_profiles(TrainlogDatabase *database)
{
int invalid = 0;
static const char *const VALIDATION_SQL =
"SELECT ("
"(SELECT COUNT(*) FROM exercise_profile_state s JOIN exercises e ON e.id=s.exercise_row_id "
" WHERE s.revision_id GLOB 'pr1*' AND (s.parent_revision_id IS NOT NULL OR s.legacy_seed<>1 OR "
" trainlog_pr1_valid(s.revision_id,e.recording_mode,e.tracking_mode,e.data_fields)<>1)) +"
"(SELECT COUNT(*) FROM exercise_profile_revisions r LEFT JOIN exercise_profile_state s "
" ON s.exercise_row_id=r.exercise_row_id AND s.revision_id=r.revision_id "
" WHERE r.revision_id GLOB 'pr1*' AND (s.exercise_row_id IS NULL OR r.parent_revision_id IS NOT NULL OR "
" r.legacy_seed<>1 OR r.recording_mode<>(SELECT recording_mode FROM exercises WHERE id=r.exercise_row_id) OR "
" r.tracking_mode<>(SELECT tracking_mode FROM exercises WHERE id=r.exercise_row_id) OR "
" r.data_fields<>(SELECT data_fields FROM exercises WHERE id=r.exercise_row_id))) +"
"(SELECT COUNT(*) FROM exercise_profile_state s WHERE s.revision_id GLOB 'pr1*' AND "
" (SELECT COUNT(*) FROM exercise_profile_revisions r WHERE r.exercise_row_id=s.exercise_row_id)<>1)"
");";
static const char *const NORMALIZE_SQL =
"DELETE FROM exercise_profile_revisions WHERE revision_id GLOB 'pr1*';"
"INSERT INTO exercise_profile_revisions "
"SELECT e.id,'pr_legacy_v1',NULL,e.recording_mode,e.tracking_mode,e.data_fields,1 "
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id "
"WHERE s.revision_id GLOB 'pr1*';"
"INSERT INTO exercise_profile_revisions "
"SELECT e.id,trainlog_profile_revision('pr_legacy_v1',e.recording_mode,e.tracking_mode,e.data_fields),"
"'pr_legacy_v1',e.recording_mode,e.tracking_mode,e.data_fields,0 "
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id "
"WHERE s.revision_id GLOB 'pr1*';"
"UPDATE exercise_profile_state SET parent_revision_id='pr_legacy_v1',legacy_seed=0,"
"revision_id=trainlog_profile_revision('pr_legacy_v1',"
"(SELECT recording_mode FROM exercises WHERE id=exercise_row_id),"
"(SELECT tracking_mode FROM exercises WHERE id=exercise_row_id),"
"(SELECT data_fields FROM exercises WHERE id=exercise_row_id)) "
"WHERE revision_id GLOB 'pr1*';";
if (read_single_int_pragma(database, VALIDATION_SQL, &invalid) != TRAINLOG_STATUS_OK ||
invalid != 0) return TRAINLOG_STATUS_DATABASE_ERROR;
return execute_sql(database, NORMALIZE_SQL);
}
static const char *const MIGRATE_V1_TO_V3_SQL =
"BEGIN IMMEDIATE;"
"ALTER TABLE sessions "
@ -1145,13 +1379,6 @@ static TrainlogStatus initialize_or_validate_schema(
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
}
if (
version ==
TRAINLOG_DATABASE_SCHEMA_VERSION
) {
return TRAINLOG_STATUS_OK;
}
if (version == 0) {
status =
execute_sql(
@ -1197,7 +1424,8 @@ static TrainlogStatus initialize_or_validate_schema(
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
} else if (version == 10) {
status = TRAINLOG_STATUS_OK;
} else if (version == 11 || version == 12 || version == 13 || version == 14) {
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
version == 15 || version == 16 || version == 17) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -1352,6 +1580,16 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK && version < 15) {
status = execute_sql(database, MIGRATE_V14_TO_V15_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 16) {
status = migrate_v15_to_v16(database);
}
if (status == TRAINLOG_STATUS_OK && version < 17) {
status = execute_sql(database, MIGRATE_V16_TO_V17_SQL);
}
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, "BEGIN IMMEDIATE;");
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, ENSURE_V17_PROFILE_REVISIONS_SQL);
if (status == TRAINLOG_STATUS_OK) status = normalize_v17_prototype_profiles(database);
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, "COMMIT;");
if (
status !=
@ -1360,7 +1598,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v15" : "migrate database to schema v15",
version == 0 ? "create schema v17" : "migrate database to schema v17",
database->connection,
SQLITE_ERROR
);
@ -1456,6 +1694,22 @@ TrainlogStatus trainlog_database_open_with_diagnostic(
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (sqlite3_create_function(database->connection, "trainlog_profile_revision", 4,
SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, sqlite_profile_revision, NULL, NULL) != SQLITE_OK) {
set_open_diagnostic(output_diagnostic, output_diagnostic_capacity,
"register profile revision function", database->connection, SQLITE_ERROR);
trainlog_database_close(database);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (sqlite3_create_function(database->connection, "trainlog_pr1_valid", 4,
SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, sqlite_prototype_profile_valid,
NULL, NULL) != SQLITE_OK) {
set_open_diagnostic(output_diagnostic, output_diagnostic_capacity,
"register prototype profile validator", database->connection, SQLITE_ERROR);
trainlog_database_close(database);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (sqlite3_busy_timeout(database->connection, 5000) != SQLITE_OK) {
set_open_diagnostic(
output_diagnostic,
@ -1957,7 +2211,6 @@ TrainlogStatus trainlog_database_update_exercise_profiled(
sqlite3_int64 row_id;
TrainlogStatus status;
int rc;
bool profile_changed;
const char *tracking;
const char *recording;
@ -1973,43 +2226,9 @@ TrainlogStatus trainlog_database_update_exercise_profiled(
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
rc = sqlite3_prepare_v2(database->connection,
"SELECT tracking_mode,recording_mode,data_fields FROM exercises WHERE id=?1;",
-1, &statement, NULL);
if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_ROW) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
profile_changed = strcmp((const char *)sqlite3_column_text(statement, 0), tracking) != 0 ||
strcmp((const char *)sqlite3_column_text(statement, 1), recording) != 0 ||
sqlite3_column_int64(statement, 2) != (sqlite3_int64)data_fields;
if (sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
statement = NULL;
if (profile_changed) {
rc = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM session_exercises WHERE exercise_row_id=?1 LIMIT 1;",
-1, &statement, NULL);
if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
rc = sqlite3_step(statement);
if (rc == SQLITE_ROW) {
status = TRAINLOG_STATUS_CONFLICT;
goto rollback;
}
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
statement = NULL;
}
/* CONTRACT: schema v16 makes tracking semantics occurrence-owned. A
* catalogue profile update therefore controls future occurrences only;
* this transaction never updates session_exercises snapshots. */
rc = sqlite3_prepare_v2(database->connection,
"UPDATE exercises SET name=?1,normalized_name=?2,tracking_mode=?3,"
"recording_mode=?4,data_fields=?5 WHERE id=?6;", -1, &statement, NULL);
@ -3050,6 +3269,7 @@ static TrainlogStatus insert_session_exercise(
sqlite3_int64 exercise_row_id;
const char *load_mode;
const char *recording_mode;
const char *tracking_mode;
char generated_entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
const char *entry_id;
int rc;
@ -3093,8 +3313,11 @@ static TrainlogStatus insert_session_exercise(
input->recording_mode
);
tracking_mode = tracking_mode_to_sql(input->tracking_mode);
if (load_mode == NULL ||
recording_mode == NULL) {
recording_mode == NULL ||
tracking_mode == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
@ -3195,14 +3418,14 @@ static TrainlogStatus insert_session_exercise(
database->connection,
"INSERT INTO session_exercises("
"entry_id, session_row_id, exercise_row_id, "
"recording_mode, data_fields, "
"recording_mode, tracking_mode, data_fields, "
"position, load_mode, rest_seconds, "
"target_sets, target_reps, "
"target_duration_seconds, "
"target_weight_kg, notes, equipment_id"
") VALUES("
"?1, ?2, ?3, ?4, ?5, ?6, ?7, "
"?8, ?9, ?10, ?11, ?12, ?13, ?14"
"?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, "
"?9, ?10, ?11, ?12, ?13, ?14, ?15"
");",
-1,
&statement,
@ -3241,10 +3464,14 @@ static TrainlogStatus insert_session_exercise(
);
}
if (rc == SQLITE_OK) {
rc = sqlite3_bind_text(statement, 5, tracking_mode, -1, SQLITE_STATIC);
}
if (rc == SQLITE_OK) {
rc = sqlite3_bind_int64(
statement,
5,
6,
(sqlite3_int64)input->data_fields
);
}
@ -3252,7 +3479,7 @@ static TrainlogStatus insert_session_exercise(
if (rc == SQLITE_OK) {
rc = sqlite3_bind_int64(
statement,
6,
7,
(sqlite3_int64)position
);
}
@ -3260,7 +3487,7 @@ static TrainlogStatus insert_session_exercise(
if (rc == SQLITE_OK) {
rc = sqlite3_bind_text(
statement,
7,
8,
load_mode,
-1,
SQLITE_STATIC
@ -3270,30 +3497,17 @@ static TrainlogStatus insert_session_exercise(
if (rc == SQLITE_OK) {
rc = sqlite3_bind_int(
statement,
8,
9,
input->rest_seconds
);
}
if (rc == SQLITE_OK) {
rc = input->target_sets > 0
? sqlite3_bind_int(
statement,
9,
input->target_sets
)
: sqlite3_bind_null(
statement,
9
);
}
if (rc == SQLITE_OK) {
rc = input->target_reps > 0
? sqlite3_bind_int(
statement,
10,
input->target_reps
input->target_sets
)
: sqlite3_bind_null(
statement,
@ -3302,11 +3516,11 @@ static TrainlogStatus insert_session_exercise(
}
if (rc == SQLITE_OK) {
rc = input->target_duration_seconds > 0
rc = input->target_reps > 0
? sqlite3_bind_int(
statement,
11,
input->target_duration_seconds
input->target_reps
)
: sqlite3_bind_null(
statement,
@ -3315,11 +3529,11 @@ static TrainlogStatus insert_session_exercise(
}
if (rc == SQLITE_OK) {
rc = input->target_has_weight
? sqlite3_bind_double(
rc = input->target_duration_seconds > 0
? sqlite3_bind_int(
statement,
12,
input->target_weight_kg
input->target_duration_seconds
)
: sqlite3_bind_null(
statement,
@ -3327,28 +3541,41 @@ static TrainlogStatus insert_session_exercise(
);
}
if (rc == SQLITE_OK) {
rc = input->target_has_weight
? sqlite3_bind_double(
statement,
13,
input->target_weight_kg
)
: sqlite3_bind_null(
statement,
13
);
}
if (rc == SQLITE_OK) {
rc =
input->notes != NULL &&
input->notes[0] != '\0'
? sqlite3_bind_text(
statement,
13,
14,
input->notes,
-1,
SQLITE_TRANSIENT
)
: sqlite3_bind_null(
statement,
13
14
);
}
if (rc == SQLITE_OK) {
rc = input->equipment_id[0] != '\0'
? sqlite3_bind_text(statement, 14, input->equipment_id, -1,
? sqlite3_bind_text(statement, 15, input->equipment_id, -1,
SQLITE_TRANSIENT)
: sqlite3_bind_null(statement, 14);
: sqlite3_bind_null(statement, 15);
}
if (rc != SQLITE_OK) {
@ -4717,7 +4944,7 @@ TrainlogStatus trainlog_database_get_session_details(
static const char *const EXERCISE_SQL =
"SELECT "
"e.name, e.tracking_mode, "
"e.name, se.tracking_mode, "
"se.recording_mode, se.data_fields, "
"se.load_mode, se.rest_seconds, "
"COALESCE(se.target_sets, 0), "
@ -5451,7 +5678,7 @@ TrainlogStatus trainlog_database_list_exercise_performance(
"s.session_id, "
"s.started_at, "
"s.session_type, "
"e.tracking_mode, "
"se.tracking_mode, "
"se.load_mode, "
"ps.id, "
"ps.reps, "
@ -5800,7 +6027,7 @@ TrainlogStatus trainlog_database_load_session_editable(
"se.id, "
"e.exercise_id, "
"e.name, "
"e.tracking_mode, "
"se.tracking_mode, "
"se.load_mode, "
"se.rest_seconds, "
"se.target_sets, "
@ -7093,7 +7320,7 @@ TrainlogStatus trainlog_database_list_exercise_occurrences_page(
"WHERE e.exercise_id=?1;";
static const char *const HYDRATE_SQL =
"SELECT s.session_id,se.entry_id,e.exercise_id,s.started_at,COALESCE(se.equipment_id,''),"
"s.session_type,e.tracking_mode,se.recording_mode,se.data_fields,se.load_mode,"
"s.session_type,se.tracking_mode,se.recording_mode,se.data_fields,se.load_mode,"
"(SELECT COUNT(*) FROM performed_sets ps WHERE ps.session_exercise_row_id=se.id),"
"ca.duration_seconds,ca.speed_kmh,ca.distance_km FROM session_exercises se "
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "

View file

@ -61,6 +61,8 @@ static const char *const TRAINING_FEEDBACK_NAME =
"trainlog-training-feedback-v2.json";
static const char *const TRAINING_FEEDBACK_V1_NAME =
"trainlog-training-feedback-v1.json";
static const char *const EXERCISE_PROFILE_STATE_NAME =
"trainlog-exercise-profile-state-v1.json";
static const char *const SYNC_REQUEST_NAME =
"trainlog-sync-request-v1.json";
@ -96,6 +98,8 @@ static const char *const EXERCISE_ALIASES_LOCAL =
"/tmp/trainlog-exercise-aliases-v1.json";
static const char *const TRAINING_FEEDBACK_LOCAL =
"/tmp/trainlog-training-feedback-v2.json";
static const char *const EXERCISE_PROFILE_STATE_LOCAL =
"/tmp/trainlog-exercise-profile-state-v1.json";
static const char *const EXERCISE_BODY_ZONES_RESULT =
"/tmp/trainlog-exercise-body-zones-result.txt";
@ -104,6 +108,8 @@ static const char *const EXERCISE_ALIASES_RESULT =
"/tmp/trainlog-exercise-aliases-result.txt";
static const char *const TRAINING_FEEDBACK_RESULT =
"/tmp/trainlog-training-feedback-result.txt";
static const char *const EXERCISE_PROFILE_STATE_RESULT =
"/tmp/trainlog-exercise-profile-state-result.txt";
static const char *const SYNC_REQUEST_LOCAL =
"/tmp/trainlog-sync-request-v1.json";
@ -2652,6 +2658,7 @@ TrainlogStatus trainlog_sync_run(
bool run_started = false;
bool receipt_published = false;
bool mobile_export_has_companions = false;
bool profile_state_received = false;
bool retained_mobile_export_is_v2 = false;
if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC ||
@ -2976,6 +2983,34 @@ TrainlogStatus trainlog_sync_run(
goto finalize;
}
/* CONTRACT: causal CURRENT-profile state precedes occurrence import, so a
* legitimate direct descendant (including the explicit legacy repair)
* cannot be rejected by the unchanged V3 occurrence validator. */
status = mobile_export_has_companions
? sync_receive_current_android_artifact(&device, folder_id,
EXERCISE_PROFILE_STATE_NAME, EXERCISE_PROFILE_STATE_LOCAL, &ignored_size)
: TRAINLOG_STATUS_NOT_FOUND;
if (status == TRAINLOG_STATUS_OK) {
profile_state_received = true;
status = sync_run_python_tool("import_exercise_profile_state.py",
EXERCISE_PROFILE_STATE_LOCAL, database_path, MOBILE_EXPORT_LOCAL,
EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_PROFILE_STATE_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(output->error, sizeof(output->error),
"Android→PC : profils : ", useful[0] ? useful : "import échoué");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
} else if (status != TRAINLOG_STATUS_NOT_FOUND) {
(void)snprintf(output->error, sizeof(output->error),
"Android→PC : lecture profile-state échouée.");
final_status = status;
goto finalize;
}
status =
sync_run_python_tool(
"import_mobile_export.py",
@ -3005,14 +3040,25 @@ TrainlogStatus trainlog_sync_run(
sizeof(useful)
);
sync_compose_diagnostic(
output->error,
sizeof(output->error),
"Android→PC : ",
useful[0] != '\0'
? useful
: "import mobile échoué"
);
/* WHY: V3/V2 peers may predate profile-state, so absence alone stays
* compatible. It becomes actionable only when the unchanged mobile
* importer proves that causal profile reconciliation was required.
* INVARIANT: never disguise unrelated import failures as capability
* failures. */
if (mobile_export_has_companions && !profile_state_received &&
strstr(useful, "profil incompatible entre identités") != NULL) {
sync_compose_diagnostic(output->error, sizeof(output->error),
"Android→PC : PROFILE_STATE_COMPANION_MISSING : ", useful);
} else {
sync_compose_diagnostic(
output->error,
sizeof(output->error),
"Android→PC : ",
useful[0] != '\0'
? useful
: "import mobile échoué"
);
}
final_status =
TRAINLOG_STATUS_DATABASE_ERROR;
@ -3024,6 +3070,24 @@ TrainlogStatus trainlog_sync_run(
output
);
/* The pre-pass may defer identities introduced by this mobile catalogue.
* This strict pass now requires every state record to resolve. */
if (profile_state_received) {
status = sync_run_python_tool("import_exercise_profile_state.py",
EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL,
EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_PROFILE_STATE_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(output->error, sizeof(output->error),
"Android→PC : vérification profils : ",
useful[0] ? useful : "import échoué");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
}
/* CONTRACT: body zones are one directional-neutral companion. Historic
* V2 publishers may omit it; when present beside V3/V2 it is applied after
* exercise definitions above established every ID or a source-V2-proven
@ -3139,6 +3203,25 @@ TrainlogStatus trainlog_sync_run(
}
outbound:
status = sync_run_python_tool("export_exercise_profile_state.py",
EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL,
EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_PROFILE_STATE_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : export profile-state échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id, EXERCISE_PROFILE_STATE_LOCAL,
EXERCISE_PROFILE_STATE_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication profile-state échouée.");
final_status = status;
goto finalize;
}
status = sync_run_python_tool("export_exercise_aliases.py",
EXERCISE_ALIASES_LOCAL, database_path, NULL, EXERCISE_ALIASES_RESULT,
tool_output, sizeof(tool_output));

View file

@ -79,23 +79,33 @@ TrainlogStatus trainlog_time_now_rfc3339(
}
TrainlogStatus trainlog_feedback_relative_label(
const char *ended_at, const char *observed_at, bool exercise_feedback,
const char *ended_at, const char *started_at, const char *observed_at,
bool exercise_feedback,
char *output, size_t output_size)
{
TrainlogTimestampKey ended, observed;
TrainlogTimestampKey ended, started, observed;
int written;
int64_t elapsed;
const char *neutral;
if (observed_at == NULL || output == NULL || output_size == 0U)
return TRAINLOG_STATUS_INVALID_ARGUMENT;
neutral = exercise_feedback ? "Ressenti" : "H+?";
if (ended_at == NULL || ended_at[0] == '\0' ||
!trainlog_timestamp_parse(ended_at, strlen(ended_at), &ended) ||
!trainlog_timestamp_parse(observed_at, strlen(observed_at), &observed)) {
written = snprintf(output, output_size, "%s", neutral);
if (!trainlog_timestamp_parse(observed_at, strlen(observed_at), &observed)) {
written = snprintf(output, output_size, "H+?");
} else if (ended_at == NULL || ended_at[0] == '\0' ||
!trainlog_timestamp_parse(ended_at, strlen(ended_at), &ended)) {
if (started_at != NULL && started_at[0] != '\0' &&
trainlog_timestamp_parse(started_at, strlen(started_at), &started) &&
observed.utc_second >= started.utc_second) {
elapsed = observed.utc_second - started.utc_second;
written = snprintf(output, output_size, "≈H+%lld",
(long long)(elapsed / INT64_C(3600)));
} else written = snprintf(output, output_size, "H+?");
} else if (observed.utc_second < ended.utc_second) {
bool within_session = exercise_feedback && started_at != NULL &&
started_at[0] != '\0' &&
trainlog_timestamp_parse(started_at, strlen(started_at), &started) &&
observed.utc_second >= started.utc_second;
written = snprintf(output, output_size, "%s",
exercise_feedback ? "Pendant la séance" : "H+?");
within_session ? "Pendant la séance" : "H+?");
} else {
elapsed = observed.utc_second - ended.utc_second;
written = snprintf(output, output_size, "H+%lld",

View file

@ -2478,18 +2478,20 @@ typedef enum TrainlogExercisePhase {
TRAINLOG_EXERCISE_MERGE_PICKER,
TRAINLOG_EXERCISE_MERGE_CONFIRM,
TRAINLOG_EXERCISE_MERGE_RESULT,
TRAINLOG_EXERCISE_CONFIRM_PROFILE,
TRAINLOG_EXERCISE_CONFIRM_DISCARD,
TRAINLOG_EXERCISE_MESSAGE
} TrainlogExercisePhase;
typedef struct TrainlogExerciseController {
/* CONTRACT: creation/editing owns one transient value set. An edit keeps
* exercise_id and passes the stored profile back unchanged; referenced
* profile restrictions remain enforced by the database transaction. */
* exercise_id; incompatible future-profile changes require confirmation
* and never mutate occurrence-owned historical snapshots. */
TrainlogExercisePhase phase;
TrainlogExercisePhase return_phase;
TrainlogFormField form;
TrainlogExercise pending;
TrainlogExercise original;
char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U];
char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U];
size_t secondary_count;
@ -2497,6 +2499,7 @@ typedef struct TrainlogExerciseController {
bool editing;
bool inline_session;
bool dirty;
bool profile_confirmed;
bool leaving;
TrainlogAppRoute pending_route;
char message[192];
@ -3654,22 +3657,22 @@ static TrainlogStatus dashboard_database_list_facts(TrainlogDatabase *database,
{
static const char *const SQL =
"SELECT 0,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,ps.position,"
"CASE WHEN e.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,ps.position,"
"CASE WHEN se.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END,"
"ps.weight_kg FROM performed_sets ps "
"JOIN session_exercises se ON se.id=ps.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"UNION ALL "
"SELECT 1,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,0,0,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,0,"
"mr.max_weight_kg FROM max_results mr "
"JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"UNION ALL "
"SELECT 2,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,0,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,"
"ca.duration_seconds,NULL FROM continuous_activity ca "
"JOIN session_exercises se ON se.id=ca.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
@ -4593,6 +4596,7 @@ static void exercise_controller_start_edit(TrainlogAppContext *app)
TrainlogExerciseController *controller = &app->exercise_controller;
(void)memset(controller, 0, sizeof(*controller));
controller->pending = app->exercise_detail;
controller->original = app->exercise_detail;
controller->editing = true;
controller->phase = TRAINLOG_EXERCISE_NAME;
controller->return_phase = TRAINLOG_EXERCISE_NAME;
@ -4706,6 +4710,13 @@ static bool exercise_controller_save(TrainlogAppContext *app)
TrainlogStatus status;
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U];
size_t index;
if (controller->editing && !controller->profile_confirmed &&
(controller->pending.tracking_mode != controller->original.tracking_mode ||
controller->pending.recording_mode != controller->original.recording_mode ||
controller->pending.data_fields != controller->original.data_fields)) {
controller->phase = TRAINLOG_EXERCISE_CONFIRM_PROFILE;
return false;
}
if (!controller->editing &&
controller->pending.recording_mode == TRAINLOG_RECORDING_SETS &&
controller->primary_zone[0] == '\0') {
@ -4812,6 +4823,17 @@ static bool app_shell_dispatch_exercise_controller(TrainlogAppContext *app,
controller->phase = controller->return_phase;
return true;
}
if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) {
if (key == 'y' || key == 'Y' || key == '1') {
controller->profile_confirmed = true;
controller->phase = TRAINLOG_EXERCISE_ZONES;
(void)exercise_controller_save(app);
} else if (key == 'n' || key == 'N' || key == '0' ||
key == TRAINLOG_KEY_ESCAPE || key == 'q' || key == 'Q') {
controller->phase = TRAINLOG_EXERCISE_ZONES;
}
return true;
}
if (controller->phase == TRAINLOG_EXERCISE_ZONES) {
const TrainlogBodyZone *zone = zone_count > 0U
? trainlog_body_zone_catalog_at(controller->zone_selected) : NULL;
@ -5068,7 +5090,12 @@ static void app_shell_actions(TrainlogAppContext *app)
}
if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) {
TrainlogExercisePhase phase = app->exercise_controller.phase;
if (phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
if (phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) {
app_shell_add_action(app, "exercise.profile.no", '0',
"0 Non", 1U, TRAINLOG_INTENT_BACK, route);
app_shell_add_action(app, "exercise.profile.yes", '1',
"1 Oui, modifier", 2U, TRAINLOG_INTENT_SAVE, route);
} else if (phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
app_shell_add_action(app, "exercise.form.discard", '1',
"1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route);
app_shell_add_action(app, "exercise.form.resume", '0',
@ -5705,6 +5732,17 @@ static void app_shell_render_exercise_controller(TrainlogAppContext *app)
"1 abandonner · 0/Échap reprendre");
return;
}
if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) {
trainlog_surface_printf(app->content, row++, 2,
"Modifier le mode de suivi ?");
trainlog_surface_printf(app->content, row++, 2,
"Les anciennes séances resteront inchangées.");
trainlog_surface_printf(app->content, row++, 2,
"Les prochaines utilisations suivront le nouveau profil.");
trainlog_surface_printf(app->content, row, 4,
"0/Échap/q Non · 1 Oui, modifier");
return;
}
if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) {
trainlog_surface_printf(app->content, row++, 2, "%s", controller->message);
trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir");
@ -6990,7 +7028,8 @@ static void app_shell_render_content(TrainlogAppContext *app)
heading = true;
}
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
feedback->observed_at, true, label, sizeof(label));
app->session_detail.started_at, feedback->observed_at,
true, label, sizeof(label));
trainlog_surface_printf(app->content, row++, 4, "%s %s", label,
feedback->raw_text);
}
@ -7045,6 +7084,7 @@ static void app_shell_render_content(TrainlogAppContext *app)
row < app->layout.content.height; ++followup_index) {
char label[32];
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
app->session_detail.started_at,
app->session_followups[followup_index].observed_at,
false, label, sizeof(label));
trainlog_surface_printf(app->content, row++, 4, "%s %s", label,

View file

@ -187,7 +187,11 @@ static int migration_preserves_identity_and_history(void)
"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);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,exercise_row_id INTEGER,entry_id TEXT,equipment_id TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,"
"session_row_id INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL,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));"
"INSERT INTO exercises VALUES(7,'ex_b432623f-bfe9-4daf-a653-60ec7fdffbde',"
"'Leg press','leg press','reps','sets',0);"
"INSERT INTO sessions VALUES(3,'se_preserved');"
@ -195,7 +199,7 @@ static int migration_preserves_identity_and_history(void)
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 15);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 17);
CHECK(trainlog_database_list_exercise_body_zones(database,
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 2U);

View file

@ -23,6 +23,20 @@
} \
} while (0)
#define CHECK_OR_CLEANUP(condition) \
do { \
if (!(condition)) { \
(void)fprintf( \
stderr, \
"CHECK failed at %s:%d: %s\\n", \
__FILE__, \
__LINE__, \
#condition \
); \
goto cleanup; \
} \
} while (0)
static bool test_normalization(void)
{
char first[512];
@ -76,19 +90,19 @@ static bool test_profiled_creation(void)
{
TrainlogDatabase *database = NULL;
TrainlogExercise walk;
TrainlogExercise exercises[4];
TrainlogExercise exercises[128];
size_t count = 0U;
size_t index;
bool found = false;
CHECK(
CHECK_OR_CLEANUP(
trainlog_database_open(
":memory:",
&database
) == TRAINLOG_STATUS_OK
);
CHECK(
CHECK_OR_CLEANUP(
trainlog_catalog_create_exercise_profiled(
database,
"Marche",
@ -99,22 +113,22 @@ static bool test_profiled_creation(void)
) == TRAINLOG_STATUS_OK
);
CHECK(
CHECK_OR_CLEANUP(
walk.recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS
);
CHECK(
CHECK_OR_CLEANUP(
walk.tracking_mode ==
TRAINLOG_TRACKING_DURATION
);
CHECK(
CHECK_OR_CLEANUP(
walk.data_fields ==
TRAINLOG_EXERCISE_DATA_SPEED_KMH
);
CHECK(
CHECK_OR_CLEANUP(
trainlog_catalog_create_exercise_profiled(
database,
"Profil invalide",
@ -125,33 +139,31 @@ static bool test_profiled_creation(void)
) == TRAINLOG_STATUS_INVALID_ARGUMENT
);
CHECK(
CHECK_OR_CLEANUP(
trainlog_database_list_exercises(
database,
exercises,
4U,
128U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
for (index = 0U; index < count; ++index) {
if (strcmp(
exercises[index].name,
"Marche"
) == 0) {
CHECK(
CHECK_OR_CLEANUP(
exercises[index].recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS
);
CHECK(
CHECK_OR_CLEANUP(
exercises[index].tracking_mode ==
TRAINLOG_TRACKING_DURATION
);
CHECK(
CHECK_OR_CLEANUP(
exercises[index].data_fields ==
TRAINLOG_EXERCISE_DATA_SPEED_KMH
);
@ -160,10 +172,14 @@ static bool test_profiled_creation(void)
}
}
CHECK(found);
CHECK_OR_CLEANUP(found);
trainlog_database_close(database);
return true;
cleanup:
trainlog_database_close(database);
return false;
}
int main(void)

View file

@ -62,6 +62,9 @@ int main(void)
exercise.recording_mode =
TRAINLOG_RECORDING_CONTINUOUS;
exercise.tracking_mode =
TRAINLOG_TRACKING_DURATION;
exercise.data_fields =
TRAINLOG_EXERCISE_DATA_SPEED_KMH;

View file

@ -73,6 +73,9 @@ int main(void)
exercise.recording_mode =
TRAINLOG_RECORDING_CONTINUOUS;
exercise.tracking_mode =
TRAINLOG_TRACKING_DURATION;
exercise.data_fields =
TRAINLOG_EXERCISE_DATA_SPEED_KMH;

View file

@ -23,10 +23,24 @@
} \
} while (0)
#define CHECK_OR_CLEANUP(condition) \
do { \
if (!(condition)) { \
(void)fprintf( \
stderr, \
"CHECK failed at %s:%d: %s\\n", \
__FILE__, \
__LINE__, \
#condition \
); \
goto cleanup; \
} \
} while (0)
static bool test_profile_roundtrip(void)
{
TrainlogDatabase *database = NULL;
TrainlogExercise exercises[4];
TrainlogExercise exercises[128];
size_t count = 0U;
size_t index;
bool found_walk = false;
@ -39,7 +53,7 @@ static bool test_profile_roundtrip(void)
) == TRAINLOG_STATUS_OK
);
CHECK(
CHECK_OR_CLEANUP(
trainlog_database_insert_exercise(
database,
"ex_legacy",
@ -49,7 +63,7 @@ static bool test_profile_roundtrip(void)
) == TRAINLOG_STATUS_OK
);
CHECK(
CHECK_OR_CLEANUP(
trainlog_database_insert_exercise_profiled(
database,
"ex_walk",
@ -61,7 +75,7 @@ static bool test_profile_roundtrip(void)
) == TRAINLOG_STATUS_OK
);
CHECK(
CHECK_OR_CLEANUP(
trainlog_database_insert_exercise_profiled(
database,
"ex_invalid",
@ -73,7 +87,7 @@ static bool test_profile_roundtrip(void)
) == TRAINLOG_STATUS_INVALID_ARGUMENT
);
CHECK(
CHECK_OR_CLEANUP(
trainlog_database_insert_exercise_profiled(
database,
"ex_unknown",
@ -85,28 +99,26 @@ static bool test_profile_roundtrip(void)
) == TRAINLOG_STATUS_INVALID_ARGUMENT
);
CHECK(
CHECK_OR_CLEANUP(
trainlog_database_list_exercises(
database,
exercises,
4U,
128U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 2U);
for (index = 0U; index < count; ++index) {
if (strcmp(exercises[index].exercise_id, "ex_walk") == 0) {
CHECK(
CHECK_OR_CLEANUP(
exercises[index].recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS
);
CHECK(
CHECK_OR_CLEANUP(
exercises[index].tracking_mode ==
TRAINLOG_TRACKING_DURATION
);
CHECK(
CHECK_OR_CLEANUP(
exercises[index].data_fields ==
TRAINLOG_EXERCISE_DATA_SPEED_KMH
);
@ -114,20 +126,24 @@ static bool test_profile_roundtrip(void)
}
if (strcmp(exercises[index].exercise_id, "ex_legacy") == 0) {
CHECK(
CHECK_OR_CLEANUP(
exercises[index].recording_mode ==
TRAINLOG_RECORDING_SETS
);
CHECK(exercises[index].data_fields == 0U);
CHECK_OR_CLEANUP(exercises[index].data_fields == 0U);
found_legacy = true;
}
}
CHECK(found_walk);
CHECK(found_legacy);
CHECK_OR_CLEANUP(found_walk);
CHECK_OR_CLEANUP(found_legacy);
trainlog_database_close(database);
return true;
cleanup:
trainlog_database_close(database);
return false;
}
static bool test_v2_to_current_migration(void)
@ -179,34 +195,36 @@ static bool test_v2_to_current_migration(void)
sqlite3 *raw = NULL;
sqlite3_stmt *statement = NULL;
TrainlogDatabase *database = NULL;
int fd;
int fd = -1;
int version = 0;
bool success = false;
fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK_OR_CLEANUP(fd >= 0);
CHECK_OR_CLEANUP(close(fd) == 0);
fd = -1;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(
CHECK_OR_CLEANUP(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK_OR_CLEANUP(
sqlite3_exec(raw, V2_SQL, NULL, NULL, NULL) ==
SQLITE_OK
);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK_OR_CLEANUP(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(
CHECK_OR_CLEANUP(
trainlog_database_open(path, &database) ==
TRAINLOG_STATUS_OK
);
CHECK(
CHECK_OR_CLEANUP(
trainlog_database_schema_version(
database,
&version
) == TRAINLOG_STATUS_OK
);
CHECK(
CHECK_OR_CLEANUP(
version ==
TRAINLOG_DATABASE_SCHEMA_VERSION
);
@ -214,9 +232,9 @@ static bool test_v2_to_current_migration(void)
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK_OR_CLEANUP(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(
CHECK_OR_CLEANUP(
sqlite3_prepare_v2(
raw,
"SELECT recording_mode, data_fields "
@ -227,18 +245,18 @@ static bool test_v2_to_current_migration(void)
) == SQLITE_OK
);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(
CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW);
CHECK_OR_CLEANUP(
strcmp(
(const char *)sqlite3_column_text(statement, 0),
"sets"
) == 0
);
CHECK(sqlite3_column_int64(statement, 1) == 0);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK_OR_CLEANUP(sqlite3_column_int64(statement, 1) == 0);
CHECK_OR_CLEANUP(sqlite3_finalize(statement) == SQLITE_OK);
statement = NULL;
CHECK(
CHECK_OR_CLEANUP(
sqlite3_prepare_v2(
raw,
"SELECT recording_mode, data_fields "
@ -249,18 +267,18 @@ static bool test_v2_to_current_migration(void)
) == SQLITE_OK
);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(
CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW);
CHECK_OR_CLEANUP(
strcmp(
(const char *)sqlite3_column_text(statement, 0),
"sets"
) == 0
);
CHECK(sqlite3_column_int64(statement, 1) == 0);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK_OR_CLEANUP(sqlite3_column_int64(statement, 1) == 0);
CHECK_OR_CLEANUP(sqlite3_finalize(statement) == SQLITE_OK);
statement = NULL;
CHECK(
CHECK_OR_CLEANUP(
sqlite3_prepare_v2(
raw,
"SELECT name FROM sqlite_master "
@ -271,14 +289,23 @@ static bool test_v2_to_current_migration(void)
) == SQLITE_OK
);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW);
CHECK_OR_CLEANUP(sqlite3_finalize(statement) == SQLITE_OK);
statement = NULL;
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK_OR_CLEANUP(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(unlink(path) == 0);
return true;
success = true;
cleanup:
if (fd >= 0) (void)close(fd);
if (statement != NULL) (void)sqlite3_finalize(statement);
if (raw != NULL) (void)sqlite3_close(raw);
if (database != NULL) trainlog_database_close(database);
if (!success) (void)unlink(path);
return success;
}
int main(void)

View file

@ -2,11 +2,14 @@
#include <stdio.h>
#include <string.h>
#define CHECK(x) do { if (!(x)) { fprintf(stderr,"FAIL %d\n",__LINE__); return 1; } } while (0)
static int label(const char *end,const char *at,bool exercise,const char *expected){char out[64];CHECK(trainlog_feedback_relative_label(end,at,exercise,out,sizeof(out))==TRAINLOG_STATUS_OK);CHECK(strcmp(out,expected)==0);CHECK(strstr(out,"H+-")==NULL);return 0;}
static int label(const char *end,const char *start,const char *at,bool exercise,const char *expected){char out[64];CHECK(trainlog_feedback_relative_label(end,start,at,exercise,out,sizeof(out))==TRAINLOG_STATUS_OK);CHECK(strcmp(out,expected)==0);CHECK(strstr(out,"H+-")==NULL);return 0;}
int main(void){const char *end="2026-09-01T10:00:00+02:00";
CHECK(label(end,"2026-09-01T09:20:00+02:00",true,"Pendant la séance")==0);
CHECK(label(end,"2026-09-01T09:59:59+02:00",true,"Pendant la séance")==0);
CHECK(label(end,end,true,"H+0")==0);CHECK(label(end,"2026-09-01T18:00:00+02:00",true,"H+8")==0);
CHECK(label(NULL,"2026-09-01T09:20:00+02:00",true,"Ressenti")==0);
CHECK(label(NULL,"2026-09-01T18:00:00+02:00",false,"H+?")==0);
CHECK(label(end,"2026-09-01T09:00:00+02:00",false,"H+?")==0);return 0;}
CHECK(label(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:20:00+02:00",true,"Pendant la séance")==0);
CHECK(label(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:59:59+02:00",true,"Pendant la séance")==0);
CHECK(label(end,"2026-09-01T09:30:00+02:00","2026-09-01T09:20:00+02:00",true,"H+?")==0);
CHECK(label(end,NULL,end,true,"H+0")==0);CHECK(label(end,NULL,"2026-09-01T18:00:00+02:00",true,"H+8")==0);
CHECK(label(NULL,NULL,"2026-09-01T09:20:00+02:00",true,"H+?")==0);
CHECK(label(NULL,"2026-09-01T08:00:00+02:00","2026-09-01T18:00:00+02:00",false,"≈H+10")==0);
CHECK(label(NULL,"2026-09-01T08:00:00+02:00","2026-09-01T18:00:00+02:00",true,"≈H+10")==0);
CHECK(label(NULL,NULL,"2026-09-01T18:00:00+02:00",false,"H+?")==0);
CHECK(label(end,NULL,"2026-09-01T09:00:00+02:00",false,"H+?")==0);return 0;}

View file

@ -56,6 +56,7 @@ static void max_input(TrainlogSessionExerciseInput *input,
(void)snprintf(input->equipment_id, sizeof(input->equipment_id), "%s",
"rear_delt_pec_fly");
input->recording_mode = TRAINLOG_RECORDING_SETS;
input->tracking_mode = TRAINLOG_TRACKING_REPS;
input->load_mode = TRAINLOG_LOAD_NONE;
input->has_max_weight = true;
input->max_weight_kg = weight;
@ -88,6 +89,7 @@ static bool test_explicit_max_round_trip_and_identity(void)
(void)snprintf(entries[2].exercise_id, sizeof(entries[2].exercise_id), "%s",
"ex_walk");
entries[2].recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
entries[2].tracking_mode = TRAINLOG_TRACKING_DURATION;
entries[2].data_fields = TRAINLOG_EXERCISE_DATA_SPEED_KMH;
entries[2].load_mode = TRAINLOG_LOAD_NONE;
entries[2].continuous_duration_seconds = 600;
@ -163,16 +165,21 @@ static bool test_v8_migration_refuses_to_guess_multiple_attempts(void)
"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_type TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,"
"session_row_id INTEGER,exercise_row_id INTEGER,entry_id TEXT,"
"equipment_id TEXT,recording_mode TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,"
"session_row_id INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL,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,position INTEGER,reps INTEGER,"
"duration_seconds INTEGER,weight_kg REAL);"
"INSERT INTO exercises VALUES(1,'ex_max_a','Max A','max a','reps','sets',0);"
"INSERT INTO exercises VALUES(2,'ex_max_b','Max B','max b','reps','sets',0);"
"INSERT INTO exercises VALUES(3,'ex_max_c','Max C','max c','reps','sets',0);"
"INSERT INTO sessions VALUES(1,'max_test');"
"INSERT INTO session_exercises VALUES(10,1,NULL,NULL,NULL,'sets');"
"INSERT INTO session_exercises VALUES(11,1,NULL,NULL,NULL,'sets');"
"INSERT INTO session_exercises VALUES(12,1,NULL,NULL,NULL,'sets');"
"INSERT INTO session_exercises VALUES(10,'sxe_max_a',1,1,'sets',0,0,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
"INSERT INTO session_exercises VALUES(11,'sxe_max_b',1,2,'sets',0,1,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
"INSERT INTO session_exercises VALUES(12,'sxe_max_c',1,3,'sets',0,2,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
"INSERT INTO performed_sets VALUES(20,10,0,1,NULL,100.0);"
"INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.0);"
"INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);"

View file

@ -0,0 +1,227 @@
/**
* @file test_schema_v16_migration.c
* @brief Occurrence-owned tracking-mode migration and future-profile proof.
*/
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sqlite3.h>
#include "trainlog/database.h"
#define CHECK(x) do { if (!(x)) { (void)fprintf(stderr, \
"CHECK failed %s:%d: %s\n", __FILE__, __LINE__, #x); return false; } } while (0)
static const char *const FIXTURE =
"PRAGMA foreign_keys=ON;"
"CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,"
"normalized_name TEXT 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 UNIQUE,started_at TEXT,"
"ended_at TEXT,session_type TEXT,notes TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT UNIQUE,"
"session_row_id INTEGER REFERENCES sessions(id),exercise_row_id INTEGER REFERENCES exercises(id),"
"recording_mode TEXT,data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER,"
"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 REFERENCES session_exercises(id),"
"position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);"
"CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id),"
"duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);"
"CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY REFERENCES session_exercises(id),max_weight_kg REAL);"
"CREATE TABLE exercise_body_zones(exercise_row_id INTEGER REFERENCES exercises(id),zone_id TEXT,role TEXT,PRIMARY KEY(exercise_row_id,zone_id));"
"CREATE TABLE exercise_body_zone_sync(exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id),synced_state TEXT);"
"CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT UNIQUE,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,observed_at TEXT,raw_text TEXT);"
"CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT,raw_text TEXT);"
"CREATE TABLE session_followups(id INTEGER PRIMARY KEY,followup_id TEXT UNIQUE,session_row_id INTEGER REFERENCES sessions(id),observed_at TEXT,raw_text TEXT);"
"CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,followup_id TEXT REFERENCES session_followups(followup_id),created_at TEXT,raw_text TEXT);"
"INSERT INTO exercises VALUES(7,'ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac','Planche face sol','planche face sol','reps','sets',0);"
"INSERT INTO exercises VALUES(8,'ex_e32c40a6-72b4-4233-a0db-41b27e990c96','Planche gauche sol','planche gauche sol','reps','sets',0);"
"INSERT INTO exercises VALUES(9,'ex_999fd503-ce89-49f9-979d-c19e6adbe80d','Planche droite sol','planche droite sol','reps','sets',0);"
"INSERT INTO sessions VALUES(11,'se_old','2026-09-01T10:00:00Z',NULL,'training','note');"
"INSERT INTO session_exercises VALUES(13,'sxe_old',11,7,'sets',0,0,'none',30,1,3,NULL,NULL,NULL,'occurrence');"
"INSERT INTO session_exercises VALUES(14,'sxe_left_old',11,8,'sets',0,1,'none',30,1,3,NULL,NULL,NULL,'occurrence gauche');"
"INSERT INTO performed_sets VALUES(17,13,0,3,NULL,NULL);"
"INSERT INTO performed_sets VALUES(18,14,0,3,NULL,NULL);"
"INSERT INTO exercise_feedback VALUES(19,'fb_old',13,'2026-09-01T10:01:00Z','stable');"
"INSERT INTO exercise_feedback_revisions VALUES('fr_old','fb_old','2026-09-01T10:02:00Z','révisé');"
"INSERT INTO session_followups VALUES(23,'fu_old',11,'2026-09-01T12:00:00Z','suivi');"
"INSERT INTO session_followup_revisions VALUES('ur_old','fu_old','2026-09-01T12:01:00Z','suivi révisé');"
"PRAGMA user_version=15;";
static bool scalar(sqlite3 *db, const char *sql, int expected)
{
sqlite3_stmt *s = NULL; bool ok = false;
if (sqlite3_prepare_v2(db, sql, -1, &s, NULL) == SQLITE_OK &&
sqlite3_step(s) == SQLITE_ROW) ok = sqlite3_column_int(s, 0) == expected;
(void)sqlite3_finalize(s); return ok;
}
#undef CHECK
#define CHECK(x) do { if (!(x)) { (void)fprintf(stderr, \
"CHECK failed %s:%d: %s\n", __FILE__, __LINE__, #x); goto cleanup; } } while (0)
static bool run_test(void)
{
char path[] = "/tmp/trainlog-v16-XXXXXX";
int fd = mkstemp(path); sqlite3 *raw = NULL; TrainlogDatabase *db = NULL;
bool ok = false;
TrainlogPersistedExerciseDetail old[1] = {{0}};
TrainlogSessionSummary summary;
size_t count = 0U; int version = 0;
TrainlogExercise changed;
TrainlogSetInput new_set = {.duration_seconds = 30};
TrainlogSessionExerciseInput input = {0};
TrainlogSessionInput session = {0};
char diagnostic[512] = {0};
CHECK(fd >= 0); CHECK(close(fd) == 0); fd = -1;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, FIXTURE, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
if (trainlog_database_open_with_diagnostic(path, &db, diagnostic,
sizeof(diagnostic)) != TRAINLOG_STATUS_OK) {
(void)fprintf(stderr, "open diagnostic: %s\n", diagnostic);
CHECK(false);
}
CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && version == 17);
trainlog_database_close(db); db = NULL;
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK);
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE s.revision_id IS NULL OR s.revision_id<>'pr_legacy_v1' OR s.legacy_seed<>1", 0));
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_revisions r ON r.exercise_row_id=e.id AND r.revision_id='pr_legacy_v1' AND r.legacy_seed=1 WHERE r.revision_id IS NULL", 0));
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode' AND \"notnull\"=1", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode' AND dflt_value IS NULL", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises WHERE id=13 AND entry_id='sxe_old' AND tracking_mode='reps'", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM performed_sets WHERE id=17 AND reps=3 AND duration_seconds IS NULL", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_feedback WHERE id=19 AND feedback_id='fb_old'", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_feedback_revisions WHERE revision_id='fr_old'", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followups WHERE id=23", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followup_revisions WHERE revision_id='ur_old'", 1));
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_update_exercise_profiled(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", "Planche face sol",
"planche face sol", TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U,
NULL, NULL, 0U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_update_exercise_profiled(db, "ex_e32c40a6-72b4-4233-a0db-41b27e990c96", "Planche gauche sol",
"planche gauche sol", TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U,
NULL, NULL, 0U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_update_exercise_profiled(db, "ex_999fd503-ce89-49f9-979d-c19e6adbe80d", "Planche droite sol",
"planche droite sol", TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U,
NULL, NULL, 0U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_get_session_details(db, "se_old", &summary, old, 1U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U && old[0].tracking_mode == TRAINLOG_TRACKING_REPS &&
old[0].actual_set_count == 1U && old[0].actual_sets[0].reps == 3);
trainlog_database_free_session_details(old, count);
CHECK(trainlog_database_get_exercise_profile(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", &changed) == TRAINLOG_STATUS_OK);
(void)snprintf(input.entry_id, sizeof(input.entry_id), "sxe_new");
(void)snprintf(input.exercise_id, sizeof(input.exercise_id), "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac");
input.recording_mode = changed.recording_mode; input.tracking_mode = changed.tracking_mode;
input.load_mode = TRAINLOG_LOAD_NONE; input.sets = &new_set; input.set_count = 1U;
(void)snprintf(session.session_id, sizeof(session.session_id), "se_new");
(void)snprintf(session.started_at, sizeof(session.started_at), "2026-09-02T10:00:00Z");
session.session_type = TRAINLOG_SESSION_TRAINING; session.exercises = &input; session.exercise_count = 1U;
CHECK(trainlog_database_insert_session(db, &session) == TRAINLOG_STATUS_OK);
trainlog_database_close(db); db = NULL;
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises se JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE se.entry_id='sxe_old' AND se.tracking_mode='reps' AND ps.reps=3 AND ps.duration_seconds IS NULL", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises se JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE se.entry_id='sxe_left_old' AND se.tracking_mode='reps' AND ps.reps=3 AND ps.duration_seconds IS NULL", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises WHERE exercise_id IN('ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac','ex_e32c40a6-72b4-4233-a0db-41b27e990c96','ex_999fd503-ce89-49f9-979d-c19e6adbe80d') AND recording_mode='sets' AND tracking_mode='duration' AND data_fields=0", 3));
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises se JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE se.entry_id='sxe_new' AND se.tracking_mode='duration' AND ps.reps IS NULL AND ps.duration_seconds=30", 1));
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
ok = true;
cleanup:
if (raw != NULL) (void)sqlite3_close(raw);
if (db != NULL) trainlog_database_close(db);
if (fd >= 0) (void)close(fd);
if (unlink(path) != 0) ok = false;
return ok;
}
static bool rollback_on_invalid_backfill(void)
{
char path[] = "/tmp/trainlog-v16-invalid-XXXXXX";
int fd = mkstemp(path); sqlite3 *raw = NULL; TrainlogDatabase *db = NULL;
bool ok = false;
char diagnostic[512] = {0};
CHECK(fd >= 0); CHECK(close(fd) == 0); fd = -1;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, FIXTURE, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "UPDATE exercises SET tracking_mode='invalid';",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
CHECK(trainlog_database_open_with_diagnostic(path, &db, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(db == NULL);
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(scalar(raw, "SELECT user_version FROM pragma_user_version", 15));
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode'", 0));
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises WHERE id=13 AND entry_id='sxe_old'", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM performed_sets WHERE id=17 AND reps=3", 1));
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
ok = true;
cleanup:
if (raw != NULL) (void)sqlite3_close(raw);
if (db != NULL) trainlog_database_close(db);
if (fd >= 0) (void)close(fd);
if (unlink(path) != 0) ok = false;
return ok;
}
static bool normalize_v17_prototype_in_place(void)
{
char path[] = "/tmp/trainlog-v17-pr1-XXXXXX";
int fd = mkstemp(path); sqlite3 *raw = NULL; TrainlogDatabase *db = NULL;
bool ok = false;
char diagnostic[512] = {0};
CHECK(fd >= 0); CHECK(close(fd) == 0); fd = -1;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, FIXTURE, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK);
trainlog_database_close(db); db = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"DELETE FROM exercise_profile_revisions WHERE exercise_row_id=9;"
"UPDATE exercise_profile_state SET revision_id='pr1|sets|reps|0',parent_revision_id=NULL,legacy_seed=1 WHERE exercise_row_id=9;"
"INSERT INTO exercise_profile_revisions VALUES(9,'pr1|sets|reps|0',NULL,'sets','reps',0,1);",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
CHECK(trainlog_database_open_with_diagnostic(path, &db, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_OK);
trainlog_database_close(db); db = NULL;
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9", 2));
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9 AND revision_id='pr_legacy_v1' AND parent_revision_id IS NULL AND legacy_seed=1", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_state WHERE exercise_row_id=9 AND revision_id='pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b' AND parent_revision_id='pr_legacy_v1' AND legacy_seed=0", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE revision_id GLOB 'pr1*'", 0));
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises WHERE id=13 AND tracking_mode='reps'", 1));
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
/* A second open is data-idempotent. */
CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK);
trainlog_database_close(db); db = NULL;
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK);
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9", 2));
CHECK(sqlite3_exec(raw,
"DELETE FROM exercise_profile_revisions WHERE exercise_row_id=9;"
"UPDATE exercise_profile_state SET revision_id='pr1|sets|reps|00',parent_revision_id=NULL,legacy_seed=1 WHERE exercise_row_id=9;"
"INSERT INTO exercise_profile_revisions VALUES(9,'pr1|sets|reps|00',NULL,'sets','reps',0,1);",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
CHECK(trainlog_database_open_with_diagnostic(path, &db, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(db == NULL);
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_state WHERE exercise_row_id=9 AND revision_id='pr1|sets|reps|00'", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9 AND revision_id='pr1|sets|reps|00'", 1));
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
ok = true;
cleanup:
if (raw != NULL) (void)sqlite3_close(raw);
if (db != NULL) trainlog_database_close(db);
if (fd >= 0) (void)close(fd);
if (unlink(path) != 0) ok = false;
return ok;
}
int main(void) { return run_test() && rollback_on_invalid_backfill() &&
normalize_v17_prototype_in_place() ? 0 : 1; }

View file

@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
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 v15: SQLite rc=",
strlen("migrate database to schema v15: SQLite rc=")) == 0);
CHECK(strncmp(diagnostic, "migrate database to schema v17: SQLite rc=",
strlen("migrate database to schema v17: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
@ -145,7 +145,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
static bool test_newer_schema_has_application_diagnostic(void)
{
char path[] = "/tmp/trainlog-schema-v16-XXXXXX";
char path[] = "/tmp/trainlog-schema-v17-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=16;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=18;", 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 16 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 18 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v15") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v17") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -75,6 +75,9 @@ static void bind_reps_exercise(
exercise->load_mode =
TRAINLOG_LOAD_EXTERNAL;
exercise->recording_mode = TRAINLOG_RECORDING_SETS;
exercise->tracking_mode = TRAINLOG_TRACKING_REPS;
exercise->rest_seconds = 90;
exercise->target_sets = 3;
exercise->target_reps = 10;
@ -235,6 +238,7 @@ static bool test_load_replace_and_rollback(void)
);
CHECK(loaded_exercises[0].set_offset == 0U);
CHECK(loaded_exercises[0].set_count == 3U);
CHECK(loaded_exercises[0].tracking_mode == TRAINLOG_TRACKING_REPS);
CHECK(loaded_sets[0].reps == 10);
CHECK(loaded_sets[2].reps == 8);
CHECK(loaded_sets[0].weight_kg > 79.99);

View file

@ -27,6 +27,8 @@ static const char *const EXERCISE_NAME = "Sync wiring exercise";
static char remote_root[4096];
static const char *remote_mobile_name = "trainlog-mobile-export-v2.json";
static unsigned int remote_mobile_version = 2U;
static const char *remote_tracking_mode = "reps";
static bool remote_profile_available = true;
static bool copy_file(const char *source, const char *target)
{
@ -148,7 +150,8 @@ TrainlogStatus trainlog_mtp_list_folder(
return TRAINLOG_STATUS_OK;
}
if (parent_folder_id == 2U) {
if (capacity < 3U) {
size_t count = remote_profile_available ? 4U : 3U;
if (capacity < count) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
set_entry(&output[0], 10U, 2U, remote_mobile_name, false);
@ -156,7 +159,11 @@ TrainlogStatus trainlog_mtp_list_folder(
"trainlog-exercise-body-zones-v1.json", false);
set_entry(&output[2], 12U, 2U,
"trainlog-equipment-associations-v2.json", false);
*output_count = 3U;
if (remote_profile_available) {
set_entry(&output[3], 13U, 2U,
"trainlog-exercise-profile-state-v1.json", false);
}
*output_count = count;
return TRAINLOG_STATUS_OK;
}
*output_count = 0U;
@ -178,7 +185,8 @@ TrainlogStatus trainlog_mtp_receive_file(
name = item_id == 10U ? remote_mobile_name :
item_id == 11U ? "trainlog-exercise-body-zones-v1.json" :
item_id == 12U ? "trainlog-equipment-associations-v2.json" : NULL;
item_id == 12U ? "trainlog-equipment-associations-v2.json" :
item_id == 13U ? "trainlog-exercise-profile-state-v1.json" : NULL;
if (name == NULL || local_path == NULL) {
return TRAINLOG_STATUS_NOT_FOUND;
}
@ -274,12 +282,31 @@ static bool write_artifacts(
"{\"format\":\"trainlog-mobile-export\",\"version\":%u,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
"\"exercises\":[{\"exercise_id\":\"%s\",\"name\":\"%s\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"%s\","
"\"data_fields\":0}],\"sessions\":[],\"body_observations\":[]}",
remote_mobile_version, mobile_id, EXERCISE_NAME) > 0);
remote_mobile_version, mobile_id, EXERCISE_NAME,
remote_tracking_mode) > 0);
}
CHECK(fclose(file) == 0);
written = snprintf(path, sizeof(path),
"%s/trainlog-exercise-profile-state-v1.json", remote_root);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "wb");
CHECK(file != NULL);
CHECK(fprintf(file,
"{\"format\":\"trainlog-exercise-profile-state\",\"version\":1,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\",\"exercises\":[{"
"\"exercise_id\":\"%s\",\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
"\"data_fields\":0,\"load_semantics\":null,\"machine_variant\":null,"
"\"machine_provenance\":null,\"scientific_profile_id\":null,"
"\"science_state\":\"unresolved\",\"legacy_equipment_id\":null,"
"\"revision_id\":\"pr_legacy_v1\",\"parent_revision_id\":null,\"legacy_seed\":true,"
"\"history\":[{\"revision_id\":\"pr_legacy_v1\",\"parent_revision_id\":null,"
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0,\"legacy_seed\":true}]}]}",
CANONICAL_ID) > 0);
CHECK(fclose(file) == 0);
written = snprintf(path, sizeof(path),
"%s/trainlog-exercise-body-zones-v1.json", remote_root);
CHECK(written >= 0 && (size_t)written < sizeof(path));
@ -589,6 +616,32 @@ static bool run_v3_unknown_with_stale_v2_proof_case(const char *case_root)
return true;
}
static bool run_missing_profile_companion_case(const char *case_root)
{
TrainlogSyncReport report;
char database_path[4096];
/* A stale local profile file is deliberately left by earlier cases. The
* selected MTP generation omits it, so neither causal pass may consume it. */
remote_mobile_name = "trainlog-mobile-export-v3.json";
remote_mobile_version = 3U;
remote_tracking_mode = "duration";
remote_profile_available = false;
CHECK(prepare_database(case_root, false, false,
database_path, sizeof(database_path)));
CHECK(write_artifacts(CANONICAL_ID, CANONICAL_ID, false));
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(!report.success);
CHECK(strstr(report.error, "PROFILE_STATE_COMPANION_MISSING") != NULL);
CHECK(strstr(report.error, "profil incompatible entre identités") != NULL);
remote_profile_available = true;
remote_tracking_mode = "reps";
remote_mobile_name = "trainlog-mobile-export-v2.json";
remote_mobile_version = 2U;
return true;
}
static bool run_all(void)
{
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
@ -596,6 +649,7 @@ static bool run_all(void)
char case_b[4096];
char case_c[4096];
char case_d[4096];
char case_e[4096];
char *root = mkdtemp(temporary);
CHECK(root != NULL);
@ -605,11 +659,13 @@ static bool run_all(void)
CHECK(snprintf(case_b, sizeof(case_b), "%s/case-b", root) > 0);
CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", root) > 0);
CHECK(snprintf(case_d, sizeof(case_d), "%s/case-d", root) > 0);
CHECK(snprintf(case_e, sizeof(case_e), "%s/case-e", root) > 0);
CHECK(run_success_case(case_a, false));
CHECK(run_success_case(case_b, true));
CHECK(run_unknown_without_proof_case(case_c));
CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d));
CHECK(run_missing_profile_companion_case(case_e));
(void)puts("PASS production sync body-zone V2 proof wiring");
return true;
}

View file

@ -620,6 +620,25 @@ static bool test_shell_inline_exercise_creation_and_stable_edit(void)
CHECK(trainlog_database_list_exercises(database, listed, 8U,
&count) == TRAINLOG_STATUS_OK);
CHECK(count == 6U);
/* Incompatible axes are future defaults only, but still require the
* global explicit confirmation contract with cancel as the safe default. */
exercise_controller_start_edit(&app);
app.exercise_controller.pending.recording_mode = TRAINLOG_RECORDING_SETS;
app.exercise_controller.pending.tracking_mode = TRAINLOG_TRACKING_DURATION;
app.exercise_controller.phase = TRAINLOG_EXERCISE_ZONES;
CHECK(!exercise_controller_save(&app));
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE);
CHECK(app_shell_dispatch_exercise_controller(&app, 'q'));
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_ZONES);
CHECK(app.exercise_detail.recording_mode == TRAINLOG_RECORDING_CONTINUOUS);
CHECK(!exercise_controller_save(&app));
CHECK(app_shell_dispatch_exercise_controller(&app, '1'));
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_MESSAGE);
CHECK(app.exercise_detail.recording_mode == TRAINLOG_RECORDING_SETS);
CHECK(app.exercise_detail.tracking_mode == TRAINLOG_TRACKING_DURATION);
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_IDLE);
trainlog_database_close(database);
return true;
}