feat(trainlog): complete stats knowledge and sync hardening
This commit is contained in:
parent
2dec03e7b2
commit
32fae197ee
76 changed files with 3701 additions and 1031 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -1,5 +1,28 @@
|
||||||
# Changelog
|
# 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
|
## Unreleased — Training feedback UX and safety
|
||||||
|
|
||||||
- Added additive schema v15 immutable feedback revisions and Training Feedback
|
- Added additive schema v15 immutable feedback revisions and Training Feedback
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,7 +52,7 @@ class SyncCatalogInbox(
|
||||||
|
|
||||||
private val preferences =
|
private val preferences =
|
||||||
appContext.getSharedPreferences(
|
appContext.getSharedPreferences(
|
||||||
"trainlog-sync",
|
SYNC_PREFERENCES_NAME,
|
||||||
Context.MODE_PRIVATE,
|
Context.MODE_PRIVATE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ class SyncCatalogInbox(
|
||||||
preferences
|
preferences
|
||||||
.edit()
|
.edit()
|
||||||
.putString(
|
.putString(
|
||||||
KEY_TREE_URI,
|
SYNC_TREE_URI_KEY,
|
||||||
uri.toString(),
|
uri.toString(),
|
||||||
)
|
)
|
||||||
.apply()
|
.apply()
|
||||||
|
|
@ -133,6 +133,19 @@ class SyncCatalogInbox(
|
||||||
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
|
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
|
||||||
val aliasesError = importExerciseAliases(directory)
|
val aliasesError = importExerciseAliases(directory)
|
||||||
if (aliasesError != null) return CatalogInboxResult.Error(aliasesError)
|
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 (
|
when (
|
||||||
val result =
|
val result =
|
||||||
|
|
@ -141,7 +154,15 @@ class SyncCatalogInbox(
|
||||||
json
|
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)) {
|
when (val sessions = importPcSessions(directory)) {
|
||||||
null -> when (val equipment = importPcEquipmentAssociations(directory)) {
|
null -> when (val equipment = importPcEquipmentAssociations(directory)) {
|
||||||
null -> when (val bodyZones = importPcBodyZones(directory)) {
|
null -> when (val bodyZones = importPcBodyZones(directory)) {
|
||||||
|
|
@ -159,6 +180,7 @@ class SyncCatalogInbox(
|
||||||
}
|
}
|
||||||
else -> CatalogInboxResult.Error(sessions)
|
else -> CatalogInboxResult.Error(sessions)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is PcCatalogImportResult.Invalid ->
|
is PcCatalogImportResult.Invalid ->
|
||||||
CatalogInboxResult.Error(
|
CatalogInboxResult.Error(
|
||||||
|
|
@ -399,15 +421,11 @@ fun readSyncReceipt(
|
||||||
private fun savedTreeUri(): Uri? =
|
private fun savedTreeUri(): Uri? =
|
||||||
preferences
|
preferences
|
||||||
.getString(
|
.getString(
|
||||||
KEY_TREE_URI,
|
SYNC_TREE_URI_KEY,
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
?.let {
|
?.let {
|
||||||
Uri.parse(it)
|
Uri.parse(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val KEY_TREE_URI =
|
|
||||||
"trainlog_tree_uri"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
import android.content.ContentUris
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import kotlinx.coroutines.Dispatchers
|
||||||
import android.provider.MediaStore
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
sealed interface SyncExportResult {
|
sealed interface SyncExportResult {
|
||||||
data class Exported(
|
data class Exported(
|
||||||
|
|
@ -22,16 +19,34 @@ sealed interface SyncExportResult {
|
||||||
) : SyncExportResult
|
) : SyncExportResult
|
||||||
}
|
}
|
||||||
|
|
||||||
class SyncExporter(
|
class SyncExporter private constructor(
|
||||||
context: Context,
|
|
||||||
private val repository:
|
private val repository:
|
||||||
TrainlogRepository,
|
TrainlogRepository,
|
||||||
|
private val safPublisher: ExchangeSafPublisher,
|
||||||
) {
|
) {
|
||||||
private val appContext =
|
constructor(context: Context, repository: TrainlogRepository) : this(
|
||||||
context.applicationContext
|
repository,
|
||||||
|
ExchangeSafPublisher { persistedExchangeSafDirectory(context) },
|
||||||
|
)
|
||||||
|
|
||||||
fun exportMobileBundle():
|
internal constructor(
|
||||||
SyncExportResult {
|
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 (
|
if (
|
||||||
Build.VERSION.SDK_INT <
|
Build.VERSION.SDK_INT <
|
||||||
Build.VERSION_CODES.Q
|
Build.VERSION_CODES.Q
|
||||||
|
|
@ -39,8 +54,8 @@ class SyncExporter(
|
||||||
return SyncExportResult.Unsupported
|
return SyncExportResult.Unsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CONTRACT: capture all four artifacts before publishing any of
|
/* CONTRACT: capture every artifact before publishing any of them.
|
||||||
* them. The files are separate for compatibility, but a user edit
|
* The files are separate for compatibility, but a user edit
|
||||||
* must not make a newly-written V2 reference a definition assembled
|
* must not make a newly-written V2 reference a definition assembled
|
||||||
* from a different logical export state. */
|
* from a different logical export state. */
|
||||||
val definitionsJson: String
|
val definitionsJson: String
|
||||||
|
|
@ -49,6 +64,7 @@ class SyncExporter(
|
||||||
val bodyZonesJson: String
|
val bodyZonesJson: String
|
||||||
val aliasesJson: String
|
val aliasesJson: String
|
||||||
val feedbackJson: String
|
val feedbackJson: String
|
||||||
|
val profileStateJson: String
|
||||||
try {
|
try {
|
||||||
definitionsJson = repository.buildEquipmentDefinitionsJson()
|
definitionsJson = repository.buildEquipmentDefinitionsJson()
|
||||||
/* V3 is the authoritative mobile session exchange. V1/V2 remain
|
/* V3 is the authoritative mobile session exchange. V1/V2 remain
|
||||||
|
|
@ -58,153 +74,30 @@ class SyncExporter(
|
||||||
bodyZonesJson = repository.buildExerciseBodyZonesJson()
|
bodyZonesJson = repository.buildExerciseBodyZonesJson()
|
||||||
aliasesJson = repository.buildExerciseAliasesJson()
|
aliasesJson = repository.buildExerciseAliasesJson()
|
||||||
feedbackJson = repository.buildTrainingFeedbackJson()
|
feedbackJson = repository.buildTrainingFeedbackJson()
|
||||||
|
profileStateJson = repository.buildExerciseProfileStateJson()
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
return SyncExportResult.Error(
|
return SyncExportResult.Error(
|
||||||
error.message ?: "Préparation de l'export impossible.",
|
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,
|
||||||
val resolver =
|
"trainlog-mobile-export-v3.json" to mobileJson,
|
||||||
appContext.contentResolver
|
"trainlog-exercise-body-zones-v1.json" to bodyZonesJson,
|
||||||
|
"trainlog-exercise-aliases-v1.json" to aliasesJson,
|
||||||
val collection =
|
"trainlog-training-feedback-v2.json" to feedbackJson,
|
||||||
MediaStore.Downloads
|
"trainlog-exercise-profile-state-v1.json" to profileStateJson,
|
||||||
.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
|
|
||||||
)
|
)
|
||||||
|
/* 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 inserted =
|
|
||||||
resolver.insert(
|
|
||||||
collection,
|
|
||||||
values
|
|
||||||
)
|
|
||||||
|
|
||||||
if (inserted == null) {
|
|
||||||
return SyncExportResult.Error(
|
|
||||||
"Création du fichier impossible."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
targetUri = inserted
|
|
||||||
created = true
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val stream =
|
|
||||||
resolver.openOutputStream(
|
|
||||||
targetUri,
|
|
||||||
"wt"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (stream == null) {
|
|
||||||
if (created) {
|
|
||||||
resolver.delete(
|
|
||||||
targetUri,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return SyncExportResult.Error(
|
|
||||||
"Flux d'écriture indisponible."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
/* CONTRACT: publication, not JSON construction, establishes the
|
||||||
* common sync ancestor. Applying the exact local snapshot can only
|
* common sync ancestor. Applying the exact local snapshot can only
|
||||||
* record equal baselines; the strict reconciler never unions zones. */
|
* record equal baselines; the strict reconciler never unions zones. */
|
||||||
|
|
@ -222,198 +115,13 @@ class SyncExporter(
|
||||||
"Enregistrement de la baseline zones impossible.",
|
"Enregistrement de la baseline zones impossible.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val companionError = writeEquipmentAssociations(associationsJson)
|
snapshot.writeJson(
|
||||||
if (companionError != null) {
|
"trainlog-equipment-associations-v2.json",
|
||||||
return SyncExportResult.Error(companionError)
|
associationsJson,
|
||||||
}
|
)?.let { return SyncExportResult.Error(it) }
|
||||||
return SyncExportResult.Exported(
|
return SyncExportResult.Exported(
|
||||||
displayPath =
|
displayPath = "Download/Trainlog/trainlog-mobile-export-v3.json",
|
||||||
"Download/Trainlog/" +
|
bytes = mobileJson.toByteArray(Charsets.UTF_8).size,
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
import android.content.ContentUris
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
sealed interface SyncRequestResult {
|
sealed interface SyncRequestResult {
|
||||||
data class Requested(
|
data class Requested(
|
||||||
|
|
@ -24,14 +21,25 @@ sealed interface SyncRequestResult {
|
||||||
) : SyncRequestResult
|
) : SyncRequestResult
|
||||||
}
|
}
|
||||||
|
|
||||||
class SyncRequestOutbox(
|
class SyncRequestOutbox private constructor(
|
||||||
context: Context,
|
private val safPublisher: ExchangeSafPublisher,
|
||||||
) {
|
) {
|
||||||
private val appContext =
|
constructor(context: Context) : this(
|
||||||
context.applicationContext
|
ExchangeSafPublisher { persistedExchangeSafDirectory(context) },
|
||||||
|
)
|
||||||
|
|
||||||
fun requestSync():
|
internal constructor(directoryProvider: () -> ExchangeSafDirectory?) : this(
|
||||||
SyncRequestResult {
|
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 (
|
if (
|
||||||
Build.VERSION.SDK_INT <
|
Build.VERSION.SDK_INT <
|
||||||
Build.VERSION_CODES.Q
|
Build.VERSION_CODES.Q
|
||||||
|
|
@ -65,187 +73,13 @@ class SyncRequestOutbox(
|
||||||
)
|
)
|
||||||
.toString()
|
.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 =
|
val displayName =
|
||||||
"trainlog-sync-request-v1.json"
|
"trainlog-sync-request-v1.json"
|
||||||
|
val error = snapshot.writeJson(displayName, payload)
|
||||||
val existing =
|
return if (error == null) {
|
||||||
findExisting(
|
SyncRequestResult.Requested(requestId)
|
||||||
collection,
|
|
||||||
displayName,
|
|
||||||
relativePath,
|
|
||||||
)
|
|
||||||
|
|
||||||
val targetUri: Uri
|
|
||||||
val created: Boolean
|
|
||||||
|
|
||||||
if (existing != null) {
|
|
||||||
targetUri = existing
|
|
||||||
created = false
|
|
||||||
} else {
|
} else {
|
||||||
val values =
|
SyncRequestResult.Error(error)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,56 @@ import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.text.Normalizer
|
import java.text.Normalizer
|
||||||
import java.io.StringReader
|
import java.io.StringReader
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.temporal.WeekFields
|
import java.time.temporal.WeekFields
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
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
|
/* WHY: ended_at is absent on valid imported/history rows and present lifecycle
|
||||||
* metadata cannot prove actual work. CONTRACT: observable history requires at
|
* metadata cannot prove actual work. CONTRACT: observable history requires at
|
||||||
* least one occurrence-owned performed set, continuous activity, or explicit
|
* least one occurrence-owned performed set, continuous activity, or explicit
|
||||||
|
|
@ -98,6 +142,13 @@ sealed interface PcCatalogImportResult {
|
||||||
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. */
|
/** Read-only diagnostic emitted by the production PC-catalog importer. */
|
||||||
data class PcCatalogExerciseDecision(
|
data class PcCatalogExerciseDecision(
|
||||||
val exerciseId: String,
|
val exerciseId: String,
|
||||||
|
|
@ -183,6 +234,12 @@ sealed interface SaveSessionResult {
|
||||||
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 {
|
sealed interface SaveFeedbackResult {
|
||||||
data class Saved(val stableId: String) : SaveFeedbackResult
|
data class Saved(val stableId: String) : SaveFeedbackResult
|
||||||
data class Invalid(val message: String) : SaveFeedbackResult
|
data class Invalid(val message: String) : SaveFeedbackResult
|
||||||
|
|
@ -969,6 +1026,16 @@ class TrainlogRepository(
|
||||||
exercise.dataFields,
|
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,
|
replaceExerciseBodyZones(db, exercise.exerciseId,
|
||||||
exercise.primaryZoneId, exercise.secondaryZoneIds)
|
exercise.primaryZoneId, exercise.secondaryZoneIds)
|
||||||
|
|
@ -989,18 +1056,12 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** CONTRACT: occurrences snapshot profile semantics, so references never lock CURRENT edits. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
fun canEditExerciseProfile(
|
fun canEditExerciseProfile(
|
||||||
exerciseId: String,
|
exerciseId: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val db = database.readableDatabase
|
val db = database.readableDatabase
|
||||||
val rowId = lookupExerciseRowIdOrNull(db, exerciseId) ?: return false
|
return lookupExerciseRowIdOrNull(db, exerciseId) != null
|
||||||
return !exerciseHasReferences(db, rowId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun editExercise(
|
fun editExercise(
|
||||||
|
|
@ -1022,13 +1083,11 @@ class TrainlogRepository(
|
||||||
db.beginTransaction()
|
db.beginTransaction()
|
||||||
val current = findExerciseRow(db, "exercise_id = ?", arrayOf(input.exerciseId))
|
val current = findExerciseRow(db, "exercise_id = ?", arrayOf(input.exerciseId))
|
||||||
?: return EditExerciseResult.DatabaseError
|
?: return EditExerciseResult.DatabaseError
|
||||||
val profileChanged =
|
/* CONTRACT: completed and draft occurrences own profile snapshots.
|
||||||
current.recordingMode != input.recordingMode ||
|
* Updating the catalogue controls future uses only; it must never
|
||||||
current.trackingMode != input.trackingMode ||
|
* rewrite or reject because historical/draft facts reference this
|
||||||
current.dataFields != input.dataFields
|
* stable exercise identity. The UI confirms incompatible future
|
||||||
if (profileChanged && exerciseHasReferences(db, current.rowId)) {
|
* behavior before invoking this transaction. */
|
||||||
return EditExerciseResult.IncompatibleProfileChange
|
|
||||||
}
|
|
||||||
|
|
||||||
val nameOwner = findExerciseRow(db, "normalized_name = ?", arrayOf(normalized))
|
val nameOwner = findExerciseRow(db, "normalized_name = ?", arrayOf(normalized))
|
||||||
if (nameOwner != null && nameOwner.rowId != current.rowId) {
|
if (nameOwner != null && nameOwner.rowId != current.rowId) {
|
||||||
|
|
@ -1042,9 +1101,32 @@ class TrainlogRepository(
|
||||||
put("tracking_mode", input.trackingMode.wireValue)
|
put("tracking_mode", input.trackingMode.wireValue)
|
||||||
put("data_fields", input.dataFields)
|
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) {
|
if (db.update("exercises", values, "id = ?", arrayOf(current.rowId.toString())) != 1) {
|
||||||
return EditExerciseResult.DatabaseError
|
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,
|
replaceExerciseBodyZones(db, input.exerciseId,
|
||||||
input.primaryZoneId, input.secondaryZoneIds)
|
input.primaryZoneId, input.secondaryZoneIds)
|
||||||
db.setTransactionSuccessful()
|
db.setTransactionSuccessful()
|
||||||
|
|
@ -1093,6 +1175,176 @@ class TrainlogRepository(
|
||||||
.toString()
|
.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 {
|
fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult {
|
||||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||||
return ExerciseBodyZoneImportResult.Invalid("Relations de zones JSON invalides.")
|
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> {
|
fun listSessions(): List<SessionSummary> {
|
||||||
val output =
|
val output =
|
||||||
mutableListOf<SessionSummary>()
|
mutableListOf<SessionSummary>()
|
||||||
|
|
@ -2157,6 +2440,10 @@ class TrainlogRepository(
|
||||||
) {
|
) {
|
||||||
return PcCatalogImportResult.DatabaseError
|
return PcCatalogImportResult.DatabaseError
|
||||||
}
|
}
|
||||||
|
if (byId.dataFields != richerFields && !advanceExerciseProfileRevision(
|
||||||
|
db, byId.rowId, byId.recordingMode.wireValue,
|
||||||
|
byId.trackingMode.wireValue, richerFields))
|
||||||
|
return PcCatalogImportResult.DatabaseError
|
||||||
|
|
||||||
reconciled += 1
|
reconciled += 1
|
||||||
traceDecision(
|
traceDecision(
|
||||||
|
|
@ -2227,6 +2514,11 @@ class TrainlogRepository(
|
||||||
) {
|
) {
|
||||||
return PcCatalogImportResult.DatabaseError
|
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
|
reconciled += 1
|
||||||
traceDecision(
|
traceDecision(
|
||||||
|
|
@ -2274,6 +2566,26 @@ class TrainlogRepository(
|
||||||
null,
|
null,
|
||||||
values
|
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
|
imported += 1
|
||||||
traceDecision("none", "inserted")
|
traceDecision("none", "inserted")
|
||||||
|
|
@ -2897,26 +3209,34 @@ class TrainlogRepository(
|
||||||
arrayOf(resolveExerciseId(db, exerciseId)),
|
arrayOf(resolveExerciseId(db, exerciseId)),
|
||||||
)
|
)
|
||||||
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
|
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
|
||||||
if (
|
/* CONTRACT: the resolved row proves stable exercise
|
||||||
entry.optString("recording_mode") != exerciseRow.recordingMode.wireValue ||
|
* identity only. Completed occurrences own their profile
|
||||||
entry.optString("tracking_mode") != exerciseRow.trackingMode.wireValue ||
|
* snapshot; CURRENT catalogue evolution cannot reject it. */
|
||||||
entry.optInt("data_fields", -1) and
|
|
||||||
exerciseRow.dataFields.inv() != 0
|
|
||||||
) {
|
|
||||||
return MobileSessionImportResult.Invalid("Profil V2 incompatible : $exerciseId")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val existingRowId = db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf(sessionId)).use {
|
val existingRowId = db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf(sessionId)).use {
|
||||||
if (it.moveToFirst()) it.getLong(0) else null
|
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 (existingRowId != null) {
|
||||||
if (pcSessionMatches(db, existingRowId, session, version)) {
|
if (pcSessionMatches(db, existingRowId, session, version)) {
|
||||||
sessionsSkipped += 1
|
sessionsSkipped += 1
|
||||||
continue
|
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")
|
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;
|
/* Child replacement is inside this import transaction;
|
||||||
* entry identity/order may only be retained and appended. */
|
* entry identity/order may only be retained and appended. */
|
||||||
db.delete(
|
db.delete(
|
||||||
|
|
@ -2976,6 +3296,21 @@ class TrainlogRepository(
|
||||||
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
|
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
|
||||||
}
|
}
|
||||||
val occurrence = db.insertOrThrow("session_exercises", null, values)
|
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")) {
|
if (entry.has("max_weight_kg")) {
|
||||||
db.insertOrThrow("max_results", null, ContentValues().apply {
|
db.insertOrThrow("max_results", null, ContentValues().apply {
|
||||||
put("session_exercise_row_id", occurrence)
|
put("session_exercise_row_id", occurrence)
|
||||||
|
|
@ -3059,7 +3394,6 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
val exerciseIds = mutableSetOf<String>()
|
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 exerciseKeys = setOf("exercise_id", "name", "recording_mode", "tracking_mode", "data_fields")
|
||||||
val exercises = root.getJSONArray("exercises")
|
val exercises = root.getJSONArray("exercises")
|
||||||
for (index in 0 until exercises.length()) {
|
for (index in 0 until exercises.length()) {
|
||||||
|
|
@ -3069,11 +3403,6 @@ class TrainlogRepository(
|
||||||
!item.value("name").isNonemptyJsonString() || !validJsonProfile(item)) {
|
!item.value("name").isNonemptyJsonString() || !validJsonProfile(item)) {
|
||||||
return "Catalogue exercices V2 invalide."
|
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")
|
val sessionKeys = setOf("session_id", "started_at", "session_type", "exercises")
|
||||||
|
|
@ -3109,29 +3438,25 @@ class TrainlogRepository(
|
||||||
val exerciseId = entry.value("exercise_id")
|
val exerciseId = entry.value("exercise_id")
|
||||||
val positionValue = entry.value("position")
|
val positionValue = entry.value("position")
|
||||||
if (!entry.hasExactKeys(expectedKeys) || !entryId.isNonemptyJsonString() || !entryIds.add(entryId as String) ||
|
if (!entry.hasExactKeys(expectedKeys) || !entryId.isNonemptyJsonString() || !entryIds.add(entryId as String) ||
|
||||||
!exerciseId.isNonemptyJsonString() || exerciseId !in exerciseIds || !entry.value("name").isNonemptyJsonString() ||
|
!exerciseId.isNonemptyJsonString() || !entry.value("name").isNonemptyJsonString() ||
|
||||||
!validJsonProfile(entry) || !validEntryPlan(entry, version, recording as String, tracking as String, hasMax) ||
|
|
||||||
!positionValue.isJsonInt(0, 100000) || !positions.add((positionValue as Number).toInt()) ||
|
!positionValue.isJsonInt(0, 100000) || !positions.add((positionValue as Number).toInt()) ||
|
||||||
!(entry.value("equipment_id") === JSONObject.NULL || entry.value("equipment_id").isNonemptyJsonString())) {
|
!(entry.value("equipment_id") === JSONObject.NULL || entry.value("equipment_id").isNonemptyJsonString())) {
|
||||||
return "Entrée de séance V2 invalide."
|
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 &&
|
if (hasMax &&
|
||||||
(session.getString("session_type") != "max_test" ||
|
(session.getString("session_type") != "max_test" ||
|
||||||
!entry.value("max_weight_kg").isPositiveJsonNumber())) {
|
!entry.value("max_weight_kg").isPositiveJsonNumber())) {
|
||||||
return "Résultat max V2 invalide."
|
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) {
|
if (hasMax) {
|
||||||
/* The exact-key check above excludes set/continuous
|
/* The exact-key check above excludes set/continuous
|
||||||
* shadows; max identity remains the exercise entry. */
|
* shadows; max identity remains the exercise entry. */
|
||||||
|
|
@ -3280,6 +3605,9 @@ class TrainlogRepository(
|
||||||
val number = toDouble()
|
val number = toDouble()
|
||||||
return number.isFinite() && number % 1.0 == 0.0 && number >= minimum && number <= maximum
|
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?.isPositiveJsonNumber(): Boolean = this is Number && toDouble().isFinite() && toDouble() > 0.0
|
||||||
private fun Any?.isNonnegativeJsonNumber(): Boolean =
|
private fun Any?.isNonnegativeJsonNumber(): Boolean =
|
||||||
this is Number && toDouble().isFinite() && toDouble() >= 0.0
|
this is Number && toDouble().isFinite() && toDouble() >= 0.0
|
||||||
|
|
@ -3353,18 +3681,17 @@ class TrainlogRepository(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pcResumedMaxUpdateIsSafe(
|
private fun pcCompletedCorrectionIsSafe(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
rowId: Long,
|
rowId: Long,
|
||||||
session: JSONObject,
|
session: JSONObject,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (session.optString("session_type") != "max_test") return false
|
|
||||||
val headerMatches = db.rawQuery(
|
val headerMatches = db.rawQuery(
|
||||||
"SELECT started_at,session_type FROM sessions WHERE id=?",
|
"SELECT started_at,session_type FROM sessions WHERE id=?",
|
||||||
arrayOf(rowId.toString()),
|
arrayOf(rowId.toString()),
|
||||||
).use {
|
).use {
|
||||||
it.moveToFirst() && it.getString(0) == session.optString("started_at") &&
|
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
|
if (!headerMatches) return false
|
||||||
val current = mutableListOf<Triple<String, Int, String>>()
|
val current = mutableListOf<Triple<String, Int, String>>()
|
||||||
|
|
@ -3379,14 +3706,14 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val incoming = session.optJSONArray("exercises") ?: return false
|
val incoming = session.optJSONArray("exercises") ?: return false
|
||||||
if (incoming.length() < current.size) return false
|
val currentById = current.associateBy { it.first }
|
||||||
return current.indices.all { index ->
|
val seen = mutableSetOf<String>()
|
||||||
|
return (0 until incoming.length()).all { index ->
|
||||||
val item = incoming.optJSONObject(index) ?: return@all false
|
val item = incoming.optJSONObject(index) ?: return@all false
|
||||||
current[index] == Triple(
|
val entryId = item.optString("entry_id")
|
||||||
item.optString("entry_id"),
|
val existing = currentById[entryId]
|
||||||
item.optInt("position", -1),
|
seen.add(entryId) && (existing == null || existing.third ==
|
||||||
resolveExerciseId(db, item.optString("exercise_id")),
|
resolveExerciseId(db, item.optString("exercise_id")))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5623,6 +5950,7 @@ class TrainlogRepository(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
draft: SessionDraft,
|
draft: SessionDraft,
|
||||||
sourceSessionId: String? = null,
|
sourceSessionId: String? = null,
|
||||||
|
generalCorrection: Boolean = false,
|
||||||
): String {
|
): String {
|
||||||
val sessionId: String
|
val sessionId: String
|
||||||
val sessionRowId: Long
|
val sessionRowId: Long
|
||||||
|
|
@ -5642,20 +5970,24 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
sessionRowId = db.insertOrThrow("sessions", null, sessionValues)
|
sessionRowId = db.insertOrThrow("sessions", null, sessionValues)
|
||||||
} else {
|
} else {
|
||||||
check(draft.sessionType == SessionType.MAX_TEST) {
|
check(generalCorrection || draft.sessionType == SessionType.MAX_TEST) {
|
||||||
"Seul un Test max peut remplacer une séance reprise."
|
"Seul un Test max peut remplacer une séance reprise."
|
||||||
}
|
}
|
||||||
sessionId = sourceSessionId
|
sessionId = sourceSessionId
|
||||||
sessionRowId = db.rawQuery(
|
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),
|
arrayOf(sourceSessionId),
|
||||||
).use { cursor ->
|
).use { cursor ->
|
||||||
check(cursor.moveToFirst()) { "Séance Test max source introuvable." }
|
check(cursor.moveToFirst()) { "Séance Test max source introuvable." }
|
||||||
cursor.getLong(0)
|
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."
|
"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:
|
/* Android has one bounded completed-session correction path:
|
||||||
* resumed MAX_TEST finalization. Preserve feedback by the stable
|
* resumed MAX_TEST finalization. Preserve feedback by the stable
|
||||||
* occurrence entry_id before cascading reconstruction; the
|
* occurrence entry_id before cascading reconstruction; the
|
||||||
|
|
@ -5675,7 +6007,7 @@ class TrainlogRepository(
|
||||||
).use { cursor -> while (cursor.moveToNext()) retainedCompletedRevisions
|
).use { cursor -> while (cursor.moveToNext()) retainedCompletedRevisions
|
||||||
.getOrPut(cursor.getString(0)) { mutableListOf() }
|
.getOrPut(cursor.getString(0)) { mutableListOf() }
|
||||||
.add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) }
|
.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))
|
arrayOf<Any>(OffsetDateTime.now().toString(), sessionRowId))
|
||||||
/* INVARIANT: child replacement and draft deletion are in the
|
/* INVARIANT: child replacement and draft deletion are in the
|
||||||
* caller's transaction; failure restores the completed baseline. */
|
* caller's transaction; failure restores the completed baseline. */
|
||||||
|
|
@ -5822,6 +6154,34 @@ class TrainlogRepository(
|
||||||
return sessionId
|
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(
|
private fun resumedDraftIdentityIsSafe(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
sessionRowId: Long,
|
sessionRowId: Long,
|
||||||
|
|
@ -6252,7 +6612,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
15,
|
16,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -6268,7 +6628,16 @@ private class TrainlogDatabaseHelper(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
) {
|
) {
|
||||||
super.onOpen(db)
|
super.onOpen(db)
|
||||||
|
db.beginTransaction()
|
||||||
|
try {
|
||||||
migrateApprovedLegPressIdentity(db)
|
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(
|
override fun onCreate(
|
||||||
|
|
@ -6283,6 +6652,7 @@ private class TrainlogDatabaseHelper(
|
||||||
createExerciseAliasTable(db)
|
createExerciseAliasTable(db)
|
||||||
createTrainingFeedbackTables(db)
|
createTrainingFeedbackTables(db)
|
||||||
createTrainingFeedbackRevisionTables(db)
|
createTrainingFeedbackRevisionTables(db)
|
||||||
|
createExerciseProfileStateTable(db, seedLegacy = false)
|
||||||
seedEquipment(db)
|
seedEquipment(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6401,6 +6771,15 @@ private class TrainlogDatabaseHelper(
|
||||||
version = 15
|
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) {
|
if (version != newVersion) {
|
||||||
error(
|
error(
|
||||||
"Unsupported Android DB upgrade " +
|
"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) {
|
private fun createExerciseAliasTable(db: SQLiteDatabase) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"CREATE TABLE IF NOT EXISTS exercise_aliases(" +
|
"CREATE TABLE IF NOT EXISTS exercise_aliases(" +
|
||||||
|
|
|
||||||
|
|
@ -124,18 +124,31 @@ internal object TrainlogTimestamp {
|
||||||
|
|
||||||
/** Shared Android presentation rule; exact instants are parsed before
|
/** Shared Android presentation rule; exact instants are parsed before
|
||||||
* subtraction and a missing/negative anchor never produces a fabricated H+. */
|
* subtraction and a missing/negative anchor never produces a fabricated H+. */
|
||||||
fun sessionFollowUpElapsedLabel(endedAt: String?, observedAt: String): String {
|
fun sessionFollowUpElapsedLabel(endedAt: String?, startedAt: String?, observedAt: String): String {
|
||||||
val end = endedAt?.let(TrainlogTimestamp::parse) ?: return "H+?"
|
|
||||||
val observed = TrainlogTimestamp.parse(observedAt) ?: return "H+?"
|
val observed = TrainlogTimestamp.parse(observedAt) ?: return "H+?"
|
||||||
|
val end = endedAt?.let(TrainlogTimestamp::parse)
|
||||||
|
if (end != null) {
|
||||||
val seconds = observed.utcSecond - end.utcSecond
|
val seconds = observed.utcSecond - end.utcSecond
|
||||||
return if (seconds < 0) "H+?" else "H+${seconds / 3600L}"
|
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
|
/** Exercise observations may precede finalization and therefore use a
|
||||||
* session-neutral label instead of a negative or misleading H+ value. */
|
* session-neutral label instead of a negative or misleading H+ value. */
|
||||||
fun exerciseFeedbackElapsedLabel(endedAt: String?, observedAt: String): String {
|
fun exerciseFeedbackElapsedLabel(endedAt: String?, startedAt: String?, observedAt: String): String {
|
||||||
val end = endedAt?.let(TrainlogTimestamp::parse) ?: return "Ressenti"
|
val observed = TrainlogTimestamp.parse(observedAt) ?: return "H+?"
|
||||||
val observed = TrainlogTimestamp.parse(observedAt) ?: return "Ressenti"
|
val end = endedAt?.let(TrainlogTimestamp::parse)
|
||||||
|
if (end != null) {
|
||||||
val seconds = observed.utcSecond - end.utcSecond
|
val seconds = observed.utcSecond - end.utcSecond
|
||||||
return if (seconds < 0) "Pendant la séance" else "H+${seconds / 3600L}"
|
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}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ sealed interface AppRoute {
|
||||||
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
|
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data object CompletedSessions : 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 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 object Exercises : AppRoute { override val section = AppSection.EXERCISES }
|
||||||
data class ExerciseDetail(val exerciseId: String) : 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 }
|
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.SessionGenerator -> "Programmer une séance"
|
||||||
AppRoute.CompletedSessions -> "Séances effectuées"
|
AppRoute.CompletedSessions -> "Séances effectuées"
|
||||||
is AppRoute.SessionDetail -> "Détail de la séance"
|
is AppRoute.SessionDetail -> "Détail de la séance"
|
||||||
|
is AppRoute.SessionCorrection -> "Modifier la séance"
|
||||||
AppRoute.Exercises -> "Exercices"
|
AppRoute.Exercises -> "Exercices"
|
||||||
is AppRoute.ExerciseDetail -> "Fiche exercice"
|
is AppRoute.ExerciseDetail -> "Fiche exercice"
|
||||||
is AppRoute.ExerciseEdit -> "Modifier l'exercice"
|
is AppRoute.ExerciseEdit -> "Modifier l'exercice"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.text.BasicText
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -111,10 +114,16 @@ fun ExerciseScreen(
|
||||||
var message by state.message
|
var message by state.message
|
||||||
var editedExerciseId by state.editedExerciseId
|
var editedExerciseId by state.editedExerciseId
|
||||||
|
|
||||||
val profileLocked =
|
val originalProfile = remember(editedExerciseId) {
|
||||||
editedExerciseId?.let {
|
editedExerciseId?.let { id -> repository.listExercises().firstOrNull { it.exerciseId == id } }
|
||||||
!repository.canEditExerciseProfile(it)
|
}
|
||||||
} ?: false
|
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) {
|
fun startEditing(exercise: ExerciseProfile) {
|
||||||
/* WHY: edit state copies catalog metadata for presentation only. The
|
/* WHY: edit state copies catalog metadata for presentation only. The
|
||||||
|
|
@ -159,9 +168,8 @@ fun ExerciseScreen(
|
||||||
selected =
|
selected =
|
||||||
recordingMode ==
|
recordingMode ==
|
||||||
RecordingMode.SETS,
|
RecordingMode.SETS,
|
||||||
enabled = !profileLocked,
|
enabled = true,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (profileLocked) return@TrainlogChoice
|
|
||||||
recordingMode =
|
recordingMode =
|
||||||
RecordingMode.SETS
|
RecordingMode.SETS
|
||||||
|
|
||||||
|
|
@ -176,9 +184,8 @@ fun ExerciseScreen(
|
||||||
selected =
|
selected =
|
||||||
recordingMode ==
|
recordingMode ==
|
||||||
RecordingMode.CONTINUOUS,
|
RecordingMode.CONTINUOUS,
|
||||||
enabled = !profileLocked,
|
enabled = true,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (profileLocked) return@TrainlogChoice
|
|
||||||
recordingMode =
|
recordingMode =
|
||||||
RecordingMode.CONTINUOUS
|
RecordingMode.CONTINUOUS
|
||||||
|
|
||||||
|
|
@ -203,9 +210,8 @@ fun ExerciseScreen(
|
||||||
selected =
|
selected =
|
||||||
trackingMode ==
|
trackingMode ==
|
||||||
TrackingMode.REPS,
|
TrackingMode.REPS,
|
||||||
enabled = !profileLocked,
|
enabled = true,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (profileLocked) return@TrainlogChoice
|
|
||||||
trackingMode =
|
trackingMode =
|
||||||
TrackingMode.REPS
|
TrackingMode.REPS
|
||||||
|
|
||||||
|
|
@ -219,9 +225,8 @@ fun ExerciseScreen(
|
||||||
selected =
|
selected =
|
||||||
trackingMode ==
|
trackingMode ==
|
||||||
TrackingMode.DURATION,
|
TrackingMode.DURATION,
|
||||||
enabled = !profileLocked,
|
enabled = true,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (profileLocked) return@TrainlogChoice
|
|
||||||
trackingMode =
|
trackingMode =
|
||||||
TrackingMode.DURATION
|
TrackingMode.DURATION
|
||||||
|
|
||||||
|
|
@ -241,9 +246,8 @@ fun ExerciseScreen(
|
||||||
TrainlogChoice(
|
TrainlogChoice(
|
||||||
label = "Vitesse",
|
label = "Vitesse",
|
||||||
selected = speed,
|
selected = speed,
|
||||||
enabled = !profileLocked,
|
enabled = true,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (profileLocked) return@TrainlogChoice
|
|
||||||
speed = !speed
|
speed = !speed
|
||||||
message = null
|
message = null
|
||||||
},
|
},
|
||||||
|
|
@ -252,9 +256,8 @@ fun ExerciseScreen(
|
||||||
TrainlogChoice(
|
TrainlogChoice(
|
||||||
label = "Distance",
|
label = "Distance",
|
||||||
selected = distance,
|
selected = distance,
|
||||||
enabled = !profileLocked,
|
enabled = true,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (profileLocked) return@TrainlogChoice
|
|
||||||
distance =
|
distance =
|
||||||
!distance
|
!distance
|
||||||
|
|
||||||
|
|
@ -344,12 +347,10 @@ fun ExerciseScreen(
|
||||||
color = colors.accent,
|
color = colors.accent,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (profileLocked) {
|
if (editedExerciseId != null && repository.canEditExerciseProfile(editedExerciseId!!).not()) {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
text =
|
text =
|
||||||
"Profil verrouillé : cet exercice est déjà référencé " +
|
"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.",
|
||||||
"par une séance terminée ou le brouillon actif. " +
|
|
||||||
"Le nom reste modifiable.",
|
|
||||||
color = colors.warning,
|
color = colors.warning,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -370,6 +371,10 @@ fun ExerciseScreen(
|
||||||
"Conserver l'identité et mettre à jour le catalogue."
|
"Conserver l'identité et mettre à jour le catalogue."
|
||||||
},
|
},
|
||||||
onClick = save@{
|
onClick = save@{
|
||||||
|
if (editedExerciseId != null && incompatibleProfileChange && !confirmedIncompatible) {
|
||||||
|
confirmingIncompatible = true
|
||||||
|
return@save
|
||||||
|
}
|
||||||
if (editedExerciseId == null &&
|
if (editedExerciseId == null &&
|
||||||
recordingMode == RecordingMode.SETS && primaryZoneId == null) {
|
recordingMode == RecordingMode.SETS && primaryZoneId == null) {
|
||||||
message = "Une zone principale est requise pour un nouvel exercice musculaire."
|
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
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ fun SessionDetailScreen(
|
||||||
repository: TrainlogRepository,
|
repository: TrainlogRepository,
|
||||||
sessionId: String?,
|
sessionId: String?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onResumeMaxTest: () -> Unit,
|
onCorrect: () -> Unit = {},
|
||||||
|
onResumeMaxTest: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
|
|
@ -103,6 +104,13 @@ fun SessionDetailScreen(
|
||||||
"${detail.summary.exerciseCount} exercice(s)"
|
"${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) {
|
if (detail.summary.sessionType == SessionType.MAX_TEST) {
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label = "Reprendre ce Test max",
|
label = "Reprendre ce Test max",
|
||||||
|
|
@ -200,7 +208,7 @@ fun SessionDetailScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
exercise.feedback.forEach { item ->
|
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.",
|
TrainlogAction("Modifier", "Corriger le texte sans déplacer l’observation.",
|
||||||
onClick = { editingFeedbackId = item.feedbackId }, accent = colors.muted)
|
onClick = { editingFeedbackId = item.feedbackId }, accent = colors.muted)
|
||||||
if (editingFeedbackId == item.feedbackId) FeedbackEditor(
|
if (editingFeedbackId == item.feedbackId) FeedbackEditor(
|
||||||
|
|
@ -232,7 +240,7 @@ fun SessionDetailScreen(
|
||||||
|
|
||||||
TrainlogFrame(title = "Suivi après séance") {
|
TrainlogFrame(title = "Suivi après séance") {
|
||||||
detail.followUps.forEach { item ->
|
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 "")
|
TrainlogInfo(item.rawText + if (item.modified) " · Modifié" else "")
|
||||||
TrainlogAction("Modifier", "Corriger le texte sans changer le repère H+.",
|
TrainlogAction("Modifier", "Corriger le texte sans changer le repère H+.",
|
||||||
onClick = { editingFollowUpId = item.followupId }, accent = colors.muted)
|
onClick = { editingFollowUpId = item.followupId }, accent = colors.muted)
|
||||||
|
|
|
||||||
|
|
@ -9,24 +9,58 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import com.labfytools.trainlog.data.CatalogInboxResult
|
import com.labfytools.trainlog.data.CatalogInboxResult
|
||||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
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.SyncReceiptResult
|
||||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||||
import com.labfytools.trainlog.data.SyncRequestResult
|
import com.labfytools.trainlog.data.SyncRequestResult
|
||||||
|
import com.labfytools.trainlog.data.logExchangeMediaStore
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
import kotlinx.coroutines.delay
|
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
|
@Composable
|
||||||
fun SyncScreen(
|
fun SyncScreen(
|
||||||
inbox: SyncCatalogInbox,
|
inbox: SyncCatalogInbox,
|
||||||
|
exporter: SyncExporter,
|
||||||
requestOutbox: SyncRequestOutbox,
|
requestOutbox: SyncRequestOutbox,
|
||||||
onCatalogChanged: () -> Unit,
|
onCatalogChanged: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
var status by
|
var status by
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -54,6 +88,8 @@ fun SyncScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var syncRunning by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
pendingRequestId
|
pendingRequestId
|
||||||
) {
|
) {
|
||||||
|
|
@ -63,10 +99,9 @@ fun SyncScreen(
|
||||||
|
|
||||||
repeat(60) {
|
repeat(60) {
|
||||||
when (
|
when (
|
||||||
val receipt =
|
val receipt = withContext(Dispatchers.IO) {
|
||||||
inbox.readSyncReceipt(
|
inbox.readSyncReceipt(requestId)
|
||||||
requestId
|
}
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
SyncReceiptResult.Pending -> {
|
SyncReceiptResult.Pending -> {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
|
|
@ -109,8 +144,9 @@ fun SyncScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
when (
|
when (
|
||||||
val catalog =
|
val catalog = withContext(Dispatchers.IO) {
|
||||||
inbox.importPcCatalog()
|
inbox.importPcCatalog()
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
is CatalogInboxResult.Imported -> {
|
is CatalogInboxResult.Imported -> {
|
||||||
success = true
|
success = true
|
||||||
|
|
@ -199,10 +235,8 @@ fun SyncScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (saved) {
|
if (saved) {
|
||||||
when (
|
coroutineScope.launch {
|
||||||
val result =
|
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
|
||||||
inbox.importPcCatalog()
|
|
||||||
) {
|
|
||||||
is CatalogInboxResult.Imported -> {
|
is CatalogInboxResult.Imported -> {
|
||||||
success = true
|
success = true
|
||||||
|
|
||||||
|
|
@ -231,6 +265,7 @@ fun SyncScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TrainlogScreen(
|
TrainlogScreen(
|
||||||
subtitle =
|
subtitle =
|
||||||
|
|
@ -249,8 +284,7 @@ fun SyncScreen(
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label =
|
label =
|
||||||
if (
|
if (
|
||||||
pendingRequestId !=
|
pendingRequestId != null || syncRunning
|
||||||
null
|
|
||||||
) {
|
) {
|
||||||
"Synchronisation en cours..."
|
"Synchronisation en cours..."
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -262,8 +296,7 @@ fun SyncScreen(
|
||||||
colors.success,
|
colors.success,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (
|
if (
|
||||||
pendingRequestId !=
|
pendingRequestId != null || syncRunning
|
||||||
null
|
|
||||||
) {
|
) {
|
||||||
return@TrainlogAction
|
return@TrainlogAction
|
||||||
}
|
}
|
||||||
|
|
@ -277,33 +310,31 @@ fun SyncScreen(
|
||||||
return@TrainlogAction
|
return@TrainlogAction
|
||||||
}
|
}
|
||||||
|
|
||||||
when (
|
/* WHY: the request file is the PC daemon's start signal.
|
||||||
val result =
|
* CONTRACT: every current Android-origin companion must be
|
||||||
requestOutbox
|
* published from one prepared repository snapshot before
|
||||||
.requestSync()
|
* 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 -> {
|
is SyncRequestResult.Requested -> {
|
||||||
success = true
|
success = true
|
||||||
|
pendingRequestId = result.requestId
|
||||||
pendingRequestId =
|
status = "Demande envoyée · attente du PC..."
|
||||||
result.requestId
|
|
||||||
|
|
||||||
status =
|
|
||||||
"Demande envoyée · attente du PC..."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SyncRequestResult.Unsupported -> {
|
SyncRequestResult.Unsupported -> {
|
||||||
success = false
|
success = false
|
||||||
|
status = "Android non supporté."
|
||||||
status =
|
|
||||||
"Android non supporté."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is SyncRequestResult.Error -> {
|
is SyncRequestResult.Error -> {
|
||||||
success = false
|
success = false
|
||||||
|
status = result.message
|
||||||
status =
|
}
|
||||||
result.message
|
}
|
||||||
|
} finally {
|
||||||
|
syncRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -346,10 +377,8 @@ fun SyncScreen(
|
||||||
description =
|
description =
|
||||||
"Action de récupération manuelle si nécessaire.",
|
"Action de récupération manuelle si nécessaire.",
|
||||||
onClick = {
|
onClick = {
|
||||||
when (
|
coroutineScope.launch {
|
||||||
val result =
|
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
|
||||||
inbox.importPcCatalog()
|
|
||||||
) {
|
|
||||||
is CatalogInboxResult.Imported -> {
|
is CatalogInboxResult.Imported -> {
|
||||||
success = true
|
success = true
|
||||||
|
|
||||||
|
|
@ -385,6 +414,7 @@ fun SyncScreen(
|
||||||
result.message
|
result.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
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.SyncExporter
|
||||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: SyncCatalogInbox, requestOutbox: SyncRequestOutbox, appState: TrainlogAppState) {
|
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 draftRevision by remember { mutableIntStateOf(0) }
|
||||||
var draftMessage by remember { mutableStateOf<String?>(null) }
|
var draftMessage by remember { mutableStateOf<String?>(null) }
|
||||||
val navigationController = appState.navigationController
|
val navigationController = appState.navigationController
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
fun exportSnapshot() {
|
||||||
|
coroutineScope.launch { exporter.exportMobileBundle() }
|
||||||
|
}
|
||||||
|
|
||||||
/* CONTRACT: automatic exchange work belongs to the application lifecycle.
|
/* CONTRACT: automatic exchange work belongs to the application lifecycle.
|
||||||
* Re-entering a route must never trigger a second import or export. */
|
* Re-entering a route must never trigger a second import or export. */
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
when (inbox.importPcCatalog()) {
|
when (withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
|
||||||
is CatalogInboxResult.Imported -> catalogRevision++
|
is CatalogInboxResult.Imported -> catalogRevision++
|
||||||
CatalogInboxResult.FolderNotAuthorized, CatalogInboxResult.FileNotFound,
|
CatalogInboxResult.FolderNotAuthorized, CatalogInboxResult.FileNotFound,
|
||||||
is CatalogInboxResult.Error -> Unit
|
is CatalogInboxResult.Error -> Unit
|
||||||
}
|
}
|
||||||
exporter.exportMobileBundle()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
|
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) },
|
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
||||||
)
|
)
|
||||||
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) })
|
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) }, {
|
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."
|
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."
|
||||||
draftRevision++
|
draftRevision++
|
||||||
|
|
@ -99,19 +106,23 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
openSection(AppSection.SESSIONS)
|
openSection(AppSection.SESSIONS)
|
||||||
})
|
})
|
||||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
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)) })
|
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.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.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exportSnapshot(); catalogRevision++; back() }
|
||||||
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exporter.exportMobileBundle(); 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)) })
|
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.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.Statistics -> StatisticsDashboard(repository)
|
||||||
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exporter.exportMobileBundle() }, { back() })
|
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
||||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||||
AppRoute.Sync -> SyncScreen(inbox, requestOutbox, { exporter.exportMobileBundle(); catalogRevision++ }, { back() })
|
AppRoute.Sync -> SyncScreen(inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||||
AppRoute.Settings -> SettingsScreen(inbox) { exporter.exportMobileBundle(); catalogRevision++ }
|
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) {
|
private fun AppRoute.stateKey(): String = when (this) {
|
||||||
is AppRoute.SessionDetail -> "session:${sessionId}"
|
is AppRoute.SessionDetail -> "session:${sessionId}"
|
||||||
|
is AppRoute.SessionCorrection -> "session-correction:${sessionId}"
|
||||||
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
|
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
|
||||||
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
|
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
|
||||||
is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}"
|
is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}"
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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")) {
|
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(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"))
|
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"))
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,11 @@ class AndroidV11ExerciseAliasMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||||
val after = snapshot(db).filterKeys { it != "exercise_aliases" }
|
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") }
|
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]) }
|
.forEach { (table, rows) -> assertEquals(rows, after[table]) }
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM exercise_aliases"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM exercise_aliases"))
|
||||||
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
|
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
|
||||||
|
|
|
||||||
|
|
@ -323,7 +323,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
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 ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,19 +5,20 @@ import org.junit.Test
|
||||||
class FeedbackElapsedLabelTest {
|
class FeedbackElapsedLabelTest {
|
||||||
@Test fun `whole hours floor and never switch to days`() {
|
@Test fun `whole hours floor and never switch to days`() {
|
||||||
val end="2026-09-01T10:00:00+02:00"
|
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`() {
|
@Test fun `exercise feedback distinguishes session time after time and unknown`() {
|
||||||
val end="2026-09-01T10:00:00+02:00"
|
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:00:00+02:00","2026-09-01T09:20:00+02:00"))
|
||||||
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:59:59+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+0",exerciseFeedbackElapsedLabel(end,end))
|
assertEquals("H+?",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:30:00+02:00","2026-09-01T09:20:00+02:00"))
|
||||||
assertEquals("H+8",exerciseFeedbackElapsedLabel(end,"2026-09-01T18:00:00+02:00"))
|
assertEquals("H+0",exerciseFeedbackElapsedLabel(end,null,end))
|
||||||
assertEquals("Ressenti",exerciseFeedbackElapsedLabel(null,"2026-09-01T09:20:00+02:00"))
|
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`() {
|
@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+-")})
|
assertTrue(labels.none{it.startsWith("H+-")})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -125,7 +125,7 @@ class RealAndroidV12MachineExerciseMigrationTest {
|
||||||
|
|
||||||
private fun assertValidVersionThirteen(databasePath: File) {
|
private fun assertValidVersionThirteen(databasePath: File) {
|
||||||
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
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;"))
|
assertEquals("ok", scalarString(database, "PRAGMA integrity_check;"))
|
||||||
database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
|
database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@ class RealAndroidV14FeedbackRevisionMigrationTest {
|
||||||
}
|
}
|
||||||
TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() }
|
TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() }
|
||||||
SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db->
|
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.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(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"))
|
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"))
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ class TrainingFeedbackRepositoryTest {
|
||||||
assertEquals(listOf(first.stableId,later.stableId),completed.map{it.feedbackId})
|
assertEquals(listOf(first.stableId,later.stableId),completed.map{it.feedbackId})
|
||||||
assertEquals(reopened.map{it.observedAt},completed.map{it.observedAt})
|
assertEquals(reopened.map{it.observedAt},completed.map{it.observedAt})
|
||||||
assertEquals(reopened.map{it.rawText},completed.map{it.rawText})
|
assertEquals(reopened.map{it.rawText},completed.map{it.rawText})
|
||||||
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(
|
assertEquals("H+?",exerciseFeedbackElapsedLabel(
|
||||||
android.getSessionDetail(saved.sessionId)!!.summary.endedAt,completed.first().observedAt))
|
android.getSessionDetail(saved.sessionId)!!.summary.endedAt,
|
||||||
|
android.getSessionDetail(saved.sessionId)!!.summary.startedAt,
|
||||||
|
completed.first().observedAt))
|
||||||
val exported=JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback")
|
val exported=JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback")
|
||||||
assertEquals(2,exported.length())
|
assertEquals(2,exported.length())
|
||||||
assertEquals(setOf(first.stableId,later.stableId),(0 until exported.length()).map{exported.getJSONObject(it).getString("feedback_id")}.toSet())
|
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(listOf("avant reprise","pendant reprise"),feedback.map{it.rawText})
|
||||||
assertEquals(101.0,android.getSessionDetail(saved.sessionId)!!.exercises.single().maxWeightKg!!,0.0)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
assertEquals(listOf(target.exerciseId), repo.listExercises().map { it.exerciseId })
|
assertEquals(listOf(target.exerciseId), repo.listExercises().map { it.exerciseId })
|
||||||
assertEquals("Curl canonique", repo.listExercises().single().name)
|
assertEquals("Curl canonique", repo.listExercises().single().name)
|
||||||
|
|
||||||
val canonicalCatalog = JSONObject(staleCatalog.toString())
|
val canonicalCatalog = JSONObject(staleCatalog)
|
||||||
canonicalCatalog.getJSONArray("exercises").getJSONObject(0)
|
canonicalCatalog.getJSONArray("exercises").getJSONObject(0)
|
||||||
.put("exercise_id", target.exerciseId)
|
.put("exercise_id", target.exerciseId)
|
||||||
.put("name", "Curl canonique renommé")
|
.put("name", "Curl canonique renommé")
|
||||||
|
|
@ -870,10 +870,16 @@ class TrainlogRepositoryDraftTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun renameRejectsDuplicateAndInvalidNamesAndLocksReferencedProfile() {
|
fun renameRejectsDuplicateAndInvalidNamesAndReferencedProfileEditPreservesSnapshots() {
|
||||||
val repo = openRepository()
|
val repo = openRepository()
|
||||||
val referenced = createExercise(repo, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
|
val referenced = createExercise(repo, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
|
||||||
createExercise(repo, "Course", 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(
|
assertEquals(
|
||||||
ActiveDraftMutationResult.Saved,
|
ActiveDraftMutationResult.Saved,
|
||||||
repo.saveActiveSessionDraft(
|
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(
|
assertEquals(
|
||||||
EditExerciseResult.Conflict,
|
EditExerciseResult.Conflict,
|
||||||
repo.editExercise(
|
repo.editExercise(
|
||||||
|
|
@ -912,8 +920,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertTrue(
|
||||||
EditExerciseResult.IncompatibleProfileChange,
|
|
||||||
repo.editExercise(
|
repo.editExercise(
|
||||||
ExerciseEditInput(
|
ExerciseEditInput(
|
||||||
referenced.exerciseId,
|
referenced.exerciseId,
|
||||||
|
|
@ -922,8 +929,20 @@ class TrainlogRepositoryDraftTest {
|
||||||
TrackingMode.REPS,
|
TrackingMode.REPS,
|
||||||
ExerciseDataFields.NONE,
|
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
|
@Test
|
||||||
|
|
@ -1528,7 +1547,6 @@ class TrainlogRepositoryDraftTest {
|
||||||
assertTrue(repo.applyPcMobileExportV2Json(changedBody.toString()) is MobileSessionImportResult.Invalid)
|
assertTrue(repo.applyPcMobileExportV2Json(changedBody.toString()) is MobileSessionImportResult.Invalid)
|
||||||
|
|
||||||
val incompatibleProfile = entry("en_incompatible_profile")
|
val incompatibleProfile = entry("en_incompatible_profile")
|
||||||
.put("tracking_mode", "duration")
|
|
||||||
.put("sets", org.json.JSONArray().put(JSONObject().put("duration_seconds", 8)))
|
.put("sets", org.json.JSONArray().put(JSONObject().put("duration_seconds", 8)))
|
||||||
assertTrue(repo.applyPcMobileExportV2Json(
|
assertTrue(repo.applyPcMobileExportV2Json(
|
||||||
artifact(org.json.JSONArray().put(session("se_incompatible_profile", incompatibleProfile))).toString(),
|
artifact(org.json.JSONArray().put(session("se_incompatible_profile", incompatibleProfile))).toString(),
|
||||||
|
|
@ -1651,7 +1669,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(15, cursor.getInt(0))
|
assertEquals(16, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -1778,7 +1796,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
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 ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
@ -2164,8 +2182,8 @@ class TrainlogRepositoryDraftTest {
|
||||||
}
|
}
|
||||||
artifact.getJSONArray("sessions").getJSONObject(0).getJSONArray("exercises")
|
artifact.getJSONArray("sessions").getJSONObject(0).getJSONArray("exercises")
|
||||||
.getJSONObject(0).getJSONObject("target").put("reps", 7)
|
.getJSONObject(0).getJSONObject("target").put("reps", 7)
|
||||||
assertTrue(source.applyPcMobileExportV3Json(artifact.toString()) is MobileSessionImportResult.Invalid)
|
assertTrue(source.applyPcMobileExportV3Json(artifact.toString()) is MobileSessionImportResult.Applied)
|
||||||
assertEquals(6, source.getSessionDetail(sessionId)!!.exercises.single().plan!!.reps)
|
assertEquals(7, source.getSessionDetail(sessionId)!!.exercises.single().plan!!.reps)
|
||||||
|
|
||||||
source.close(); repository = null
|
source.close(); repository = null
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use {
|
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use {
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ class DraftFeedbackUiReadbackTest {
|
||||||
assertEquals(ActiveDraftMutationResult.Saved,
|
assertEquals(ActiveDraftMutationResult.Saved,
|
||||||
repository.saveActiveSessionDraft(ActiveSessionDraft(exercises = listOf(entry))))
|
repository.saveActiveSessionDraft(ActiveSessionDraft(exercises = listOf(entry))))
|
||||||
assertTrue(repository.saveDraftExerciseFeedback(
|
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)
|
val completedSessionId = mutableStateOf<String?>(null)
|
||||||
compose.setContent { TrainlogTheme {
|
compose.setContent { TrainlogTheme {
|
||||||
|
|
@ -69,6 +69,9 @@ class DraftFeedbackUiReadbackTest {
|
||||||
|
|
||||||
val saved = repository.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
|
val saved = repository.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
|
||||||
compose.runOnIdle { completedSessionId.value = saved.sessionId }
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
catalogue. The artifact preserves occurrence order, continuous metrics, set
|
||||||
weights and equipment. The legacy V1 contract remains separate and readable.
|
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,
|
V3 preserves ordinary plan metadata atomically with occurrence identity,
|
||||||
equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are
|
equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are
|
||||||
never silently rewritten as V3.
|
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
|
## 2. Implemented navigation
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -505,10 +538,14 @@ Android writes:
|
||||||
trainlog-sync-request-v1.json
|
trainlog-sync-request-v1.json
|
||||||
```
|
```
|
||||||
|
|
||||||
If MediaStore cannot reopen an older MTP-created canonical object, it may
|
The request is resolved and rewritten through the same persisted SAF tree as
|
||||||
publish the exact collision sibling `trainlog-sync-request-v1 (N).json`. The
|
the bundle. Android does not create a numbered request sibling merely because
|
||||||
desktop engine selects the newest canonical-or-suffixed request
|
MediaStore cannot expose an MTP- or peer-created canonical object. The stable
|
||||||
deterministically, while the stable `request_id` remains the replay boundary.
|
`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:
|
and waits for a matching:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,8 +168,13 @@ silently rendered as unclassified.
|
||||||
|
|
||||||
### Desktop
|
### Desktop
|
||||||
|
|
||||||
Desktop SQLite schema v15 is canonical long-term history. Its v14 -> v15
|
Desktop SQLite schema v17 is canonical long-term history. Its v15 -> v16
|
||||||
additive step introduces immutable wording-revision tables for the subjective
|
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
|
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
|
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
|
||||||
be finite `>= 0`; the column already existed and targets/max results retain
|
be finite `>= 0`; the column already existed and targets/max results retain
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ TRAINING_KNOWLEDGE_V1=PASS
|
||||||
SESSION_GENERATOR_V1=PASS
|
SESSION_GENERATOR_V1=PASS
|
||||||
APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2
|
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
|
ANDROID_BUILD=PASS
|
||||||
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
|
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
|
||||||
```
|
```
|
||||||
|
|
@ -81,7 +81,9 @@ Implemented:
|
||||||
F7 actions, and restored focus after overlays/routes);
|
F7 actions, and restored focus after overlays/routes);
|
||||||
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
|
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
|
||||||
minimum terminal;
|
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
|
occurrence-level equipment identity, and desktop-local custom-equipment
|
||||||
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
|
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
|
||||||
rebuilds only `performed_sets` to permit explicit zero actual loads while
|
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
|
catalogs as the sole authored scientific source, generates the immutable C
|
||||||
catalog representation, and loads the same assets on Android. It has no
|
catalog representation, and loads the same assets on Android. It has no
|
||||||
database migration, no auto-seeding, and no synchronization artifact. The
|
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,
|
rows own machine-facing load semantics, optional legacy equipment provenance,
|
||||||
an optional scientific profile, and an explicit resolved/unresolved science
|
an optional scientific profile, and an explicit resolved/unresolved science
|
||||||
state. Legacy equipment tables and occurrence columns remain intact.
|
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
|
`sy_fb0630a8-1938-4d62-9836-b0281955f625` both completed successfully. The
|
||||||
second run reported zero additions/reconciliations, both stores retained the
|
second run reported zero additions/reconciliations, both stores retained the
|
||||||
same 32-relation stable-ID hash, and Android application tables plus companion
|
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
|
exercise states compared equal to the first pass. Android now resolves the
|
||||||
MediaStore collision family `trainlog-sync-request-v1 (N).json`; request-ID
|
exact request and every outbound artifact through the persisted authorized SAF
|
||||||
replay protection remains authoritative.
|
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
|
## Current implementation cursor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ is a later, separately versioned migration.
|
||||||
## 1. Status
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION=15
|
TRAINLOG_DATABASE_SCHEMA_VERSION=17
|
||||||
DATABASE_SCHEMA_V12=PASS
|
DATABASE_SCHEMA_V17=PASS
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
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
|
current-text cache. The authoritative current revision is selected by parsed
|
||||||
`created_at` instant, then bytewise `revision_id` on ties.
|
`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
|
## 3. Connection invariants
|
||||||
|
|
||||||
Every connection enables:
|
Every connection enables:
|
||||||
|
|
@ -204,6 +222,7 @@ session_row_id
|
||||||
exercise_row_id
|
exercise_row_id
|
||||||
entry_id UNIQUE stable occurrence identity
|
entry_id UNIQUE stable occurrence identity
|
||||||
recording_mode
|
recording_mode
|
||||||
|
tracking_mode immutable occurrence unit snapshot
|
||||||
data_fields
|
data_fields
|
||||||
position
|
position
|
||||||
load_mode
|
load_mode
|
||||||
|
|
@ -216,9 +235,9 @@ equipment_id nullable equipment identity
|
||||||
notes
|
notes
|
||||||
```
|
```
|
||||||
|
|
||||||
Current desktop history snapshots `recording_mode` and `data_fields` in the
|
Desktop history snapshots `recording_mode`, `tracking_mode` and `data_fields`
|
||||||
session row. `tracking_mode` remains associated with the referenced exercise
|
in the occurrence row. Catalogue profile edits supply defaults only to future
|
||||||
catalog identity.
|
occurrences and never update these snapshots.
|
||||||
|
|
||||||
An occurrence `equipment_id` resolves to either a supplied manifest definition
|
An occurrence `equipment_id` resolves to either a supplied manifest definition
|
||||||
or a desktop-local `custom_equipment` definition. An ID that cannot be
|
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
|
Exercise naming changes update only `exercises.name` and `normalized_name` for
|
||||||
explicitly mapped stable IDs; no schema migration or historical-row rewrite is
|
explicitly mapped stable IDs; no schema migration or historical-row rewrite is
|
||||||
required.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,14 @@ CONTINUOUS + REPS
|
||||||
|
|
||||||
UI behavior must never be inferred from an exercise display name.
|
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
|
## 2. Examples
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Machine-exercise Phase 1 compatibility
|
## 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
|
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
||||||
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
||||||
silently added to a frozen artifact: stable exercise IDs and canonical names
|
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
|
dedicated versioned companion is required. Legacy equipment IDs may still be
|
||||||
written for fixed supplied machines as compatibility provenance.
|
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
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -34,6 +47,14 @@ BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS
|
||||||
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
|
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
|
Synchronization artifacts are separate from the frozen Trainlog session JSON
|
||||||
v1 format.
|
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
|
`trainlog-training-feedback-v2.json`. Its append-only revision union, strict identity
|
||||||
checks, ordering, bounds, and conflicts are specified in
|
checks, ordering, bounds, and conflicts are specified in
|
||||||
[Training feedback](training_feedback.md).
|
[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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
exact feedback preservation, selective cascade on removal, no inheritance by a
|
||||||
new entry, unchanged follow-ups, and full rollback on injected reattachment
|
new entry, unchanged follow-ups, and full rollback on injected reattachment
|
||||||
failure. Android additionally covers its bounded resumed-MAX replacement path.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,11 @@ resources.
|
||||||
`observed_at` and `sessions.ended_at` are exact offset-aware Trainlog
|
`observed_at` and `sessions.ended_at` are exact offset-aware Trainlog
|
||||||
timestamps. New Android completions persist their explicit finalization instant
|
timestamps. New Android completions persist their explicit finalization instant
|
||||||
as `ended_at`; old NULL anchors are not backfilled or derived from `started_at`.
|
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,
|
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
|
When `ended_at` is unavailable but valid `started_at` gives a nonnegative delta,
|
||||||
the end displays `Pendant la séance`; at/after end it uses H+, and without an
|
both surfaces use `≈H+floor((observed-start)/3600)`; otherwise they use `H+?`.
|
||||||
anchor it displays the neutral `Ressenti`. No negative H+ is rendered. Ordering
|
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
|
parses instants, then compares the stable ID bytewise; it never
|
||||||
uses lexical timestamp order or machine-local timezone.
|
uses lexical timestamp order or machine-local timezone.
|
||||||
|
|
||||||
|
|
|
||||||
13
docs/tui.md
13
docs/tui.md
|
|
@ -8,6 +8,19 @@ synchronization.
|
||||||
|
|
||||||
Desktop SQLite is the canonical long-term history.
|
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
|
## 2. APP_SHELL_V1 navigation
|
||||||
|
|
||||||
The persistent shell has seven root sections:
|
The persistent shell has seven root sections:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"""Strict JSON validation regressions for exercise-alias imports."""
|
"""Strict JSON validation regressions for exercise-alias imports."""
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import sys
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -10,6 +11,7 @@ from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
SPEC = importlib.util.spec_from_file_location(
|
SPEC = importlib.util.spec_from_file_location(
|
||||||
"import_exercise_aliases", ROOT / "tools/import_exercise_aliases.py")
|
"import_exercise_aliases", ROOT / "tools/import_exercise_aliases.py")
|
||||||
MODULE = importlib.util.module_from_spec(SPEC)
|
MODULE = importlib.util.module_from_spec(SPEC)
|
||||||
|
|
|
||||||
149
tests/test_profile_state_sync.py
Normal file
149
tests/test_profile_state_sync.py
Normal 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()
|
||||||
|
|
@ -24,6 +24,28 @@ CREATE TABLE exercise_body_zone_sync(
|
||||||
PRAGMA user_version=11;
|
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):
|
def run(command, ok=True):
|
||||||
result = subprocess.run(command, text=True, capture_output=True)
|
result = subprocess.run(command, text=True, capture_output=True)
|
||||||
|
|
@ -32,6 +54,11 @@ def run(command, ok=True):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def db_value(path, sql):
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
return connection.execute(sql).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
def payload():
|
def payload():
|
||||||
exercises = [
|
exercises = [
|
||||||
{"exercise_id": "ex_reps", "name": "Reps", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
|
{"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 = 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")
|
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)
|
corrected = run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)])
|
||||||
assert snapshot(db) == before
|
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 = [
|
mutations = [
|
||||||
lambda e: e["target"].update(sets=0),
|
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]
|
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["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}]
|
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")
|
print("PASS session_exchange_v3")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import sqlite3
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CATALOG = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
|
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")
|
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)
|
parser.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = sqlite3.connect(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
|
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 à v13 requis")
|
raise ValueError("schema desktop v8 à v16 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
FORMAT = "trainlog-equipment-definitions"
|
FORMAT = "trainlog-equipment-definitions"
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
@ -20,11 +21,11 @@ def main():
|
||||||
parser.add_argument("output", type=Path)
|
parser.add_argument("output", type=Path)
|
||||||
parser.add_argument("--database", type=Path, default=default_database())
|
parser.add_argument("--database", type=Path, default=default_database())
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = sqlite3.connect(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
|
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 à v13 requis")
|
raise ValueError("schema desktop v8 à v16 requis")
|
||||||
equipment = [dict(row) for row in connection.execute(
|
equipment = [dict(row) for row in connection.execute(
|
||||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||||
"FROM custom_equipment ORDER BY equipment_id")]
|
"FROM custom_equipment ORDER BY equipment_id")]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import argparse
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
MAX_ALIASES = 4096
|
MAX_ALIASES = 4096
|
||||||
|
|
||||||
|
|
@ -13,10 +14,10 @@ def main():
|
||||||
parser.add_argument("output", type=Path)
|
parser.add_argument("output", type=Path)
|
||||||
parser.add_argument("--database", type=Path, required=True)
|
parser.add_argument("--database", type=Path, required=True)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
con = sqlite3.connect(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15):
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17):
|
||||||
raise ValueError("schema desktop v12/v13 requis")
|
raise ValueError("schema desktop v12-v16 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
CATALOG = ROOT / "catalog/body-zones-v1.json"
|
CATALOG = ROOT / "catalog/body-zones-v1.json"
|
||||||
|
|
@ -37,11 +38,11 @@ def main():
|
||||||
parser.add_argument("--database", type=Path, default=default_database())
|
parser.add_argument("--database", type=Path, default=default_database())
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
assignable = assignable_zone_ids()
|
assignable = assignable_zone_ids()
|
||||||
connection = sqlite3.connect(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17):
|
||||||
raise ValueError("schema desktop v11/v12/v13 requis")
|
raise ValueError("schema desktop v11-v16 requis")
|
||||||
exercises = []
|
exercises = []
|
||||||
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
|
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:
|
if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None:
|
||||||
|
|
|
||||||
77
tools/export_exercise_profile_state.py
Normal file
77
tools/export_exercise_profile_state.py
Normal 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()
|
||||||
|
|
@ -7,6 +7,7 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from exercise_names import load_exercise_names
|
from exercise_names import load_exercise_names
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
|
||||||
def default_database_path():
|
def default_database_path():
|
||||||
|
|
@ -54,7 +55,7 @@ def main():
|
||||||
"PC_CATALOG_EXPORT=FAIL database not found"
|
"PC_CATALOG_EXPORT=FAIL database not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
connection = sqlite3.connect(
|
connection = connect_database(
|
||||||
args.database
|
args.database
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -67,7 +68,7 @@ def main():
|
||||||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# 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(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from validate_json import TrainlogSemanticError, parse_timestamp
|
from validate_json import TrainlogSemanticError, parse_timestamp
|
||||||
from exercise_names import load_exercise_names
|
from exercise_names import load_exercise_names
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
|
||||||
CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
|
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("--database", type=Path, default=default_database())
|
||||||
parser.add_argument("--version", type=int, choices=(2, 3), default=3)
|
parser.add_argument("--version", type=int, choices=(2, 3), default=3)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
con = sqlite3.connect(args.database)
|
con = connect_database(args.database)
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
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):
|
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/v12/v13 requis (v10 accepté pour export V2 explicite)")
|
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||||
known_equipment = supplied_equipment_ids()
|
known_equipment = supplied_equipment_ids()
|
||||||
canonical_names = load_exercise_names()
|
canonical_names = load_exercise_names()
|
||||||
known_equipment.update(row[0] for row in con.execute(
|
known_equipment.update(row[0] for row in con.execute(
|
||||||
|
|
@ -89,9 +90,10 @@ def main():
|
||||||
f"session_id={session['session_id']} started_at")
|
f"session_id={session['session_id']} started_at")
|
||||||
payload = {key: session[key] for key in ("session_id", "started_at", "session_type")}
|
payload = {key: session[key] for key in ("session_id", "started_at", "session_type")}
|
||||||
payload["exercises"] = []
|
payload["exercises"] = []
|
||||||
# INVARIANT: tracking mode is catalogue metadata. v7 occurrences
|
# INVARIANT: v16 exports the occurrence-owned tracking snapshot;
|
||||||
# retain their stable entry_id but do not duplicate that field.
|
# older readable schemas retain their bounded catalogue fallback.
|
||||||
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"
|
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"],)):
|
for entry in con.execute(sql, (session["id"],)):
|
||||||
validate_plan(entry)
|
validate_plan(entry)
|
||||||
if entry["recording_mode"] == "continuous" or entry["max_weight_kg"] is not None:
|
if entry["recording_mode"] == "continuous" or entry["max_weight_kg"] is not None:
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Export the bounded, direction-neutral TRAINING_FEEDBACK_V2 companion."""
|
"""Export the bounded, direction-neutral TRAINING_FEEDBACK_V2 companion."""
|
||||||
import argparse, datetime, json, sqlite3
|
import argparse, datetime, json, sqlite3
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
def instant(value): return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
def instant(value): return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
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:
|
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()
|
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()
|
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")
|
if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations")
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import argparse
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
FORMAT = "trainlog-equipment-associations"
|
FORMAT = "trainlog-equipment-associations"
|
||||||
VERSION = 2
|
VERSION = 2
|
||||||
|
|
@ -170,10 +171,10 @@ def main():
|
||||||
fail("extension équipement non supportée")
|
fail("extension équipement non supportée")
|
||||||
if set(payload) != {"format", "version", "generated_at", "associations"}:
|
if set(payload) != {"format", "version", "generated_at", "associations"}:
|
||||||
fail("clés extension équipement invalides")
|
fail("clés extension équipement invalides")
|
||||||
connection = sqlite3.connect(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||||
fail("schema desktop v8 à v13 requis")
|
fail("schema desktop v8 à v16 requis")
|
||||||
known = load_catalog(args.catalog)
|
known = load_catalog(args.catalog)
|
||||||
known.update(row[0] for row in connection.execute(
|
known.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import argparse
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
FORMAT = "trainlog-equipment-definitions"
|
FORMAT = "trainlog-equipment-definitions"
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
@ -58,10 +59,10 @@ def main():
|
||||||
default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json")
|
default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
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:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||||
fail("schema desktop v8 à v13 requis")
|
fail("schema desktop v8 à v16 requis")
|
||||||
imported = skipped = 0
|
imported = skipped = 0
|
||||||
# Validate every same-ID row before inserting any definition.
|
# Validate every same-ID row before inserting any definition.
|
||||||
for definition in definitions:
|
for definition in definitions:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import json
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
MAX_BYTES = 1024 * 1024
|
MAX_BYTES = 1024 * 1024
|
||||||
MAX_ALIASES = 4096
|
MAX_ALIASES = 4096
|
||||||
|
|
@ -59,11 +60,11 @@ def main():
|
||||||
parser.add_argument("--database", type=Path, required=True)
|
parser.add_argument("--database", type=Path, required=True)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
aliases = load(args.artifact)
|
aliases = load(args.artifact)
|
||||||
con = sqlite3.connect(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
con.execute("PRAGMA foreign_keys=ON")
|
con.execute("PRAGMA foreign_keys=ON")
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15):
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17):
|
||||||
fail("schema desktop v12/v13/v14/v15 requis")
|
fail("schema desktop v12-v16 requis")
|
||||||
con.execute("BEGIN IMMEDIATE")
|
con.execute("BEGIN IMMEDIATE")
|
||||||
for source, canonical in aliases:
|
for source, canonical in aliases:
|
||||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import sqlite3
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
@ -201,11 +202,11 @@ def main():
|
||||||
mobile_proof = (load_mobile_exercise_proof(args.mobile_export)
|
mobile_proof = (load_mobile_exercise_proof(args.mobile_export)
|
||||||
if args.mobile_export is not None else {})
|
if args.mobile_export is not None else {})
|
||||||
|
|
||||||
connection = sqlite3.connect(args.database)
|
connection = connect_database(args.database)
|
||||||
updated = skipped = kept_local = 0
|
updated = skipped = kept_local = 0
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17):
|
||||||
raise ImportFailure("schema desktop v11/v12/v13 requis")
|
raise ImportFailure("schema desktop v11-v16 requis")
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
grouped = {}
|
grouped = {}
|
||||||
|
|
|
||||||
102
tools/import_exercise_profile_state.py
Normal file
102
tools/import_exercise_profile_state.py
Normal 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()
|
||||||
|
|
@ -10,6 +10,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from validate_json import TrainlogSemanticError, parse_timestamp
|
from validate_json import TrainlogSemanticError, parse_timestamp
|
||||||
from exercise_names import ExerciseNameCatalogError, load_exercise_names
|
from exercise_names import ExerciseNameCatalogError, load_exercise_names
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
|
||||||
FORMAT = "trainlog-mobile-export"
|
FORMAT = "trainlog-mobile-export"
|
||||||
|
|
@ -492,18 +493,10 @@ def validate_session_exercise(
|
||||||
label,
|
label,
|
||||||
)
|
)
|
||||||
|
|
||||||
catalog_profile = known_exercise_ids[exercise_id]
|
# CONTRACT: catalogue membership proves stable identity only. Every
|
||||||
if (
|
# completed occurrence owns its recording/tracking/data-field snapshot in
|
||||||
recording_mode != catalog_profile["recording_mode"]
|
# every readable mobile version; later CURRENT profile evolution cannot
|
||||||
or tracking_mode != catalog_profile["tracking_mode"]
|
# invalidate or reinterpret historical actuals.
|
||||||
or item["data_fields"] & ~catalog_profile["data_fields"]
|
|
||||||
):
|
|
||||||
# CONTRACT: an occurrence snapshots the fields that actually existed
|
|
||||||
# when it was recorded. It may omit later optional catalog fields, but
|
|
||||||
# it may never claim a field absent from the catalog profile.
|
|
||||||
raise ImportFailure(
|
|
||||||
f"{label}: snapshot incompatible avec le profil catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
if version < 3:
|
if version < 3:
|
||||||
if item["load_mode"] != "none" or item["rest_seconds"] != 0:
|
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
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# 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(
|
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 ""
|
values = "?, " if entry_id is not None else ""
|
||||||
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
||||||
equipment_values = ", ?" 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))
|
arguments.extend(occurrence_plan_values(item))
|
||||||
if schema_version >= 6:
|
if schema_version >= 6:
|
||||||
arguments.append(item.get("equipment_id"))
|
arguments.append(item.get("equipment_id"))
|
||||||
|
|
@ -1299,7 +1297,7 @@ def import_set_session_exercise(
|
||||||
INSERT INTO session_exercises(
|
INSERT INTO session_exercises(
|
||||||
""" + columns + """session_row_id,
|
""" + columns + """session_row_id,
|
||||||
exercise_row_id,
|
exercise_row_id,
|
||||||
recording_mode,
|
recording_mode""" + tracking_columns + """,
|
||||||
data_fields,
|
data_fields,
|
||||||
position,
|
position,
|
||||||
load_mode,
|
load_mode,
|
||||||
|
|
@ -1309,7 +1307,7 @@ def import_set_session_exercise(
|
||||||
target_duration_seconds,
|
target_duration_seconds,
|
||||||
target_weight_kg, notes""" + equipment_columns + """
|
target_weight_kg, notes""" + equipment_columns + """
|
||||||
) VALUES(
|
) VALUES(
|
||||||
""" + values + """?, ?, 'sets', ?, ?, ?, ?,
|
""" + values + """?, ?, 'sets'""" + tracking_values + """, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, NULL""" + equipment_values + """
|
?, ?, ?, ?, NULL""" + equipment_values + """
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
|
|
@ -1363,7 +1361,12 @@ def import_continuous_session_exercise(
|
||||||
values = "?, " if entry_id is not None else ""
|
values = "?, " if entry_id is not None else ""
|
||||||
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
||||||
equipment_values = ", ?" 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))
|
arguments.extend(occurrence_plan_values(item))
|
||||||
if schema_version >= 6:
|
if schema_version >= 6:
|
||||||
arguments.append(item.get("equipment_id"))
|
arguments.append(item.get("equipment_id"))
|
||||||
|
|
@ -1372,7 +1375,7 @@ def import_continuous_session_exercise(
|
||||||
INSERT INTO session_exercises(
|
INSERT INTO session_exercises(
|
||||||
""" + columns + """session_row_id,
|
""" + columns + """session_row_id,
|
||||||
exercise_row_id,
|
exercise_row_id,
|
||||||
recording_mode,
|
recording_mode""" + tracking_columns + """,
|
||||||
data_fields,
|
data_fields,
|
||||||
position,
|
position,
|
||||||
load_mode,
|
load_mode,
|
||||||
|
|
@ -1382,7 +1385,7 @@ def import_continuous_session_exercise(
|
||||||
target_duration_seconds,
|
target_duration_seconds,
|
||||||
target_weight_kg, notes""" + equipment_columns + """
|
target_weight_kg, notes""" + equipment_columns + """
|
||||||
) VALUES(
|
) VALUES(
|
||||||
""" + values + """?, ?, 'continuous', ?, ?, ?, ?,
|
""" + values + """?, ?, 'continuous'""" + tracking_values + """, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?, NULL""" + equipment_values + """
|
?, ?, ?, ?, NULL""" + equipment_values + """
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
|
|
@ -1422,25 +1425,24 @@ def import_max_session_exercise(
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
"max_weight_kg exige le schéma desktop v9"
|
"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(
|
cursor = connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO session_exercises(
|
INSERT INTO session_exercises(
|
||||||
entry_id, session_row_id, exercise_row_id, recording_mode,
|
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_sets, target_reps, target_duration_seconds,
|
||||||
target_weight_kg, notes, equipment_id
|
target_weight_kg, notes, equipment_id
|
||||||
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?);
|
) VALUES(?, ?, ?, ?""" + tracking_value + """, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?);
|
||||||
""",
|
""",
|
||||||
(
|
arguments,
|
||||||
item["entry_id"],
|
|
||||||
session_row_id,
|
|
||||||
exercise_row,
|
|
||||||
item["recording_mode"],
|
|
||||||
item["data_fields"],
|
|
||||||
position,
|
|
||||||
*occurrence_plan_values(item),
|
|
||||||
item.get("equipment_id"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) "
|
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) "
|
||||||
|
|
@ -1456,6 +1458,8 @@ def import_sessions(
|
||||||
report,
|
report,
|
||||||
):
|
):
|
||||||
for session in payload["sessions"]:
|
for session in payload["sessions"]:
|
||||||
|
retained_feedback = []
|
||||||
|
retained_feedback_revisions = []
|
||||||
if session_exists(
|
if session_exists(
|
||||||
connection,
|
connection,
|
||||||
session["session_id"],
|
session["session_id"],
|
||||||
|
|
@ -1495,11 +1499,21 @@ def import_sessions(
|
||||||
and all(current[index] == incoming[index]
|
and all(current[index] == incoming[index]
|
||||||
for index in range(len(current)))
|
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
|
# A v1 history may be upgraded only when exercise/order mapping is
|
||||||
# unique. Any other identity disagreement is an explicit conflict.
|
# unique. Any other identity disagreement is an explicit conflict.
|
||||||
if current != incoming and not (
|
if current != incoming and not (
|
||||||
(legacy and [x[1] for x in current] == [x[1] for x in incoming])
|
(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"])
|
raise ImportFailure("conflit d'identités d'entrées pour " + session["session_id"])
|
||||||
if not legacy and session_semantically_matches(
|
if not legacy and session_semantically_matches(
|
||||||
|
|
@ -1510,12 +1524,25 @@ def import_sessions(
|
||||||
):
|
):
|
||||||
report["sessions_skipped"] += 1
|
report["sessions_skipped"] += 1
|
||||||
continue
|
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"])
|
raise ImportFailure("conflit de contenu pour " + session["session_id"])
|
||||||
# CONTRACT: a resumed max_test may edit existing max values and
|
# CONTRACT: V3 carries the complete occurrence and planning state.
|
||||||
# append occurrences, but cannot remove/reorder/rebind any stable
|
# A correction may alter facts and remove explicitly confirmed
|
||||||
# entry. This bounded replacement makes tomorrow's continuation
|
# parents, but a retained entry_id cannot be rebound to another
|
||||||
# idempotent without turning arbitrary session conflicts into wins.
|
# 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
|
# Explicit child deletion makes reconciliation safe even for old
|
||||||
# databases which were created without enforced foreign keys.
|
# databases which were created without enforced foreign keys.
|
||||||
connection.execute("DELETE FROM performed_sets WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
|
connection.execute("DELETE FROM performed_sets WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
|
||||||
|
|
@ -1569,9 +1596,9 @@ def import_sessions(
|
||||||
f"exercice desktop absent: {desktop_id}"
|
f"exercice desktop absent: {desktop_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||||
row["tracking_mode"]
|
if schema_version < 16 and (
|
||||||
!= item["tracking_mode"]
|
row["tracking_mode"] != item["tracking_mode"]
|
||||||
or row["recording_mode"]
|
or row["recording_mode"]
|
||||||
!= item["recording_mode"]
|
!= item["recording_mode"]
|
||||||
or item["data_fields"] & ~row["data_fields"]
|
or item["data_fields"] & ~row["data_fields"]
|
||||||
|
|
@ -1610,6 +1637,25 @@ def import_sessions(
|
||||||
row_id,
|
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
|
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()
|
"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"]):
|
if header is None or (header[0], header[1]) != (incoming["started_at"], incoming["session_type"]):
|
||||||
return False
|
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(
|
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.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 "
|
"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()
|
"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}"
|
f"base desktop introuvable: {database_path}"
|
||||||
)
|
)
|
||||||
|
|
||||||
connection = sqlite3.connect(
|
connection = connect_database(
|
||||||
database_path
|
database_path
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1927,7 +1975,7 @@ def main():
|
||||||
|
|
||||||
known_equipment_ids = load_supplied_equipment_ids(args.catalog)
|
known_equipment_ids = load_supplied_equipment_ids(args.catalog)
|
||||||
if args.database.exists():
|
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:
|
if connection.execute("PRAGMA user_version").fetchone()[0] >= 8:
|
||||||
known_equipment_ids.update(row[0] for row in connection.execute(
|
known_equipment_ids.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Strict V1-compatible, append-only TRAINING_FEEDBACK_V2 desktop merger."""
|
"""Strict V1-compatible, append-only TRAINING_FEEDBACK_V2 desktop merger."""
|
||||||
import argparse,json,re,sqlite3
|
import argparse,json,re,sqlite3
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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}$")
|
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()))
|
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]
|
return added,skipped,current[2]
|
||||||
def main():
|
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
|
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||||
try:
|
try:
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
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()
|
db.execute("BEGIN IMMEDIATE");seen=set()
|
||||||
for index,item in enumerate(root["exercise_feedback"]):
|
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"}
|
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"}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from exercise_names import normalize_catalog_name
|
from exercise_names import normalize_catalog_name
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DEFAULT_MANIFEST = Path("/tmp/trainlog-machine-exercise-manifest/migration-manifest-v1.json")
|
DEFAULT_MANIFEST = Path("/tmp/trainlog-machine-exercise-manifest/migration-manifest-v1.json")
|
||||||
|
|
@ -167,7 +168,7 @@ def main():
|
||||||
if database == LIVE_DB.resolve():
|
if database == LIVE_DB.resolve():
|
||||||
fail("refusing to migrate the canonical live desktop database")
|
fail("refusing to migrate the canonical live desktop database")
|
||||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||||
connection = sqlite3.connect(database)
|
connection = connect_database(database)
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
changed = apply(connection, manifest)
|
changed = apply(connection, manifest)
|
||||||
integrity = connection.execute("PRAGMA integrity_check").fetchone()[0]
|
integrity = connection.execute("PRAGMA integrity_check").fetchone()[0]
|
||||||
|
|
|
||||||
49
tools/trainlog_sqlite.py
Normal file
49
tools/trainlog_sqlite.py
Normal 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))
|
||||||
|
|
@ -10,6 +10,7 @@ import math
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -29,12 +30,88 @@ SCHEMA_PATH = ROOT / "format" / "trainlog-v1.schema.json"
|
||||||
VALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "valid"
|
VALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "valid"
|
||||||
INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid"
|
INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid"
|
||||||
BODY_ZONE_CATALOG_PATH = ROOT / "catalog" / "body-zones-v1.json"
|
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):
|
class TrainlogSemanticError(ValueError):
|
||||||
"""Raised when structurally valid JSON violates its format semantics."""
|
"""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:
|
def validate_body_zone_catalog(document: Any) -> None:
|
||||||
"""Validate the one canonical taxonomy, hierarchy and initial mappings."""
|
"""Validate the one canonical taxonomy, hierarchy and initial mappings."""
|
||||||
if not isinstance(document, dict) or set(document) != {
|
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_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_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_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:
|
if is_body_zones:
|
||||||
try:
|
try:
|
||||||
validate_body_zone_catalog(document)
|
validate_body_zone_catalog(document)
|
||||||
|
|
@ -456,6 +534,12 @@ def validate_document(
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return [str(exc)]
|
return [str(exc)]
|
||||||
return []
|
return []
|
||||||
|
if is_profile_state:
|
||||||
|
try:
|
||||||
|
validate_exercise_profile_state(document)
|
||||||
|
except TrainlogSemanticError as exc:
|
||||||
|
return [str(exc)]
|
||||||
|
return []
|
||||||
if not is_mobile_v2:
|
if not is_mobile_v2:
|
||||||
errors = structural_errors(validator, document)
|
errors = structural_errors(validator, document)
|
||||||
if errors:
|
if errors:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 15
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 17
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ typedef struct TrainlogSessionExerciseInput {
|
||||||
/* Optional canonical ID from equipment-v1.json, never a SQLite row ID. */
|
/* Optional canonical ID from equipment-v1.json, never a SQLite row ID. */
|
||||||
char equipment_id[TRAINLOG_ID_MAX + 1U];
|
char equipment_id[TRAINLOG_ID_MAX + 1U];
|
||||||
TrainlogRecordingMode recording_mode;
|
TrainlogRecordingMode recording_mode;
|
||||||
|
/** Occurrence-owned unit snapshot; catalogue edits affect future rows only. */
|
||||||
|
TrainlogTrackingMode tracking_mode;
|
||||||
TrainlogExerciseDataFields data_fields;
|
TrainlogExerciseDataFields data_fields;
|
||||||
TrainlogLoadMode load_mode;
|
TrainlogLoadMode load_mode;
|
||||||
int rest_seconds;
|
int rest_seconds;
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,12 @@ TrainlogStatus trainlog_time_now_rfc3339(
|
||||||
size_t output_size
|
size_t output_size
|
||||||
);
|
);
|
||||||
|
|
||||||
/* CONTRACT: exercise observations before end use "Pendant la séance" and a
|
/* CONTRACT: ended_at yields exact H+ and alone can prove "Pendant la séance".
|
||||||
* missing anchor uses "Ressenti"; follow-ups use H+? in both cases. No caller
|
* If unavailable, a valid nonnegative started_at delta is visibly approximate
|
||||||
* can receive a negative H+ label. */
|
* (≈H+); otherwise both feedback kinds use H+?. */
|
||||||
TrainlogStatus trainlog_feedback_relative_label(
|
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);
|
char *output, size_t output_size);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -674,6 +674,22 @@ test_feedback_correction = executable(
|
||||||
)
|
)
|
||||||
test('feedback_correction', test_feedback_correction)
|
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 = executable(
|
||||||
'test_app_shell',
|
'test_app_shell',
|
||||||
'tests/test_app_shell.c',
|
'tests/test_app_shell.c',
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,59 @@
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#include <sqlite3.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)
|
TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database)
|
||||||
{
|
{
|
||||||
|
|
@ -55,7 +108,7 @@ TrainlogStatus trainlog_database_scan_generation_history(
|
||||||
{
|
{
|
||||||
static const char *const SQL =
|
static const char *const SQL =
|
||||||
"SELECT s.session_id,s.started_at,se.entry_id,e.exercise_id,e.recording_mode,"
|
"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,"
|
"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 "
|
"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 "
|
"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;"
|
"SELECT 'fr0_'||followup_id,followup_id,observed_at,raw_text FROM session_followups;"
|
||||||
"PRAGMA user_version=15;COMMIT;";
|
"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 =
|
static const char *const MIGRATE_V1_TO_V3_SQL =
|
||||||
"BEGIN IMMEDIATE;"
|
"BEGIN IMMEDIATE;"
|
||||||
"ALTER TABLE sessions "
|
"ALTER TABLE sessions "
|
||||||
|
|
@ -1145,13 +1379,6 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
version ==
|
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION
|
|
||||||
) {
|
|
||||||
return TRAINLOG_STATUS_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (version == 0) {
|
if (version == 0) {
|
||||||
status =
|
status =
|
||||||
execute_sql(
|
execute_sql(
|
||||||
|
|
@ -1197,7 +1424,8 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
||||||
} else if (version == 10) {
|
} else if (version == 10) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
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;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
|
|
@ -1352,6 +1580,16 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
if (status == TRAINLOG_STATUS_OK && version < 15) {
|
if (status == TRAINLOG_STATUS_OK && version < 15) {
|
||||||
status = execute_sql(database, MIGRATE_V14_TO_V15_SQL);
|
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 (
|
if (
|
||||||
status !=
|
status !=
|
||||||
|
|
@ -1360,7 +1598,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
set_open_diagnostic(
|
set_open_diagnostic(
|
||||||
output_diagnostic,
|
output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
output_diagnostic_capacity,
|
||||||
version == 0 ? "create schema v15" : "migrate database to schema v15",
|
version == 0 ? "create schema v17" : "migrate database to schema v17",
|
||||||
database->connection,
|
database->connection,
|
||||||
SQLITE_ERROR
|
SQLITE_ERROR
|
||||||
);
|
);
|
||||||
|
|
@ -1456,6 +1694,22 @@ TrainlogStatus trainlog_database_open_with_diagnostic(
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
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) {
|
if (sqlite3_busy_timeout(database->connection, 5000) != SQLITE_OK) {
|
||||||
set_open_diagnostic(
|
set_open_diagnostic(
|
||||||
output_diagnostic,
|
output_diagnostic,
|
||||||
|
|
@ -1957,7 +2211,6 @@ TrainlogStatus trainlog_database_update_exercise_profiled(
|
||||||
sqlite3_int64 row_id;
|
sqlite3_int64 row_id;
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
int rc;
|
int rc;
|
||||||
bool profile_changed;
|
|
||||||
const char *tracking;
|
const char *tracking;
|
||||||
const char *recording;
|
const char *recording;
|
||||||
|
|
||||||
|
|
@ -1973,43 +2226,9 @@ TrainlogStatus trainlog_database_update_exercise_profiled(
|
||||||
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK)
|
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK)
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
|
||||||
rc = sqlite3_prepare_v2(database->connection,
|
/* CONTRACT: schema v16 makes tracking semantics occurrence-owned. A
|
||||||
"SELECT tracking_mode,recording_mode,data_fields FROM exercises WHERE id=?1;",
|
* catalogue profile update therefore controls future occurrences only;
|
||||||
-1, &statement, NULL);
|
* this transaction never updates session_exercises snapshots. */
|
||||||
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;
|
|
||||||
}
|
|
||||||
rc = sqlite3_prepare_v2(database->connection,
|
rc = sqlite3_prepare_v2(database->connection,
|
||||||
"UPDATE exercises SET name=?1,normalized_name=?2,tracking_mode=?3,"
|
"UPDATE exercises SET name=?1,normalized_name=?2,tracking_mode=?3,"
|
||||||
"recording_mode=?4,data_fields=?5 WHERE id=?6;", -1, &statement, NULL);
|
"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;
|
sqlite3_int64 exercise_row_id;
|
||||||
const char *load_mode;
|
const char *load_mode;
|
||||||
const char *recording_mode;
|
const char *recording_mode;
|
||||||
|
const char *tracking_mode;
|
||||||
char generated_entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
char generated_entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||||
const char *entry_id;
|
const char *entry_id;
|
||||||
int rc;
|
int rc;
|
||||||
|
|
@ -3093,8 +3313,11 @@ static TrainlogStatus insert_session_exercise(
|
||||||
input->recording_mode
|
input->recording_mode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
tracking_mode = tracking_mode_to_sql(input->tracking_mode);
|
||||||
|
|
||||||
if (load_mode == NULL ||
|
if (load_mode == NULL ||
|
||||||
recording_mode == NULL) {
|
recording_mode == NULL ||
|
||||||
|
tracking_mode == NULL) {
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3195,14 +3418,14 @@ static TrainlogStatus insert_session_exercise(
|
||||||
database->connection,
|
database->connection,
|
||||||
"INSERT INTO session_exercises("
|
"INSERT INTO session_exercises("
|
||||||
"entry_id, session_row_id, exercise_row_id, "
|
"entry_id, session_row_id, exercise_row_id, "
|
||||||
"recording_mode, data_fields, "
|
"recording_mode, tracking_mode, data_fields, "
|
||||||
"position, load_mode, rest_seconds, "
|
"position, load_mode, rest_seconds, "
|
||||||
"target_sets, target_reps, "
|
"target_sets, target_reps, "
|
||||||
"target_duration_seconds, "
|
"target_duration_seconds, "
|
||||||
"target_weight_kg, notes, equipment_id"
|
"target_weight_kg, notes, equipment_id"
|
||||||
") VALUES("
|
") VALUES("
|
||||||
"?1, ?2, ?3, ?4, ?5, ?6, ?7, "
|
"?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, "
|
||||||
"?8, ?9, ?10, ?11, ?12, ?13, ?14"
|
"?9, ?10, ?11, ?12, ?13, ?14, ?15"
|
||||||
");",
|
");",
|
||||||
-1,
|
-1,
|
||||||
&statement,
|
&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) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = sqlite3_bind_int64(
|
rc = sqlite3_bind_int64(
|
||||||
statement,
|
statement,
|
||||||
5,
|
6,
|
||||||
(sqlite3_int64)input->data_fields
|
(sqlite3_int64)input->data_fields
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3252,7 +3479,7 @@ static TrainlogStatus insert_session_exercise(
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = sqlite3_bind_int64(
|
rc = sqlite3_bind_int64(
|
||||||
statement,
|
statement,
|
||||||
6,
|
7,
|
||||||
(sqlite3_int64)position
|
(sqlite3_int64)position
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3260,7 +3487,7 @@ static TrainlogStatus insert_session_exercise(
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = sqlite3_bind_text(
|
rc = sqlite3_bind_text(
|
||||||
statement,
|
statement,
|
||||||
7,
|
8,
|
||||||
load_mode,
|
load_mode,
|
||||||
-1,
|
-1,
|
||||||
SQLITE_STATIC
|
SQLITE_STATIC
|
||||||
|
|
@ -3270,30 +3497,17 @@ static TrainlogStatus insert_session_exercise(
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = sqlite3_bind_int(
|
rc = sqlite3_bind_int(
|
||||||
statement,
|
statement,
|
||||||
8,
|
9,
|
||||||
input->rest_seconds
|
input->rest_seconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = input->target_sets > 0
|
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(
|
? sqlite3_bind_int(
|
||||||
statement,
|
statement,
|
||||||
10,
|
10,
|
||||||
input->target_reps
|
input->target_sets
|
||||||
)
|
)
|
||||||
: sqlite3_bind_null(
|
: sqlite3_bind_null(
|
||||||
statement,
|
statement,
|
||||||
|
|
@ -3302,11 +3516,11 @@ static TrainlogStatus insert_session_exercise(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = input->target_duration_seconds > 0
|
rc = input->target_reps > 0
|
||||||
? sqlite3_bind_int(
|
? sqlite3_bind_int(
|
||||||
statement,
|
statement,
|
||||||
11,
|
11,
|
||||||
input->target_duration_seconds
|
input->target_reps
|
||||||
)
|
)
|
||||||
: sqlite3_bind_null(
|
: sqlite3_bind_null(
|
||||||
statement,
|
statement,
|
||||||
|
|
@ -3315,11 +3529,11 @@ static TrainlogStatus insert_session_exercise(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = input->target_has_weight
|
rc = input->target_duration_seconds > 0
|
||||||
? sqlite3_bind_double(
|
? sqlite3_bind_int(
|
||||||
statement,
|
statement,
|
||||||
12,
|
12,
|
||||||
input->target_weight_kg
|
input->target_duration_seconds
|
||||||
)
|
)
|
||||||
: sqlite3_bind_null(
|
: sqlite3_bind_null(
|
||||||
statement,
|
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) {
|
if (rc == SQLITE_OK) {
|
||||||
rc =
|
rc =
|
||||||
input->notes != NULL &&
|
input->notes != NULL &&
|
||||||
input->notes[0] != '\0'
|
input->notes[0] != '\0'
|
||||||
? sqlite3_bind_text(
|
? sqlite3_bind_text(
|
||||||
statement,
|
statement,
|
||||||
13,
|
14,
|
||||||
input->notes,
|
input->notes,
|
||||||
-1,
|
-1,
|
||||||
SQLITE_TRANSIENT
|
SQLITE_TRANSIENT
|
||||||
)
|
)
|
||||||
: sqlite3_bind_null(
|
: sqlite3_bind_null(
|
||||||
statement,
|
statement,
|
||||||
13
|
14
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = input->equipment_id[0] != '\0'
|
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)
|
SQLITE_TRANSIENT)
|
||||||
: sqlite3_bind_null(statement, 14);
|
: sqlite3_bind_null(statement, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rc != SQLITE_OK) {
|
if (rc != SQLITE_OK) {
|
||||||
|
|
@ -4717,7 +4944,7 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
|
|
||||||
static const char *const EXERCISE_SQL =
|
static const char *const EXERCISE_SQL =
|
||||||
"SELECT "
|
"SELECT "
|
||||||
"e.name, e.tracking_mode, "
|
"e.name, se.tracking_mode, "
|
||||||
"se.recording_mode, se.data_fields, "
|
"se.recording_mode, se.data_fields, "
|
||||||
"se.load_mode, se.rest_seconds, "
|
"se.load_mode, se.rest_seconds, "
|
||||||
"COALESCE(se.target_sets, 0), "
|
"COALESCE(se.target_sets, 0), "
|
||||||
|
|
@ -5451,7 +5678,7 @@ TrainlogStatus trainlog_database_list_exercise_performance(
|
||||||
"s.session_id, "
|
"s.session_id, "
|
||||||
"s.started_at, "
|
"s.started_at, "
|
||||||
"s.session_type, "
|
"s.session_type, "
|
||||||
"e.tracking_mode, "
|
"se.tracking_mode, "
|
||||||
"se.load_mode, "
|
"se.load_mode, "
|
||||||
"ps.id, "
|
"ps.id, "
|
||||||
"ps.reps, "
|
"ps.reps, "
|
||||||
|
|
@ -5800,7 +6027,7 @@ TrainlogStatus trainlog_database_load_session_editable(
|
||||||
"se.id, "
|
"se.id, "
|
||||||
"e.exercise_id, "
|
"e.exercise_id, "
|
||||||
"e.name, "
|
"e.name, "
|
||||||
"e.tracking_mode, "
|
"se.tracking_mode, "
|
||||||
"se.load_mode, "
|
"se.load_mode, "
|
||||||
"se.rest_seconds, "
|
"se.rest_seconds, "
|
||||||
"se.target_sets, "
|
"se.target_sets, "
|
||||||
|
|
@ -7093,7 +7320,7 @@ TrainlogStatus trainlog_database_list_exercise_occurrences_page(
|
||||||
"WHERE e.exercise_id=?1;";
|
"WHERE e.exercise_id=?1;";
|
||||||
static const char *const HYDRATE_SQL =
|
static const char *const HYDRATE_SQL =
|
||||||
"SELECT s.session_id,se.entry_id,e.exercise_id,s.started_at,COALESCE(se.equipment_id,''),"
|
"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),"
|
"(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 "
|
"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 "
|
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ static const char *const TRAINING_FEEDBACK_NAME =
|
||||||
"trainlog-training-feedback-v2.json";
|
"trainlog-training-feedback-v2.json";
|
||||||
static const char *const TRAINING_FEEDBACK_V1_NAME =
|
static const char *const TRAINING_FEEDBACK_V1_NAME =
|
||||||
"trainlog-training-feedback-v1.json";
|
"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 =
|
static const char *const SYNC_REQUEST_NAME =
|
||||||
"trainlog-sync-request-v1.json";
|
"trainlog-sync-request-v1.json";
|
||||||
|
|
@ -96,6 +98,8 @@ static const char *const EXERCISE_ALIASES_LOCAL =
|
||||||
"/tmp/trainlog-exercise-aliases-v1.json";
|
"/tmp/trainlog-exercise-aliases-v1.json";
|
||||||
static const char *const TRAINING_FEEDBACK_LOCAL =
|
static const char *const TRAINING_FEEDBACK_LOCAL =
|
||||||
"/tmp/trainlog-training-feedback-v2.json";
|
"/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 =
|
static const char *const EXERCISE_BODY_ZONES_RESULT =
|
||||||
"/tmp/trainlog-exercise-body-zones-result.txt";
|
"/tmp/trainlog-exercise-body-zones-result.txt";
|
||||||
|
|
@ -104,6 +108,8 @@ static const char *const EXERCISE_ALIASES_RESULT =
|
||||||
"/tmp/trainlog-exercise-aliases-result.txt";
|
"/tmp/trainlog-exercise-aliases-result.txt";
|
||||||
static const char *const TRAINING_FEEDBACK_RESULT =
|
static const char *const TRAINING_FEEDBACK_RESULT =
|
||||||
"/tmp/trainlog-training-feedback-result.txt";
|
"/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 =
|
static const char *const SYNC_REQUEST_LOCAL =
|
||||||
"/tmp/trainlog-sync-request-v1.json";
|
"/tmp/trainlog-sync-request-v1.json";
|
||||||
|
|
@ -2652,6 +2658,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
bool run_started = false;
|
bool run_started = false;
|
||||||
bool receipt_published = false;
|
bool receipt_published = false;
|
||||||
bool mobile_export_has_companions = false;
|
bool mobile_export_has_companions = false;
|
||||||
|
bool profile_state_received = false;
|
||||||
bool retained_mobile_export_is_v2 = false;
|
bool retained_mobile_export_is_v2 = false;
|
||||||
|
|
||||||
if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC ||
|
if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC ||
|
||||||
|
|
@ -2976,6 +2983,34 @@ TrainlogStatus trainlog_sync_run(
|
||||||
goto finalize;
|
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 =
|
status =
|
||||||
sync_run_python_tool(
|
sync_run_python_tool(
|
||||||
"import_mobile_export.py",
|
"import_mobile_export.py",
|
||||||
|
|
@ -3005,6 +3040,16 @@ TrainlogStatus trainlog_sync_run(
|
||||||
sizeof(useful)
|
sizeof(useful)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* 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(
|
sync_compose_diagnostic(
|
||||||
output->error,
|
output->error,
|
||||||
sizeof(output->error),
|
sizeof(output->error),
|
||||||
|
|
@ -3013,6 +3058,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
? useful
|
? useful
|
||||||
: "import mobile échoué"
|
: "import mobile échoué"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final_status =
|
final_status =
|
||||||
TRAINLOG_STATUS_DATABASE_ERROR;
|
TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
|
@ -3024,6 +3070,24 @@ TrainlogStatus trainlog_sync_run(
|
||||||
output
|
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
|
/* CONTRACT: body zones are one directional-neutral companion. Historic
|
||||||
* V2 publishers may omit it; when present beside V3/V2 it is applied after
|
* 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
|
* exercise definitions above established every ID or a source-V2-proven
|
||||||
|
|
@ -3139,6 +3203,25 @@ TrainlogStatus trainlog_sync_run(
|
||||||
}
|
}
|
||||||
|
|
||||||
outbound:
|
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",
|
status = sync_run_python_tool("export_exercise_aliases.py",
|
||||||
EXERCISE_ALIASES_LOCAL, database_path, NULL, EXERCISE_ALIASES_RESULT,
|
EXERCISE_ALIASES_LOCAL, database_path, NULL, EXERCISE_ALIASES_RESULT,
|
||||||
tool_output, sizeof(tool_output));
|
tool_output, sizeof(tool_output));
|
||||||
|
|
|
||||||
|
|
@ -79,23 +79,33 @@ TrainlogStatus trainlog_time_now_rfc3339(
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogStatus trainlog_feedback_relative_label(
|
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)
|
char *output, size_t output_size)
|
||||||
{
|
{
|
||||||
TrainlogTimestampKey ended, observed;
|
TrainlogTimestampKey ended, started, observed;
|
||||||
int written;
|
int written;
|
||||||
int64_t elapsed;
|
int64_t elapsed;
|
||||||
const char *neutral;
|
|
||||||
if (observed_at == NULL || output == NULL || output_size == 0U)
|
if (observed_at == NULL || output == NULL || output_size == 0U)
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
neutral = exercise_feedback ? "Ressenti" : "H+?";
|
if (!trainlog_timestamp_parse(observed_at, strlen(observed_at), &observed)) {
|
||||||
if (ended_at == NULL || ended_at[0] == '\0' ||
|
written = snprintf(output, output_size, "H+?");
|
||||||
!trainlog_timestamp_parse(ended_at, strlen(ended_at), &ended) ||
|
} else if (ended_at == NULL || ended_at[0] == '\0' ||
|
||||||
!trainlog_timestamp_parse(observed_at, strlen(observed_at), &observed)) {
|
!trainlog_timestamp_parse(ended_at, strlen(ended_at), &ended)) {
|
||||||
written = snprintf(output, output_size, "%s", neutral);
|
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) {
|
} 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",
|
written = snprintf(output, output_size, "%s",
|
||||||
exercise_feedback ? "Pendant la séance" : "H+?");
|
within_session ? "Pendant la séance" : "H+?");
|
||||||
} else {
|
} else {
|
||||||
elapsed = observed.utc_second - ended.utc_second;
|
elapsed = observed.utc_second - ended.utc_second;
|
||||||
written = snprintf(output, output_size, "H+%lld",
|
written = snprintf(output, output_size, "H+%lld",
|
||||||
|
|
|
||||||
|
|
@ -2478,18 +2478,20 @@ typedef enum TrainlogExercisePhase {
|
||||||
TRAINLOG_EXERCISE_MERGE_PICKER,
|
TRAINLOG_EXERCISE_MERGE_PICKER,
|
||||||
TRAINLOG_EXERCISE_MERGE_CONFIRM,
|
TRAINLOG_EXERCISE_MERGE_CONFIRM,
|
||||||
TRAINLOG_EXERCISE_MERGE_RESULT,
|
TRAINLOG_EXERCISE_MERGE_RESULT,
|
||||||
|
TRAINLOG_EXERCISE_CONFIRM_PROFILE,
|
||||||
TRAINLOG_EXERCISE_CONFIRM_DISCARD,
|
TRAINLOG_EXERCISE_CONFIRM_DISCARD,
|
||||||
TRAINLOG_EXERCISE_MESSAGE
|
TRAINLOG_EXERCISE_MESSAGE
|
||||||
} TrainlogExercisePhase;
|
} TrainlogExercisePhase;
|
||||||
|
|
||||||
typedef struct TrainlogExerciseController {
|
typedef struct TrainlogExerciseController {
|
||||||
/* CONTRACT: creation/editing owns one transient value set. An edit keeps
|
/* CONTRACT: creation/editing owns one transient value set. An edit keeps
|
||||||
* exercise_id and passes the stored profile back unchanged; referenced
|
* exercise_id; incompatible future-profile changes require confirmation
|
||||||
* profile restrictions remain enforced by the database transaction. */
|
* and never mutate occurrence-owned historical snapshots. */
|
||||||
TrainlogExercisePhase phase;
|
TrainlogExercisePhase phase;
|
||||||
TrainlogExercisePhase return_phase;
|
TrainlogExercisePhase return_phase;
|
||||||
TrainlogFormField form;
|
TrainlogFormField form;
|
||||||
TrainlogExercise pending;
|
TrainlogExercise pending;
|
||||||
|
TrainlogExercise original;
|
||||||
char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U];
|
char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U];
|
||||||
char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U];
|
char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U];
|
||||||
size_t secondary_count;
|
size_t secondary_count;
|
||||||
|
|
@ -2497,6 +2499,7 @@ typedef struct TrainlogExerciseController {
|
||||||
bool editing;
|
bool editing;
|
||||||
bool inline_session;
|
bool inline_session;
|
||||||
bool dirty;
|
bool dirty;
|
||||||
|
bool profile_confirmed;
|
||||||
bool leaving;
|
bool leaving;
|
||||||
TrainlogAppRoute pending_route;
|
TrainlogAppRoute pending_route;
|
||||||
char message[192];
|
char message[192];
|
||||||
|
|
@ -3654,22 +3657,22 @@ static TrainlogStatus dashboard_database_list_facts(TrainlogDatabase *database,
|
||||||
{
|
{
|
||||||
static const char *const SQL =
|
static const char *const SQL =
|
||||||
"SELECT 0,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
|
"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,"
|
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,ps.position,"
|
||||||
"CASE WHEN e.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END,"
|
"CASE WHEN se.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END,"
|
||||||
"ps.weight_kg FROM performed_sets ps "
|
"ps.weight_kg FROM performed_sets ps "
|
||||||
"JOIN session_exercises se ON se.id=ps.session_exercise_row_id "
|
"JOIN session_exercises se ON se.id=ps.session_exercise_row_id "
|
||||||
"JOIN sessions s ON s.id=se.session_row_id "
|
"JOIN sessions s ON s.id=se.session_row_id "
|
||||||
"JOIN exercises e ON e.id=se.exercise_row_id "
|
"JOIN exercises e ON e.id=se.exercise_row_id "
|
||||||
"UNION ALL "
|
"UNION ALL "
|
||||||
"SELECT 1,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
|
"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 "
|
"mr.max_weight_kg FROM max_results mr "
|
||||||
"JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
|
"JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
|
||||||
"JOIN sessions s ON s.id=se.session_row_id "
|
"JOIN sessions s ON s.id=se.session_row_id "
|
||||||
"JOIN exercises e ON e.id=se.exercise_row_id "
|
"JOIN exercises e ON e.id=se.exercise_row_id "
|
||||||
"UNION ALL "
|
"UNION ALL "
|
||||||
"SELECT 2,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
|
"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 "
|
"ca.duration_seconds,NULL FROM continuous_activity ca "
|
||||||
"JOIN session_exercises se ON se.id=ca.session_exercise_row_id "
|
"JOIN session_exercises se ON se.id=ca.session_exercise_row_id "
|
||||||
"JOIN sessions s ON s.id=se.session_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;
|
TrainlogExerciseController *controller = &app->exercise_controller;
|
||||||
(void)memset(controller, 0, sizeof(*controller));
|
(void)memset(controller, 0, sizeof(*controller));
|
||||||
controller->pending = app->exercise_detail;
|
controller->pending = app->exercise_detail;
|
||||||
|
controller->original = app->exercise_detail;
|
||||||
controller->editing = true;
|
controller->editing = true;
|
||||||
controller->phase = TRAINLOG_EXERCISE_NAME;
|
controller->phase = TRAINLOG_EXERCISE_NAME;
|
||||||
controller->return_phase = TRAINLOG_EXERCISE_NAME;
|
controller->return_phase = TRAINLOG_EXERCISE_NAME;
|
||||||
|
|
@ -4706,6 +4710,13 @@ static bool exercise_controller_save(TrainlogAppContext *app)
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U];
|
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U];
|
||||||
size_t index;
|
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 &&
|
if (!controller->editing &&
|
||||||
controller->pending.recording_mode == TRAINLOG_RECORDING_SETS &&
|
controller->pending.recording_mode == TRAINLOG_RECORDING_SETS &&
|
||||||
controller->primary_zone[0] == '\0') {
|
controller->primary_zone[0] == '\0') {
|
||||||
|
|
@ -4812,6 +4823,17 @@ static bool app_shell_dispatch_exercise_controller(TrainlogAppContext *app,
|
||||||
controller->phase = controller->return_phase;
|
controller->phase = controller->return_phase;
|
||||||
return true;
|
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) {
|
if (controller->phase == TRAINLOG_EXERCISE_ZONES) {
|
||||||
const TrainlogBodyZone *zone = zone_count > 0U
|
const TrainlogBodyZone *zone = zone_count > 0U
|
||||||
? trainlog_body_zone_catalog_at(controller->zone_selected) : NULL;
|
? 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) {
|
if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) {
|
||||||
TrainlogExercisePhase phase = app->exercise_controller.phase;
|
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',
|
app_shell_add_action(app, "exercise.form.discard", '1',
|
||||||
"1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route);
|
"1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route);
|
||||||
app_shell_add_action(app, "exercise.form.resume", '0',
|
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");
|
"1 abandonner · 0/Échap reprendre");
|
||||||
return;
|
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) {
|
if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) {
|
||||||
trainlog_surface_printf(app->content, row++, 2, "%s", controller->message);
|
trainlog_surface_printf(app->content, row++, 2, "%s", controller->message);
|
||||||
trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir");
|
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;
|
heading = true;
|
||||||
}
|
}
|
||||||
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
|
(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,
|
trainlog_surface_printf(app->content, row++, 4, "%s %s", label,
|
||||||
feedback->raw_text);
|
feedback->raw_text);
|
||||||
}
|
}
|
||||||
|
|
@ -7045,6 +7084,7 @@ static void app_shell_render_content(TrainlogAppContext *app)
|
||||||
row < app->layout.content.height; ++followup_index) {
|
row < app->layout.content.height; ++followup_index) {
|
||||||
char label[32];
|
char label[32];
|
||||||
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
|
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
|
||||||
|
app->session_detail.started_at,
|
||||||
app->session_followups[followup_index].observed_at,
|
app->session_followups[followup_index].observed_at,
|
||||||
false, label, sizeof(label));
|
false, label, sizeof(label));
|
||||||
trainlog_surface_printf(app->content, row++, 4, "%s %s", label,
|
trainlog_surface_printf(app->content, row++, 4, "%s %s", label,
|
||||||
|
|
|
||||||
|
|
@ -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,"
|
"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);"
|
"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 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',"
|
"INSERT INTO exercises VALUES(7,'ex_b432623f-bfe9-4daf-a653-60ec7fdffbde',"
|
||||||
"'Leg press','leg press','reps','sets',0);"
|
"'Leg press','leg press','reps','sets',0);"
|
||||||
"INSERT INTO sessions VALUES(3,'se_preserved');"
|
"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);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
raw = NULL;
|
raw = NULL;
|
||||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
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,
|
CHECK(trainlog_database_list_exercise_body_zones(database,
|
||||||
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(count == 2U);
|
CHECK(count == 2U);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,20 @@
|
||||||
} \
|
} \
|
||||||
} while (0)
|
} 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)
|
static bool test_normalization(void)
|
||||||
{
|
{
|
||||||
char first[512];
|
char first[512];
|
||||||
|
|
@ -76,19 +90,19 @@ static bool test_profiled_creation(void)
|
||||||
{
|
{
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
TrainlogExercise walk;
|
TrainlogExercise walk;
|
||||||
TrainlogExercise exercises[4];
|
TrainlogExercise exercises[128];
|
||||||
size_t count = 0U;
|
size_t count = 0U;
|
||||||
size_t index;
|
size_t index;
|
||||||
bool found = false;
|
bool found = false;
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
trainlog_database_open(
|
trainlog_database_open(
|
||||||
":memory:",
|
":memory:",
|
||||||
&database
|
&database
|
||||||
) == TRAINLOG_STATUS_OK
|
) == TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
trainlog_catalog_create_exercise_profiled(
|
trainlog_catalog_create_exercise_profiled(
|
||||||
database,
|
database,
|
||||||
"Marche",
|
"Marche",
|
||||||
|
|
@ -99,22 +113,22 @@ static bool test_profiled_creation(void)
|
||||||
) == TRAINLOG_STATUS_OK
|
) == TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
walk.recording_mode ==
|
walk.recording_mode ==
|
||||||
TRAINLOG_RECORDING_CONTINUOUS
|
TRAINLOG_RECORDING_CONTINUOUS
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
walk.tracking_mode ==
|
walk.tracking_mode ==
|
||||||
TRAINLOG_TRACKING_DURATION
|
TRAINLOG_TRACKING_DURATION
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
walk.data_fields ==
|
walk.data_fields ==
|
||||||
TRAINLOG_EXERCISE_DATA_SPEED_KMH
|
TRAINLOG_EXERCISE_DATA_SPEED_KMH
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
trainlog_catalog_create_exercise_profiled(
|
trainlog_catalog_create_exercise_profiled(
|
||||||
database,
|
database,
|
||||||
"Profil invalide",
|
"Profil invalide",
|
||||||
|
|
@ -125,33 +139,31 @@ static bool test_profiled_creation(void)
|
||||||
) == TRAINLOG_STATUS_INVALID_ARGUMENT
|
) == TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
trainlog_database_list_exercises(
|
trainlog_database_list_exercises(
|
||||||
database,
|
database,
|
||||||
exercises,
|
exercises,
|
||||||
4U,
|
128U,
|
||||||
&count
|
&count
|
||||||
) == TRAINLOG_STATUS_OK
|
) == TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(count == 1U);
|
|
||||||
|
|
||||||
for (index = 0U; index < count; ++index) {
|
for (index = 0U; index < count; ++index) {
|
||||||
if (strcmp(
|
if (strcmp(
|
||||||
exercises[index].name,
|
exercises[index].name,
|
||||||
"Marche"
|
"Marche"
|
||||||
) == 0) {
|
) == 0) {
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
exercises[index].recording_mode ==
|
exercises[index].recording_mode ==
|
||||||
TRAINLOG_RECORDING_CONTINUOUS
|
TRAINLOG_RECORDING_CONTINUOUS
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
exercises[index].tracking_mode ==
|
exercises[index].tracking_mode ==
|
||||||
TRAINLOG_TRACKING_DURATION
|
TRAINLOG_TRACKING_DURATION
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
exercises[index].data_fields ==
|
exercises[index].data_fields ==
|
||||||
TRAINLOG_EXERCISE_DATA_SPEED_KMH
|
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);
|
trainlog_database_close(database);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ int main(void)
|
||||||
exercise.recording_mode =
|
exercise.recording_mode =
|
||||||
TRAINLOG_RECORDING_CONTINUOUS;
|
TRAINLOG_RECORDING_CONTINUOUS;
|
||||||
|
|
||||||
|
exercise.tracking_mode =
|
||||||
|
TRAINLOG_TRACKING_DURATION;
|
||||||
|
|
||||||
exercise.data_fields =
|
exercise.data_fields =
|
||||||
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ int main(void)
|
||||||
exercise.recording_mode =
|
exercise.recording_mode =
|
||||||
TRAINLOG_RECORDING_CONTINUOUS;
|
TRAINLOG_RECORDING_CONTINUOUS;
|
||||||
|
|
||||||
|
exercise.tracking_mode =
|
||||||
|
TRAINLOG_TRACKING_DURATION;
|
||||||
|
|
||||||
exercise.data_fields =
|
exercise.data_fields =
|
||||||
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,24 @@
|
||||||
} \
|
} \
|
||||||
} while (0)
|
} 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)
|
static bool test_profile_roundtrip(void)
|
||||||
{
|
{
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
TrainlogExercise exercises[4];
|
TrainlogExercise exercises[128];
|
||||||
size_t count = 0U;
|
size_t count = 0U;
|
||||||
size_t index;
|
size_t index;
|
||||||
bool found_walk = false;
|
bool found_walk = false;
|
||||||
|
|
@ -39,7 +53,7 @@ static bool test_profile_roundtrip(void)
|
||||||
) == TRAINLOG_STATUS_OK
|
) == TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
trainlog_database_insert_exercise(
|
trainlog_database_insert_exercise(
|
||||||
database,
|
database,
|
||||||
"ex_legacy",
|
"ex_legacy",
|
||||||
|
|
@ -49,7 +63,7 @@ static bool test_profile_roundtrip(void)
|
||||||
) == TRAINLOG_STATUS_OK
|
) == TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
trainlog_database_insert_exercise_profiled(
|
trainlog_database_insert_exercise_profiled(
|
||||||
database,
|
database,
|
||||||
"ex_walk",
|
"ex_walk",
|
||||||
|
|
@ -61,7 +75,7 @@ static bool test_profile_roundtrip(void)
|
||||||
) == TRAINLOG_STATUS_OK
|
) == TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
trainlog_database_insert_exercise_profiled(
|
trainlog_database_insert_exercise_profiled(
|
||||||
database,
|
database,
|
||||||
"ex_invalid",
|
"ex_invalid",
|
||||||
|
|
@ -73,7 +87,7 @@ static bool test_profile_roundtrip(void)
|
||||||
) == TRAINLOG_STATUS_INVALID_ARGUMENT
|
) == TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
trainlog_database_insert_exercise_profiled(
|
trainlog_database_insert_exercise_profiled(
|
||||||
database,
|
database,
|
||||||
"ex_unknown",
|
"ex_unknown",
|
||||||
|
|
@ -85,28 +99,26 @@ static bool test_profile_roundtrip(void)
|
||||||
) == TRAINLOG_STATUS_INVALID_ARGUMENT
|
) == TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
trainlog_database_list_exercises(
|
trainlog_database_list_exercises(
|
||||||
database,
|
database,
|
||||||
exercises,
|
exercises,
|
||||||
4U,
|
128U,
|
||||||
&count
|
&count
|
||||||
) == TRAINLOG_STATUS_OK
|
) == TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(count == 2U);
|
|
||||||
|
|
||||||
for (index = 0U; index < count; ++index) {
|
for (index = 0U; index < count; ++index) {
|
||||||
if (strcmp(exercises[index].exercise_id, "ex_walk") == 0) {
|
if (strcmp(exercises[index].exercise_id, "ex_walk") == 0) {
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
exercises[index].recording_mode ==
|
exercises[index].recording_mode ==
|
||||||
TRAINLOG_RECORDING_CONTINUOUS
|
TRAINLOG_RECORDING_CONTINUOUS
|
||||||
);
|
);
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
exercises[index].tracking_mode ==
|
exercises[index].tracking_mode ==
|
||||||
TRAINLOG_TRACKING_DURATION
|
TRAINLOG_TRACKING_DURATION
|
||||||
);
|
);
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
exercises[index].data_fields ==
|
exercises[index].data_fields ==
|
||||||
TRAINLOG_EXERCISE_DATA_SPEED_KMH
|
TRAINLOG_EXERCISE_DATA_SPEED_KMH
|
||||||
);
|
);
|
||||||
|
|
@ -114,20 +126,24 @@ static bool test_profile_roundtrip(void)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strcmp(exercises[index].exercise_id, "ex_legacy") == 0) {
|
if (strcmp(exercises[index].exercise_id, "ex_legacy") == 0) {
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
exercises[index].recording_mode ==
|
exercises[index].recording_mode ==
|
||||||
TRAINLOG_RECORDING_SETS
|
TRAINLOG_RECORDING_SETS
|
||||||
);
|
);
|
||||||
CHECK(exercises[index].data_fields == 0U);
|
CHECK_OR_CLEANUP(exercises[index].data_fields == 0U);
|
||||||
found_legacy = true;
|
found_legacy = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CHECK(found_walk);
|
CHECK_OR_CLEANUP(found_walk);
|
||||||
CHECK(found_legacy);
|
CHECK_OR_CLEANUP(found_legacy);
|
||||||
|
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool test_v2_to_current_migration(void)
|
static bool test_v2_to_current_migration(void)
|
||||||
|
|
@ -179,34 +195,36 @@ static bool test_v2_to_current_migration(void)
|
||||||
sqlite3 *raw = NULL;
|
sqlite3 *raw = NULL;
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
int fd;
|
int fd = -1;
|
||||||
int version = 0;
|
int version = 0;
|
||||||
|
bool success = false;
|
||||||
|
|
||||||
fd = mkstemp(path);
|
fd = mkstemp(path);
|
||||||
CHECK(fd >= 0);
|
CHECK_OR_CLEANUP(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK_OR_CLEANUP(close(fd) == 0);
|
||||||
|
fd = -1;
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK_OR_CLEANUP(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
sqlite3_exec(raw, V2_SQL, NULL, NULL, NULL) ==
|
sqlite3_exec(raw, V2_SQL, NULL, NULL, NULL) ==
|
||||||
SQLITE_OK
|
SQLITE_OK
|
||||||
);
|
);
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
CHECK_OR_CLEANUP(sqlite3_close(raw) == SQLITE_OK);
|
||||||
raw = NULL;
|
raw = NULL;
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
trainlog_database_open(path, &database) ==
|
trainlog_database_open(path, &database) ==
|
||||||
TRAINLOG_STATUS_OK
|
TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
trainlog_database_schema_version(
|
trainlog_database_schema_version(
|
||||||
database,
|
database,
|
||||||
&version
|
&version
|
||||||
) == TRAINLOG_STATUS_OK
|
) == TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
version ==
|
version ==
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION
|
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||||
);
|
);
|
||||||
|
|
@ -214,9 +232,9 @@ static bool test_v2_to_current_migration(void)
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
database = NULL;
|
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(
|
sqlite3_prepare_v2(
|
||||||
raw,
|
raw,
|
||||||
"SELECT recording_mode, data_fields "
|
"SELECT recording_mode, data_fields "
|
||||||
|
|
@ -227,18 +245,18 @@ static bool test_v2_to_current_migration(void)
|
||||||
) == SQLITE_OK
|
) == SQLITE_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW);
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
strcmp(
|
strcmp(
|
||||||
(const char *)sqlite3_column_text(statement, 0),
|
(const char *)sqlite3_column_text(statement, 0),
|
||||||
"sets"
|
"sets"
|
||||||
) == 0
|
) == 0
|
||||||
);
|
);
|
||||||
CHECK(sqlite3_column_int64(statement, 1) == 0);
|
CHECK_OR_CLEANUP(sqlite3_column_int64(statement, 1) == 0);
|
||||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
CHECK_OR_CLEANUP(sqlite3_finalize(statement) == SQLITE_OK);
|
||||||
statement = NULL;
|
statement = NULL;
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
sqlite3_prepare_v2(
|
sqlite3_prepare_v2(
|
||||||
raw,
|
raw,
|
||||||
"SELECT recording_mode, data_fields "
|
"SELECT recording_mode, data_fields "
|
||||||
|
|
@ -249,18 +267,18 @@ static bool test_v2_to_current_migration(void)
|
||||||
) == SQLITE_OK
|
) == SQLITE_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW);
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
strcmp(
|
strcmp(
|
||||||
(const char *)sqlite3_column_text(statement, 0),
|
(const char *)sqlite3_column_text(statement, 0),
|
||||||
"sets"
|
"sets"
|
||||||
) == 0
|
) == 0
|
||||||
);
|
);
|
||||||
CHECK(sqlite3_column_int64(statement, 1) == 0);
|
CHECK_OR_CLEANUP(sqlite3_column_int64(statement, 1) == 0);
|
||||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
CHECK_OR_CLEANUP(sqlite3_finalize(statement) == SQLITE_OK);
|
||||||
statement = NULL;
|
statement = NULL;
|
||||||
|
|
||||||
CHECK(
|
CHECK_OR_CLEANUP(
|
||||||
sqlite3_prepare_v2(
|
sqlite3_prepare_v2(
|
||||||
raw,
|
raw,
|
||||||
"SELECT name FROM sqlite_master "
|
"SELECT name FROM sqlite_master "
|
||||||
|
|
@ -271,14 +289,23 @@ static bool test_v2_to_current_migration(void)
|
||||||
) == SQLITE_OK
|
) == SQLITE_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW);
|
||||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
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;
|
raw = NULL;
|
||||||
|
|
||||||
CHECK(unlink(path) == 0);
|
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)
|
int main(void)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#define CHECK(x) do { if (!(x)) { fprintf(stderr,"FAIL %d\n",__LINE__); return 1; } } while (0)
|
#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";
|
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:00:00+02:00","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,"2026-09-01T09:00:00+02:00","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(end,"2026-09-01T09:30:00+02:00","2026-09-01T09:20:00+02:00",true,"H+?")==0);
|
||||||
CHECK(label(NULL,"2026-09-01T09:20:00+02:00",true,"Ressenti")==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,"2026-09-01T18:00:00+02:00",false,"H+?")==0);
|
CHECK(label(NULL,NULL,"2026-09-01T09:20:00+02:00",true,"H+?")==0);
|
||||||
CHECK(label(end,"2026-09-01T09:00:00+02:00",false,"H+?")==0);return 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;}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ static void max_input(TrainlogSessionExerciseInput *input,
|
||||||
(void)snprintf(input->equipment_id, sizeof(input->equipment_id), "%s",
|
(void)snprintf(input->equipment_id, sizeof(input->equipment_id), "%s",
|
||||||
"rear_delt_pec_fly");
|
"rear_delt_pec_fly");
|
||||||
input->recording_mode = TRAINLOG_RECORDING_SETS;
|
input->recording_mode = TRAINLOG_RECORDING_SETS;
|
||||||
|
input->tracking_mode = TRAINLOG_TRACKING_REPS;
|
||||||
input->load_mode = TRAINLOG_LOAD_NONE;
|
input->load_mode = TRAINLOG_LOAD_NONE;
|
||||||
input->has_max_weight = true;
|
input->has_max_weight = true;
|
||||||
input->max_weight_kg = weight;
|
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",
|
(void)snprintf(entries[2].exercise_id, sizeof(entries[2].exercise_id), "%s",
|
||||||
"ex_walk");
|
"ex_walk");
|
||||||
entries[2].recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
|
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].data_fields = TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
||||||
entries[2].load_mode = TRAINLOG_LOAD_NONE;
|
entries[2].load_mode = TRAINLOG_LOAD_NONE;
|
||||||
entries[2].continuous_duration_seconds = 600;
|
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,"
|
"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);"
|
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
|
||||||
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_type TEXT);"
|
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_type TEXT);"
|
||||||
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,"
|
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,"
|
||||||
"session_row_id INTEGER,exercise_row_id INTEGER,entry_id TEXT,"
|
"session_row_id INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL,recording_mode TEXT NOT NULL,"
|
||||||
"equipment_id TEXT,recording_mode TEXT);"
|
"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,"
|
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,"
|
||||||
"session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,"
|
"session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,"
|
||||||
"duration_seconds INTEGER,weight_kg REAL);"
|
"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 sessions VALUES(1,'max_test');"
|
||||||
"INSERT INTO session_exercises VALUES(10,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,1,NULL,NULL,NULL,'sets');"
|
"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,1,NULL,NULL,NULL,'sets');"
|
"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(20,10,0,1,NULL,100.0);"
|
||||||
"INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.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);"
|
"INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);"
|
||||||
|
|
|
||||||
227
tui/tests/test_schema_v16_migration.c
Normal file
227
tui/tests/test_schema_v16_migration.c
Normal 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; }
|
||||||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic, "migrate database to schema v15: SQLite rc=",
|
CHECK(strncmp(diagnostic, "migrate database to schema v17: SQLite rc=",
|
||||||
strlen("migrate database to schema v15: SQLite rc=")) == 0);
|
strlen("migrate database to schema v17: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != 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)
|
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];
|
char diagnostic[256];
|
||||||
sqlite3 *raw = NULL;
|
sqlite3 *raw = NULL;
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
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(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
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(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
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(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ static void bind_reps_exercise(
|
||||||
exercise->load_mode =
|
exercise->load_mode =
|
||||||
TRAINLOG_LOAD_EXTERNAL;
|
TRAINLOG_LOAD_EXTERNAL;
|
||||||
|
|
||||||
|
exercise->recording_mode = TRAINLOG_RECORDING_SETS;
|
||||||
|
exercise->tracking_mode = TRAINLOG_TRACKING_REPS;
|
||||||
|
|
||||||
exercise->rest_seconds = 90;
|
exercise->rest_seconds = 90;
|
||||||
exercise->target_sets = 3;
|
exercise->target_sets = 3;
|
||||||
exercise->target_reps = 10;
|
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_offset == 0U);
|
||||||
CHECK(loaded_exercises[0].set_count == 3U);
|
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[0].reps == 10);
|
||||||
CHECK(loaded_sets[2].reps == 8);
|
CHECK(loaded_sets[2].reps == 8);
|
||||||
CHECK(loaded_sets[0].weight_kg > 79.99);
|
CHECK(loaded_sets[0].weight_kg > 79.99);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ static const char *const EXERCISE_NAME = "Sync wiring exercise";
|
||||||
static char remote_root[4096];
|
static char remote_root[4096];
|
||||||
static const char *remote_mobile_name = "trainlog-mobile-export-v2.json";
|
static const char *remote_mobile_name = "trainlog-mobile-export-v2.json";
|
||||||
static unsigned int remote_mobile_version = 2U;
|
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)
|
static bool copy_file(const char *source, const char *target)
|
||||||
{
|
{
|
||||||
|
|
@ -148,7 +150,8 @@ TrainlogStatus trainlog_mtp_list_folder(
|
||||||
return TRAINLOG_STATUS_OK;
|
return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
if (parent_folder_id == 2U) {
|
if (parent_folder_id == 2U) {
|
||||||
if (capacity < 3U) {
|
size_t count = remote_profile_available ? 4U : 3U;
|
||||||
|
if (capacity < count) {
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
set_entry(&output[0], 10U, 2U, remote_mobile_name, false);
|
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);
|
"trainlog-exercise-body-zones-v1.json", false);
|
||||||
set_entry(&output[2], 12U, 2U,
|
set_entry(&output[2], 12U, 2U,
|
||||||
"trainlog-equipment-associations-v2.json", false);
|
"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;
|
return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
*output_count = 0U;
|
*output_count = 0U;
|
||||||
|
|
@ -178,7 +185,8 @@ TrainlogStatus trainlog_mtp_receive_file(
|
||||||
|
|
||||||
name = item_id == 10U ? remote_mobile_name :
|
name = item_id == 10U ? remote_mobile_name :
|
||||||
item_id == 11U ? "trainlog-exercise-body-zones-v1.json" :
|
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) {
|
if (name == NULL || local_path == NULL) {
|
||||||
return TRAINLOG_STATUS_NOT_FOUND;
|
return TRAINLOG_STATUS_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
@ -274,12 +282,31 @@ static bool write_artifacts(
|
||||||
"{\"format\":\"trainlog-mobile-export\",\"version\":%u,"
|
"{\"format\":\"trainlog-mobile-export\",\"version\":%u,"
|
||||||
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
|
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
|
||||||
"\"exercises\":[{\"exercise_id\":\"%s\",\"name\":\"%s\","
|
"\"exercises\":[{\"exercise_id\":\"%s\",\"name\":\"%s\","
|
||||||
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
|
"\"recording_mode\":\"sets\",\"tracking_mode\":\"%s\","
|
||||||
"\"data_fields\":0}],\"sessions\":[],\"body_observations\":[]}",
|
"\"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);
|
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),
|
written = snprintf(path, sizeof(path),
|
||||||
"%s/trainlog-exercise-body-zones-v1.json", remote_root);
|
"%s/trainlog-exercise-body-zones-v1.json", remote_root);
|
||||||
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
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;
|
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)
|
static bool run_all(void)
|
||||||
{
|
{
|
||||||
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
|
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
|
||||||
|
|
@ -596,6 +649,7 @@ static bool run_all(void)
|
||||||
char case_b[4096];
|
char case_b[4096];
|
||||||
char case_c[4096];
|
char case_c[4096];
|
||||||
char case_d[4096];
|
char case_d[4096];
|
||||||
|
char case_e[4096];
|
||||||
char *root = mkdtemp(temporary);
|
char *root = mkdtemp(temporary);
|
||||||
|
|
||||||
CHECK(root != NULL);
|
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_b, sizeof(case_b), "%s/case-b", root) > 0);
|
||||||
CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", 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_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_a, false));
|
||||||
CHECK(run_success_case(case_b, true));
|
CHECK(run_success_case(case_b, true));
|
||||||
CHECK(run_unknown_without_proof_case(case_c));
|
CHECK(run_unknown_without_proof_case(case_c));
|
||||||
CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d));
|
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");
|
(void)puts("PASS production sync body-zone V2 proof wiring");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -620,6 +620,25 @@ static bool test_shell_inline_exercise_creation_and_stable_edit(void)
|
||||||
CHECK(trainlog_database_list_exercises(database, listed, 8U,
|
CHECK(trainlog_database_list_exercises(database, listed, 8U,
|
||||||
&count) == TRAINLOG_STATUS_OK);
|
&count) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(count == 6U);
|
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);
|
trainlog_database_close(database);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue