fix(tui): use shared header on history screen
This commit is contained in:
parent
c9c4d3b9ce
commit
7093dd5606
41 changed files with 2689 additions and 321 deletions
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -9,6 +9,13 @@ Detailed implementation chronology remains available in Git history and
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- multi-occurrence session V2: stable per-occurrence `entry_id`, repeated
|
||||||
|
catalogue exercises in one session, per-set actual weights, and occurrence
|
||||||
|
equipment associations across Android, desktop, import and export;
|
||||||
|
- shared versioned equipment catalogue, Android machine selection/search,
|
||||||
|
Android-local custom equipment creation, and explicit rejection of unknown
|
||||||
|
equipment identities rather than silent association loss;
|
||||||
|
|
||||||
- Android `EXERCISE_EDIT_V1`: visible catalog editing, stable-ID name rename,
|
- Android `EXERCISE_EDIT_V1`: visible catalog editing, stable-ID name rename,
|
||||||
explicit invalid/conflict/profile/database results, and profile locking once
|
explicit invalid/conflict/profile/database results, and profile locking once
|
||||||
completed history or an active draft references the exercise;
|
completed history or an active draft references the exercise;
|
||||||
|
|
@ -45,6 +52,14 @@ Detailed implementation chronology remains available in Git history and
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- desktop SQLite schema v7 and Android SQLite schema v7 preserve historic rows
|
||||||
|
while adding durable occurrence identities and occurrence-level equipment;
|
||||||
|
- the active Android↔PC completed-session exchange is V2; frozen V1 artifacts
|
||||||
|
remain readable as historical formats and are not redefined for repeated
|
||||||
|
occurrences;
|
||||||
|
- synchronization invokes each local helper with the explicit XDG-resolved
|
||||||
|
desktop database path and records the concrete equipment-import failure;
|
||||||
|
|
||||||
- same-ID Android ↔ PC catalog reconciliation now updates display-name metadata
|
- same-ID Android ↔ PC catalog reconciliation now updates display-name metadata
|
||||||
in place and rejects a different-ID normalized-name collision, preserving
|
in place and rejects a different-ID normalized-name collision, preserving
|
||||||
synchronization identity and preventing renamed duplicates;
|
synchronization identity and preventing renamed duplicates;
|
||||||
|
|
@ -68,6 +83,12 @@ Detailed implementation chronology remains available in Git history and
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- equipment companion import previously omitted its required `--database`
|
||||||
|
argument and blocked synchronization after a successful session import;
|
||||||
|
- PC catalogue/mobile export paths now accept schema v7 and preserve catalogue
|
||||||
|
tracking metadata; Android completed-session equipment editing now targets
|
||||||
|
the stable occurrence `entry_id`, not an ambiguous catalogue exercise ID;
|
||||||
|
|
||||||
- in-progress Android workout loss when leaving the foreground or recreating
|
- in-progress Android workout loss when leaving the foreground or recreating
|
||||||
the Activity/process;
|
the Activity/process;
|
||||||
- missing selected-exercise recovery preserves raw partial input and reports a
|
- missing selected-exercise recovery preserves raw partial input and reports a
|
||||||
|
|
@ -88,12 +109,12 @@ Current validated baseline:
|
||||||
```text
|
```text
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V5=PASS
|
DESKTOP_SCHEMA_V7=PASS
|
||||||
DESKTOP_TESTS=22/22 PASS
|
DESKTOP_TESTS=25/25 PASS
|
||||||
|
|
||||||
ANDROID_BUILD=PASS
|
ANDROID_BUILD=PASS
|
||||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V4=PASS
|
ANDROID_LOCAL_DATABASE_V7=PASS
|
||||||
ANDROID_HOST_TESTS=8/8 PASS
|
ANDROID_HOST_TESTS=8/8 PASS
|
||||||
ANDROID_DEVICE_INSTRUMENTATION=5/5 PASS
|
ANDROID_DEVICE_INSTRUMENTATION=5/5 PASS
|
||||||
ANDROID_SESSION_DRAFT_V1=PASS
|
ANDROID_SESSION_DRAFT_V1=PASS
|
||||||
|
|
@ -109,6 +130,8 @@ ANDROID_TRIGGERED_SYNC=PASS
|
||||||
ANDROID_SYNC_RECEIPT=PASS
|
ANDROID_SYNC_RECEIPT=PASS
|
||||||
TUI_SYNC_LOG_SHOW=PASS
|
TUI_SYNC_LOG_SHOW=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
|
MULTI_OCCURRENCE_SESSION_V2=PASS
|
||||||
|
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
### Measured max v1
|
### Measured max v1
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -16,9 +16,9 @@ desktop.
|
||||||
```text
|
```text
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V5=PASS
|
DESKTOP_SCHEMA_V7=PASS
|
||||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V4=PASS
|
ANDROID_LOCAL_DATABASE_V7=PASS
|
||||||
ANDROID_SESSION_DRAFT_V1=PASS
|
ANDROID_SESSION_DRAFT_V1=PASS
|
||||||
EXERCISE_EDIT_V1=PASS
|
EXERCISE_EDIT_V1=PASS
|
||||||
ANDROID_BANNER_PARITY_V1=PASS
|
ANDROID_BANNER_PARITY_V1=PASS
|
||||||
|
|
@ -32,8 +32,10 @@ TRAINLOG_SYNCD=PASS
|
||||||
ANDROID_TRIGGERED_SYNC=PASS
|
ANDROID_TRIGGERED_SYNC=PASS
|
||||||
ANDROID_SYNC_RECEIPT=PASS
|
ANDROID_SYNC_RECEIPT=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
|
MULTI_OCCURRENCE_SESSION_V2=PASS
|
||||||
|
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
||||||
|
|
||||||
DESKTOP_TESTS=22/22 PASS
|
DESKTOP_TESTS=25/25 PASS
|
||||||
ANDROID_BUILD=PASS
|
ANDROID_BUILD=PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -100,6 +102,13 @@ Actual repetition sets are stored independently. Compact input supports:
|
||||||
4..10..4
|
4..10..4
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A session may contain several ordered occurrences of the same catalogue
|
||||||
|
exercise. Each occurrence has a stable `entry_id`, distinct from the stable
|
||||||
|
`exercise_id` of the catalogue item. Equipment selection belongs to that
|
||||||
|
occurrence, as do its actual per-set loads. `external` records an applied or
|
||||||
|
machine-displayed load; `assistance` records assistance and is not interpreted
|
||||||
|
as increasing strength.
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -138,6 +147,10 @@ export or desktop synchronization as completed sessions.
|
||||||
Schema v4 migrates additively from v3, preserving existing capture data. See
|
Schema v4 migrates additively from v3, preserving existing capture data. See
|
||||||
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
||||||
|
|
||||||
|
The current Android schema is v7. Its additive v4 -> v7 chain adds the shared
|
||||||
|
equipment catalogue, per-occurrence equipment links and durable occurrence
|
||||||
|
identities without recreating completed history or the active draft.
|
||||||
|
|
||||||
Exercises can be renamed in place from Android. The `ex_<uuid-v4>` identity is
|
Exercises can be renamed in place from Android. The `ex_<uuid-v4>` identity is
|
||||||
unchanged; completed history, an active draft, and synchronization therefore
|
unchanged; completed history, an active draft, and synchronization therefore
|
||||||
continue to resolve the same logical exercise. Referenced profiles are locked;
|
continue to resolve the same logical exercise. Referenced profiles are locked;
|
||||||
|
|
@ -159,6 +172,16 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
|
||||||
|
|
||||||
## Android-triggered synchronization
|
## Android-triggered synchronization
|
||||||
|
|
||||||
|
Launch the desktop TUI from a built checkout with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
trainlog
|
||||||
|
```
|
||||||
|
|
||||||
|
The usual user command resolves to `build/tui/trainlog` in this checkout. The
|
||||||
|
desktop database is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
|
||||||
|
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
|
||||||
|
|
||||||
Build the desktop first, then install the user service:
|
Build the desktop first, then install the user service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -181,6 +204,9 @@ Sync
|
||||||
|
|
||||||
The request is consumed by `trainlog-syncd`, the shared bidirectional engine
|
The request is consumed by `trainlog-syncd`, the shared bidirectional engine
|
||||||
runs, a receipt is returned to Android, and the PC catalog is applied locally.
|
runs, a receipt is returned to Android, and the PC catalog is applied locally.
|
||||||
|
The active completed-session exchange is V2 and preserves occurrence
|
||||||
|
`entry_id`, per-set weights and equipment associations. Frozen V1 artifacts
|
||||||
|
remain readable as legacy artifacts; they are not silently redefined as V2.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|
@ -246,5 +272,5 @@ BODY_ANALYTICS_V1=PASS
|
||||||
BODY_COMPOSITION_ESTIMATE=PASS
|
BODY_COMPOSITION_ESTIMATE=PASS
|
||||||
BODY_PROPORTION_RATIOS=PASS
|
BODY_PROPORTION_RATIOS=PASS
|
||||||
BODY_SYMMETRY_ANALYTICS=PASS
|
BODY_SYMMETRY_ANALYTICS=PASS
|
||||||
DESKTOP_TESTS=22/22 PASS
|
DESKTOP_TESTS=25/25 PASS
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,14 @@ android {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
/* CONTRACT: this repository-level manifest is the one canonical
|
||||||
|
* equipment source. Android must not fork it into Kotlin constants. */
|
||||||
|
assets.srcDir("../../catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isIncludeAndroidResources = true
|
unitTests.isIncludeAndroidResources = true
|
||||||
unitTests.all {
|
unitTests.all {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.text.Normalizer
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
enum class EquipmentLoadSemantics {
|
||||||
|
EXTERNAL,
|
||||||
|
ASSISTANCE,
|
||||||
|
BODYWEIGHT,
|
||||||
|
CARDIO,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class EquipmentCatalogEntry(
|
||||||
|
val equipmentId: String,
|
||||||
|
val labelName: String,
|
||||||
|
val displayName: String,
|
||||||
|
val aliases: List<String>,
|
||||||
|
val type: String,
|
||||||
|
val loadSemantics: EquipmentLoadSemantics,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ExerciseEquipmentCatalogRelation(
|
||||||
|
val exerciseId: String,
|
||||||
|
val equipmentId: String,
|
||||||
|
val loadSemantics: EquipmentLoadSemantics,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CONTRACT: `catalog/equipment-v1.json` is shared repository data. This
|
||||||
|
* reader deliberately has no fallback list: a missing or malformed manifest
|
||||||
|
* is an explicit deployment error, never a silently divergent Android list.
|
||||||
|
*/
|
||||||
|
object EquipmentCatalog {
|
||||||
|
private const val ASSET_NAME = "equipment-v1.json"
|
||||||
|
|
||||||
|
fun load(context: Context): List<EquipmentCatalogEntry> {
|
||||||
|
val root = context.assets.open(ASSET_NAME).bufferedReader().use {
|
||||||
|
JSONObject(it.readText())
|
||||||
|
}
|
||||||
|
check(root.getString("format") == "trainlog-equipment-catalog")
|
||||||
|
check(root.getInt("version") == 1)
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
return buildList {
|
||||||
|
val entries = root.getJSONArray("equipment")
|
||||||
|
for (index in 0 until entries.length()) {
|
||||||
|
val item = entries.getJSONObject(index)
|
||||||
|
val id = item.getString("id").trim()
|
||||||
|
check(id.isNotEmpty()) { "equipment_id vide" }
|
||||||
|
check(seen.add(id)) { "equipment_id dupliqué: $id" }
|
||||||
|
val aliases = item.getJSONArray("aliases")
|
||||||
|
val parsedAliases = List(aliases.length()) { aliases.getString(it).trim() }
|
||||||
|
check(parsedAliases.all { it.isNotEmpty() }) { "alias vide pour $id" }
|
||||||
|
val labelName = item.getString("label_name").trim()
|
||||||
|
val displayName = item.getString("display_name").trim()
|
||||||
|
check(displayName.isNotEmpty()) { "display_name vide pour $id" }
|
||||||
|
val type = item.getString("type").trim()
|
||||||
|
check(type.isNotEmpty()) { "type vide pour $id" }
|
||||||
|
add(
|
||||||
|
EquipmentCatalogEntry(
|
||||||
|
equipmentId = id,
|
||||||
|
labelName = labelName,
|
||||||
|
displayName = displayName,
|
||||||
|
aliases = parsedAliases,
|
||||||
|
type = type,
|
||||||
|
loadSemantics = EquipmentLoadSemantics.valueOf(
|
||||||
|
item.getString("load_semantics").uppercase(Locale.ROOT),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exerciseEquipmentRelations(
|
||||||
|
context: Context,
|
||||||
|
): List<ExerciseEquipmentCatalogRelation> {
|
||||||
|
val root = context.assets.open(ASSET_NAME).bufferedReader().use {
|
||||||
|
JSONObject(it.readText())
|
||||||
|
}
|
||||||
|
val knownEquipment = load(context).map { it.equipmentId }.toSet()
|
||||||
|
val relations = root.getJSONArray("exercise_equipment")
|
||||||
|
return List(relations.length()) { index ->
|
||||||
|
val item = relations.getJSONObject(index)
|
||||||
|
val equipmentId = item.getString("equipment_id")
|
||||||
|
check(equipmentId in knownEquipment)
|
||||||
|
ExerciseEquipmentCatalogRelation(
|
||||||
|
exerciseId = item.getString("exercise_id"),
|
||||||
|
equipmentId = equipmentId,
|
||||||
|
loadSemantics = EquipmentLoadSemantics.valueOf(
|
||||||
|
item.getString("load_semantics").uppercase(Locale.ROOT),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(
|
||||||
|
entries: List<EquipmentCatalogEntry>,
|
||||||
|
query: String,
|
||||||
|
): List<EquipmentCatalogEntry> {
|
||||||
|
val needle = normalize(query)
|
||||||
|
if (needle.isEmpty()) return entries
|
||||||
|
return entries.filter { entry ->
|
||||||
|
sequenceOf(entry.displayName, entry.labelName)
|
||||||
|
.plus(entry.aliases.asSequence())
|
||||||
|
.any { normalize(it).contains(needle) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalize(value: String): String =
|
||||||
|
Normalizer.normalize(value, Normalizer.Form.NFD)
|
||||||
|
.replace("\\p{M}+".toRegex(), "")
|
||||||
|
.lowercase(Locale.ROOT)
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
@ -137,14 +137,17 @@ class SyncCatalogInbox(
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
is PcCatalogImportResult.Applied ->
|
is PcCatalogImportResult.Applied ->
|
||||||
CatalogInboxResult.Imported(
|
when (val sessions = importPcSessions(directory)) {
|
||||||
imported =
|
null -> when (val equipment = importPcEquipmentAssociations(directory)) {
|
||||||
result.imported,
|
null -> CatalogInboxResult.Imported(
|
||||||
reconciled =
|
imported = result.imported,
|
||||||
result.reconciled,
|
reconciled = result.reconciled,
|
||||||
skipped =
|
skipped = result.skipped,
|
||||||
result.skipped,
|
)
|
||||||
)
|
else -> CatalogInboxResult.Error(equipment)
|
||||||
|
}
|
||||||
|
else -> CatalogInboxResult.Error(sessions)
|
||||||
|
}
|
||||||
|
|
||||||
is PcCatalogImportResult.Invalid ->
|
is PcCatalogImportResult.Invalid ->
|
||||||
CatalogInboxResult.Error(
|
CatalogInboxResult.Error(
|
||||||
|
|
@ -166,6 +169,38 @@ class SyncCatalogInbox(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun importPcSessions(directory: DocumentFile): String? {
|
||||||
|
val file = directory.findFile("trainlog-pc-mobile-export-v2.json") ?: return null
|
||||||
|
return try {
|
||||||
|
val json = appContext.contentResolver.openInputStream(file.uri)
|
||||||
|
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||||
|
?: return "Lecture snapshot séances V2 impossible."
|
||||||
|
when (val result = repository.applyPcMobileExportV2Json(json)) {
|
||||||
|
is MobileSessionImportResult.Applied -> null
|
||||||
|
is MobileSessionImportResult.Invalid -> result.message
|
||||||
|
MobileSessionImportResult.DatabaseError -> "Erreur base locale séances V2."
|
||||||
|
}
|
||||||
|
} catch (error: Exception) { error.message ?: "Import séances V2 impossible." }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importPcEquipmentAssociations(directory: DocumentFile): String? {
|
||||||
|
val file = directory.findFile("trainlog-equipment-associations-v2.json")
|
||||||
|
?: directory.findFile("trainlog-equipment-associations-v1.json")
|
||||||
|
?: return null /* Historic PC sync: absence means no information. */
|
||||||
|
return try {
|
||||||
|
val json = appContext.contentResolver.openInputStream(file.uri)
|
||||||
|
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||||
|
?: return "Lecture extension équipement impossible."
|
||||||
|
when (val result = repository.applyPcEquipmentAssociationsJson(json)) {
|
||||||
|
is EquipmentAssociationImportResult.Applied -> null
|
||||||
|
is EquipmentAssociationImportResult.Invalid -> result.message
|
||||||
|
EquipmentAssociationImportResult.DatabaseError -> "Erreur base locale équipement."
|
||||||
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
error.message ?: "Import extension équipement impossible."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun readSyncReceipt(
|
fun readSyncReceipt(
|
||||||
requestId: String,
|
requestId: String,
|
||||||
): SyncReceiptResult {
|
): SyncReceiptResult {
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,9 @@ class SyncExporter(
|
||||||
return SyncExportResult.Unsupported
|
return SyncExportResult.Unsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
val json =
|
/* V2 is the authoritative mobile session exchange. V1 remains
|
||||||
repository.buildMobileExportJson()
|
* readable by desktop for historic devices but is not published here. */
|
||||||
|
val json = repository.buildMobileExportV2Json()
|
||||||
|
|
||||||
val bytes =
|
val bytes =
|
||||||
json.toByteArray(
|
json.toByteArray(
|
||||||
|
|
@ -62,7 +63,7 @@ class SyncExporter(
|
||||||
"/Trainlog/"
|
"/Trainlog/"
|
||||||
|
|
||||||
val displayName =
|
val displayName =
|
||||||
"trainlog-mobile-export-v1.json"
|
"trainlog-mobile-export-v2.json"
|
||||||
|
|
||||||
val existing =
|
val existing =
|
||||||
findExisting(
|
findExisting(
|
||||||
|
|
@ -170,6 +171,10 @@ class SyncExporter(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val companionError = writeEquipmentAssociations()
|
||||||
|
if (companionError != null) {
|
||||||
|
return SyncExportResult.Error(companionError)
|
||||||
|
}
|
||||||
return SyncExportResult.Exported(
|
return SyncExportResult.Exported(
|
||||||
displayPath =
|
displayPath =
|
||||||
"Download/Trainlog/" +
|
"Download/Trainlog/" +
|
||||||
|
|
@ -195,6 +200,35 @@ class SyncExporter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Publish the companion separately so frozen mobile-export-v1 stays byte-compatible. */
|
||||||
|
private fun writeEquipmentAssociations(): 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(repository.buildEquipmentAssociationsJson().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 findExisting(
|
private fun findExisting(
|
||||||
collection: Uri,
|
collection: Uri,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,25 @@ sealed interface PcCatalogImportResult {
|
||||||
PcCatalogImportResult
|
PcCatalogImportResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed interface EquipmentAssociationImportResult {
|
||||||
|
data class Applied(val updated: Int) : EquipmentAssociationImportResult
|
||||||
|
data class Invalid(val message: String) : EquipmentAssociationImportResult
|
||||||
|
data object DatabaseError : EquipmentAssociationImportResult
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface MobileSessionImportResult {
|
||||||
|
data class Applied(val sessions: Int) : MobileSessionImportResult
|
||||||
|
data class Invalid(val message: String) : MobileSessionImportResult
|
||||||
|
data object DatabaseError : MobileSessionImportResult
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface CreateEquipmentResult {
|
||||||
|
data class Created(val equipment: EquipmentCatalogEntry) : CreateEquipmentResult
|
||||||
|
data object Invalid : CreateEquipmentResult
|
||||||
|
data object Conflict : CreateEquipmentResult
|
||||||
|
data class DatabaseError(val message: String) : CreateEquipmentResult
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface SaveBodyObservationResult {
|
sealed interface SaveBodyObservationResult {
|
||||||
data class Saved(
|
data class Saved(
|
||||||
val observationId: String,
|
val observationId: String,
|
||||||
|
|
@ -131,9 +150,10 @@ class TrainlogRepository(
|
||||||
databaseName: String =
|
databaseName: String =
|
||||||
ANDROID_DATABASE_NAME,
|
ANDROID_DATABASE_NAME,
|
||||||
) {
|
) {
|
||||||
|
private val applicationContext = context.applicationContext
|
||||||
private val database =
|
private val database =
|
||||||
TrainlogDatabaseHelper(
|
TrainlogDatabaseHelper(
|
||||||
context.applicationContext,
|
applicationContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -141,6 +161,61 @@ class TrainlogRepository(
|
||||||
database.close()
|
database.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun listEquipment(): List<EquipmentCatalogEntry> {
|
||||||
|
val output = mutableListOf<EquipmentCatalogEntry>()
|
||||||
|
database.readableDatabase.query(
|
||||||
|
"equipment",
|
||||||
|
arrayOf("equipment_id", "label_name", "display_name", "equipment_type", "load_semantics"),
|
||||||
|
null, null, null, null, "display_name COLLATE NOCASE, equipment_id",
|
||||||
|
).use { cursor ->
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val id = cursor.getString(0)
|
||||||
|
val aliases = mutableListOf<String>()
|
||||||
|
database.readableDatabase.rawQuery(
|
||||||
|
"SELECT alias FROM equipment_aliases ea JOIN equipment e ON e.id = ea.equipment_row_id WHERE e.equipment_id = ? ORDER BY alias COLLATE NOCASE;",
|
||||||
|
arrayOf(id),
|
||||||
|
).use { aliasCursor -> while (aliasCursor.moveToNext()) aliases += aliasCursor.getString(0) }
|
||||||
|
output += EquipmentCatalogEntry(
|
||||||
|
equipmentId = id,
|
||||||
|
labelName = cursor.getString(1),
|
||||||
|
displayName = cursor.getString(2),
|
||||||
|
aliases = aliases,
|
||||||
|
type = cursor.getString(3),
|
||||||
|
loadSemantics = EquipmentLoadSemantics.valueOf(cursor.getString(4).uppercase(Locale.ROOT)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchEquipment(query: String): List<EquipmentCatalogEntry> =
|
||||||
|
EquipmentCatalog.search(listEquipment(), query)
|
||||||
|
|
||||||
|
/** Custom equipment is local user data, not an edit to the bundled manifest. */
|
||||||
|
fun createCustomEquipment(name: String): CreateEquipmentResult {
|
||||||
|
val displayName = name.trim().replace(Regex("\\s+"), " ")
|
||||||
|
if (displayName.isBlank() || displayName.length > 120) return CreateEquipmentResult.Invalid
|
||||||
|
return try {
|
||||||
|
val db = database.writableDatabase
|
||||||
|
val duplicate = db.rawQuery(
|
||||||
|
"SELECT 1 FROM equipment WHERE lower(display_name) = lower(?) LIMIT 1;", arrayOf(displayName),
|
||||||
|
).use { it.moveToFirst() }
|
||||||
|
if (duplicate) return CreateEquipmentResult.Conflict
|
||||||
|
val entry = EquipmentCatalogEntry(
|
||||||
|
equipmentId = "eq_" + UUID.randomUUID(), labelName = "", displayName = displayName,
|
||||||
|
aliases = emptyList(), type = "custom_machine", loadSemantics = EquipmentLoadSemantics.EXTERNAL,
|
||||||
|
)
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put("equipment_id", entry.equipmentId); put("label_name", entry.labelName)
|
||||||
|
put("display_name", entry.displayName); put("equipment_type", entry.type)
|
||||||
|
put("load_semantics", "external")
|
||||||
|
}
|
||||||
|
db.insertOrThrow("equipment", null, values)
|
||||||
|
CreateEquipmentResult.Created(entry)
|
||||||
|
} catch (error: SQLiteConstraintException) { CreateEquipmentResult.Conflict
|
||||||
|
} catch (error: Exception) { CreateEquipmentResult.DatabaseError(error.message ?: "erreur SQLite") }
|
||||||
|
}
|
||||||
|
|
||||||
fun listExercises(): List<ExerciseProfile> {
|
fun listExercises(): List<ExerciseProfile> {
|
||||||
val output =
|
val output =
|
||||||
mutableListOf<ExerciseProfile>()
|
mutableListOf<ExerciseProfile>()
|
||||||
|
|
@ -439,9 +514,16 @@ class TrainlogRepository(
|
||||||
draft.exercises.any {
|
draft.exercises.any {
|
||||||
!validateSessionExercise(it)
|
!validateSessionExercise(it)
|
||||||
} ||
|
} ||
|
||||||
|
/* exercise_id identifies the catalogue movement. Repeated passages
|
||||||
|
* are valid; only their stable occurrence IDs must be unique. */
|
||||||
draft.exercises
|
draft.exercises
|
||||||
.map {
|
.map {
|
||||||
it.exercise.exerciseId
|
it.entryId
|
||||||
|
}
|
||||||
|
.any { it.isBlank() } ||
|
||||||
|
draft.exercises
|
||||||
|
.map {
|
||||||
|
it.entryId
|
||||||
}
|
}
|
||||||
.distinct()
|
.distinct()
|
||||||
.size !=
|
.size !=
|
||||||
|
|
@ -691,6 +773,15 @@ class TrainlogRepository(
|
||||||
exerciseDraft.exercise
|
exerciseDraft.exercise
|
||||||
.dataFields
|
.dataFields
|
||||||
)
|
)
|
||||||
|
put("entry_id", exerciseDraft.entryId)
|
||||||
|
val equipmentRowId = lookupEquipmentRowIdOrNull(
|
||||||
|
db,
|
||||||
|
exerciseDraft.equipmentId,
|
||||||
|
)
|
||||||
|
check(exerciseDraft.equipmentId == null || equipmentRowId != null) {
|
||||||
|
"Unknown equipment: ${exerciseDraft.equipmentId}"
|
||||||
|
}
|
||||||
|
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
|
||||||
}
|
}
|
||||||
|
|
||||||
val sessionExerciseRowId =
|
val sessionExerciseRowId =
|
||||||
|
|
@ -757,6 +848,7 @@ class TrainlogRepository(
|
||||||
"position",
|
"position",
|
||||||
setIndex
|
setIndex
|
||||||
)
|
)
|
||||||
|
set.weightKg?.let { put("weight_kg", it) }
|
||||||
|
|
||||||
if (
|
if (
|
||||||
exerciseDraft.exercise
|
exerciseDraft.exercise
|
||||||
|
|
@ -1198,6 +1290,26 @@ class TrainlogRepository(
|
||||||
if (cursor.moveToFirst()) cursor.getLong(0) else null
|
if (cursor.moveToFirst()) cursor.getLong(0) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun lookupEquipmentRowIdOrNull(
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
equipmentId: String?,
|
||||||
|
): Long? {
|
||||||
|
if (equipmentId == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return db.query(
|
||||||
|
"equipment",
|
||||||
|
arrayOf("id"),
|
||||||
|
"equipment_id = ?",
|
||||||
|
arrayOf(equipmentId),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) cursor.getLong(0) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun exerciseHasReferences(
|
private fun exerciseHasReferences(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
exerciseRowId: Long,
|
exerciseRowId: Long,
|
||||||
|
|
@ -1350,6 +1462,216 @@ class TrainlogRepository(
|
||||||
return root.toString()
|
return root.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 is a new artifact, never a reinterpretation of frozen V1. Each
|
||||||
|
* session occurrence carries its durable Android entry_id, ordering,
|
||||||
|
* equipment and actual load so repeated catalogue exercises round-trip.
|
||||||
|
*/
|
||||||
|
fun buildMobileExportV2Json(): String {
|
||||||
|
val root = JSONObject(buildMobileExportJson())
|
||||||
|
root.put("version", 2)
|
||||||
|
val db = database.readableDatabase
|
||||||
|
val sessions = root.getJSONArray("sessions")
|
||||||
|
for (sessionIndex in 0 until sessions.length()) {
|
||||||
|
val session = sessions.getJSONObject(sessionIndex)
|
||||||
|
val items = session.getJSONArray("exercises")
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT se.id,se.entry_id,se.position,eq.equipment_id FROM session_exercises se " +
|
||||||
|
"JOIN sessions s ON s.id=se.session_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id " +
|
||||||
|
"WHERE s.session_id=? ORDER BY se.position ASC;",
|
||||||
|
arrayOf(session.getString("session_id")),
|
||||||
|
).use { entries ->
|
||||||
|
var itemIndex = 0
|
||||||
|
while (entries.moveToNext()) {
|
||||||
|
val item = items.getJSONObject(itemIndex++)
|
||||||
|
val rowId = entries.getLong(0)
|
||||||
|
item.put("entry_id", entries.getString(1))
|
||||||
|
item.put("position", entries.getInt(2))
|
||||||
|
if (entries.isNull(3)) item.put("equipment_id", JSONObject.NULL)
|
||||||
|
else item.put("equipment_id", entries.getString(3))
|
||||||
|
if (item.has("sets")) {
|
||||||
|
val sets = item.getJSONArray("sets")
|
||||||
|
db.query("performed_sets", arrayOf("weight_kg"),
|
||||||
|
"session_exercise_row_id=?", arrayOf(rowId.toString()), null, null, "position ASC").use { cursor ->
|
||||||
|
var setIndex = 0
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
if (!cursor.isNull(0)) sets.getJSONObject(setIndex).put("weight_kg", cursor.getDouble(0))
|
||||||
|
setIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the same V2 session artifact emitted by desktop, keyed by entry_id. */
|
||||||
|
fun applyPcMobileExportV2Json(json: String): MobileSessionImportResult {
|
||||||
|
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||||
|
return MobileSessionImportResult.Invalid("Snapshot séances JSON invalide.")
|
||||||
|
}
|
||||||
|
if (root.optString("format") != "trainlog-mobile-export" || root.optInt("version", -1) != 2) {
|
||||||
|
return MobileSessionImportResult.Invalid("Snapshot séances V2 non supporté.")
|
||||||
|
}
|
||||||
|
val sessions = root.optJSONArray("sessions") ?: return MobileSessionImportResult.Invalid("Sessions manquantes.")
|
||||||
|
val db = database.writableDatabase
|
||||||
|
return try {
|
||||||
|
db.beginTransaction()
|
||||||
|
for (i in 0 until sessions.length()) {
|
||||||
|
val session = sessions.getJSONObject(i)
|
||||||
|
val sessionId = session.optString("session_id")
|
||||||
|
val startedAt = session.optString("started_at")
|
||||||
|
val type = session.optString("session_type")
|
||||||
|
val entries = session.optJSONArray("exercises")
|
||||||
|
if (sessionId.isBlank() || startedAt.isBlank() || type !in setOf("training", "max_test") || entries == null) {
|
||||||
|
return MobileSessionImportResult.Invalid("Session V2 invalide.")
|
||||||
|
}
|
||||||
|
val rowId = db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf(sessionId)).use {
|
||||||
|
if (it.moveToFirst()) it.getLong(0) else null
|
||||||
|
} ?: run {
|
||||||
|
val values = ContentValues().apply { put("session_id", sessionId); put("started_at", startedAt); put("session_type", type) }
|
||||||
|
db.insertOrThrow("sessions", null, values)
|
||||||
|
}
|
||||||
|
/* V2 replaces only a same stable session; every entry is
|
||||||
|
* checked before insertion and entry_id remains global unique. */
|
||||||
|
db.delete("session_exercises", "session_row_id=?", arrayOf(rowId.toString()))
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
for (index in 0 until entries.length()) {
|
||||||
|
val entry = entries.getJSONObject(index)
|
||||||
|
val entryId = entry.optString("entry_id")
|
||||||
|
val exerciseId = entry.optString("exercise_id")
|
||||||
|
val position = entry.optInt("position", -1)
|
||||||
|
if (entryId.isBlank() || !seen.add(entryId) || exerciseId.isBlank() || position < 0) {
|
||||||
|
return MobileSessionImportResult.Invalid("Identité d'entrée V2 invalide.")
|
||||||
|
}
|
||||||
|
val exerciseRow = findExerciseRow(db, "exercise_id = ?", arrayOf(exerciseId))
|
||||||
|
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
|
||||||
|
val recording = entry.optString("recording_mode")
|
||||||
|
val tracking = entry.optString("tracking_mode")
|
||||||
|
if (recording !in setOf("sets", "continuous") || tracking !in setOf("reps", "duration")) {
|
||||||
|
return MobileSessionImportResult.Invalid("Profil V2 invalide.")
|
||||||
|
}
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put("entry_id", entryId); put("session_row_id", rowId); put("exercise_row_id", exerciseRow.rowId)
|
||||||
|
put("position", position); put("recording_mode", recording); put("tracking_mode", tracking)
|
||||||
|
put("data_fields", entry.optInt("data_fields", 0)); put("equipment_row_id", lookupEquipmentRowIdOrNull(db, entry.optString("equipment_id").ifBlank { null }))
|
||||||
|
}
|
||||||
|
val occurrence = db.insertOrThrow("session_exercises", null, values)
|
||||||
|
if (recording == "continuous") {
|
||||||
|
val c = entry.optJSONObject("continuous") ?: return MobileSessionImportResult.Invalid("Activité continue manquante.")
|
||||||
|
db.insertOrThrow("continuous_activity", null, ContentValues().apply {
|
||||||
|
put("session_exercise_row_id", occurrence); put("duration_seconds", c.optInt("duration_seconds", 0))
|
||||||
|
if (c.has("speed_kmh")) put("speed_kmh", c.getDouble("speed_kmh")); if (c.has("distance_km")) put("distance_km", c.getDouble("distance_km"))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
val sets = entry.optJSONArray("sets") ?: return MobileSessionImportResult.Invalid("Séries manquantes.")
|
||||||
|
for (setIndex in 0 until sets.length()) {
|
||||||
|
val set = sets.getJSONObject(setIndex)
|
||||||
|
db.insertOrThrow("performed_sets", null, ContentValues().apply {
|
||||||
|
put("session_exercise_row_id", occurrence); put("position", setIndex)
|
||||||
|
if (tracking == "reps") put("reps", set.optInt("reps", -1)) else put("duration_seconds", set.optInt("duration_seconds", 0))
|
||||||
|
if (set.has("weight_kg")) put("weight_kg", set.getDouble("weight_kg"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.setTransactionSuccessful()
|
||||||
|
MobileSessionImportResult.Applied(sessions.length())
|
||||||
|
} catch (_: Exception) { MobileSessionImportResult.DatabaseError
|
||||||
|
} finally { db.endTransaction() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CONTRACT: this companion artifact is deliberately outside frozen mobile
|
||||||
|
* export v1. `(session_id, exercise_id)` is stable and unambiguous under
|
||||||
|
* the one-entry-per-exercise-per-session model. `cleared` is intentional
|
||||||
|
* state, unlike an absent v1 artifact which conveys no equipment signal.
|
||||||
|
*/
|
||||||
|
fun buildEquipmentAssociationsJson(): String {
|
||||||
|
val root = JSONObject()
|
||||||
|
.put("format", "trainlog-equipment-associations")
|
||||||
|
.put("version", 2)
|
||||||
|
.put("generated_at", OffsetDateTime.now().toString())
|
||||||
|
val associations = JSONArray()
|
||||||
|
database.readableDatabase.rawQuery(
|
||||||
|
"SELECT s.session_id, se.entry_id, e.exercise_id, eq.equipment_id FROM session_exercises se " +
|
||||||
|
"JOIN sessions s ON s.id = se.session_row_id " +
|
||||||
|
"JOIN exercises e ON e.id = se.exercise_row_id " +
|
||||||
|
"LEFT JOIN equipment eq ON eq.id = se.equipment_row_id " +
|
||||||
|
"ORDER BY s.started_at ASC, s.id ASC, se.position ASC;",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val item = JSONObject()
|
||||||
|
.put("session_id", cursor.getString(0))
|
||||||
|
.put("entry_id", cursor.getString(1))
|
||||||
|
.put("exercise_id", cursor.getString(2))
|
||||||
|
if (cursor.isNull(3)) item.put("state", "cleared") else {
|
||||||
|
item.put("state", "set")
|
||||||
|
item.put("equipment_id", cursor.getString(3))
|
||||||
|
}
|
||||||
|
associations.put(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root.put("associations", associations).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyPcEquipmentAssociationsJson(json: String): EquipmentAssociationImportResult {
|
||||||
|
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||||
|
return EquipmentAssociationImportResult.Invalid("Extension équipement JSON invalide.")
|
||||||
|
}
|
||||||
|
val version = root.optInt("version", -1)
|
||||||
|
if (root.optString("format") != "trainlog-equipment-associations" || version !in setOf(1, 2)) {
|
||||||
|
return EquipmentAssociationImportResult.Invalid("Extension équipement non supportée.")
|
||||||
|
}
|
||||||
|
val items = root.optJSONArray("associations")
|
||||||
|
?: return EquipmentAssociationImportResult.Invalid("Associations équipement manquantes.")
|
||||||
|
val known = listEquipment().map { it.equipmentId }.toSet()
|
||||||
|
val db = database.writableDatabase
|
||||||
|
var updated = 0
|
||||||
|
db.beginTransaction()
|
||||||
|
try {
|
||||||
|
for (index in 0 until items.length()) {
|
||||||
|
val item = items.getJSONObject(index)
|
||||||
|
val sessionId = item.optString("session_id")
|
||||||
|
val exerciseId = item.optString("exercise_id")
|
||||||
|
val entryId = if (version == 2) item.optString("entry_id") else null
|
||||||
|
val state = item.optString("state")
|
||||||
|
if (sessionId.isBlank() || exerciseId.isBlank() || state !in setOf("set", "cleared") || (version == 2 && entryId.isNullOrBlank())) {
|
||||||
|
return EquipmentAssociationImportResult.Invalid("Association équipement invalide.")
|
||||||
|
}
|
||||||
|
val equipmentId = if (state == "set") item.optString("equipment_id") else null
|
||||||
|
if (state == "set" && (equipmentId.isNullOrBlank() || equipmentId !in known)) {
|
||||||
|
return EquipmentAssociationImportResult.Invalid("Équipement inconnu : $equipmentId")
|
||||||
|
}
|
||||||
|
val row = db.rawQuery(
|
||||||
|
if (version == 2) {
|
||||||
|
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=? AND se.entry_id=?;"
|
||||||
|
} else {
|
||||||
|
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND e.exercise_id=?;"
|
||||||
|
},
|
||||||
|
if (version == 2) arrayOf(sessionId, entryId) else arrayOf(sessionId, exerciseId),
|
||||||
|
).use { cursor ->
|
||||||
|
val first = if (cursor.moveToFirst()) cursor.getLong(0) else null
|
||||||
|
if (version == 1 && first != null && cursor.moveToNext()) null else first
|
||||||
|
}
|
||||||
|
?: return EquipmentAssociationImportResult.Invalid("Entrée de séance inconnue : $sessionId/$exerciseId")
|
||||||
|
val values = ContentValues()
|
||||||
|
if (equipmentId == null) values.putNull("equipment_row_id") else {
|
||||||
|
val equipmentRowId = lookupEquipmentRowIdOrNull(db, equipmentId)!!
|
||||||
|
values.put("equipment_row_id", equipmentRowId)
|
||||||
|
}
|
||||||
|
updated += db.update("session_exercises", values, "id = ?", arrayOf(row.toString()))
|
||||||
|
}
|
||||||
|
db.setTransactionSuccessful()
|
||||||
|
return EquipmentAssociationImportResult.Applied(updated)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return EquipmentAssociationImportResult.DatabaseError
|
||||||
|
} finally { db.endTransaction() }
|
||||||
|
}
|
||||||
|
|
||||||
fun saveBodyObservation(
|
fun saveBodyObservation(
|
||||||
draft: BodyObservationDraft,
|
draft: BodyObservationDraft,
|
||||||
): SaveBodyObservationResult {
|
): SaveBodyObservationResult {
|
||||||
|
|
@ -1587,15 +1909,20 @@ class TrainlogRepository(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
se.id,
|
se.id,
|
||||||
|
se.entry_id,
|
||||||
|
e.exercise_id,
|
||||||
e.name,
|
e.name,
|
||||||
se.recording_mode,
|
se.recording_mode,
|
||||||
se.tracking_mode,
|
se.tracking_mode,
|
||||||
se.data_fields
|
se.data_fields,
|
||||||
|
eq.display_name
|
||||||
FROM session_exercises AS se
|
FROM session_exercises AS se
|
||||||
JOIN sessions AS s
|
JOIN sessions AS s
|
||||||
ON s.id = se.session_row_id
|
ON s.id = se.session_row_id
|
||||||
JOIN exercises AS e
|
JOIN exercises AS e
|
||||||
ON e.id = se.exercise_row_id
|
ON e.id = se.exercise_row_id
|
||||||
|
LEFT JOIN equipment AS eq
|
||||||
|
ON eq.id = se.equipment_row_id
|
||||||
WHERE s.session_id = ?
|
WHERE s.session_id = ?
|
||||||
ORDER BY se.position ASC;
|
ORDER BY se.position ASC;
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
|
|
@ -1605,12 +1932,13 @@ class TrainlogRepository(
|
||||||
val sessionExerciseRowId =
|
val sessionExerciseRowId =
|
||||||
cursor.getLong(0)
|
cursor.getLong(0)
|
||||||
|
|
||||||
val name =
|
val entryId = cursor.getString(1)
|
||||||
cursor.getString(1)
|
val exerciseId = cursor.getString(2)
|
||||||
|
val name = cursor.getString(3)
|
||||||
|
|
||||||
val recording =
|
val recording =
|
||||||
when (
|
when (
|
||||||
cursor.getString(2)
|
cursor.getString(4)
|
||||||
) {
|
) {
|
||||||
"continuous" ->
|
"continuous" ->
|
||||||
RecordingMode.CONTINUOUS
|
RecordingMode.CONTINUOUS
|
||||||
|
|
@ -1621,7 +1949,7 @@ class TrainlogRepository(
|
||||||
|
|
||||||
val tracking =
|
val tracking =
|
||||||
when (
|
when (
|
||||||
cursor.getString(3)
|
cursor.getString(5)
|
||||||
) {
|
) {
|
||||||
"duration" ->
|
"duration" ->
|
||||||
TrackingMode.DURATION
|
TrackingMode.DURATION
|
||||||
|
|
@ -1631,7 +1959,8 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
|
|
||||||
val dataFields =
|
val dataFields =
|
||||||
cursor.getInt(4)
|
cursor.getInt(6)
|
||||||
|
val equipmentDisplayName = if (cursor.isNull(7)) null else cursor.getString(7)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
recording ==
|
recording ==
|
||||||
|
|
@ -1666,8 +1995,11 @@ class TrainlogRepository(
|
||||||
|
|
||||||
exercises +=
|
exercises +=
|
||||||
SessionExerciseDetail(
|
SessionExerciseDetail(
|
||||||
|
entryId = entryId,
|
||||||
|
exerciseId = exerciseId,
|
||||||
exerciseName =
|
exerciseName =
|
||||||
name,
|
name,
|
||||||
|
equipmentDisplayName = equipmentDisplayName,
|
||||||
recordingMode =
|
recordingMode =
|
||||||
recording,
|
recording,
|
||||||
trackingMode =
|
trackingMode =
|
||||||
|
|
@ -1710,6 +2042,7 @@ class TrainlogRepository(
|
||||||
arrayOf(
|
arrayOf(
|
||||||
"reps",
|
"reps",
|
||||||
"duration_seconds",
|
"duration_seconds",
|
||||||
|
"weight_kg",
|
||||||
),
|
),
|
||||||
"session_exercise_row_id = ?",
|
"session_exercise_row_id = ?",
|
||||||
arrayOf(
|
arrayOf(
|
||||||
|
|
@ -1746,14 +2079,18 @@ class TrainlogRepository(
|
||||||
setCursor
|
setCursor
|
||||||
.getInt(1)
|
.getInt(1)
|
||||||
},
|
},
|
||||||
|
weightKg = if (setCursor.isNull(2)) null else setCursor.getDouble(2),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exercises +=
|
exercises +=
|
||||||
SessionExerciseDetail(
|
SessionExerciseDetail(
|
||||||
|
entryId = entryId,
|
||||||
|
exerciseId = exerciseId,
|
||||||
exerciseName =
|
exerciseName =
|
||||||
name,
|
name,
|
||||||
|
equipmentDisplayName = equipmentDisplayName,
|
||||||
recordingMode =
|
recordingMode =
|
||||||
recording,
|
recording,
|
||||||
trackingMode =
|
trackingMode =
|
||||||
|
|
@ -1772,6 +2109,26 @@ class TrainlogRepository(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setCompletedSessionEquipment(
|
||||||
|
sessionId: String,
|
||||||
|
entryId: String,
|
||||||
|
equipmentId: String?,
|
||||||
|
): Boolean {
|
||||||
|
val db = database.writableDatabase
|
||||||
|
val equipmentRowId = lookupEquipmentRowIdOrNull(db, equipmentId)
|
||||||
|
if (equipmentId != null && equipmentRowId == null) return false
|
||||||
|
return try {
|
||||||
|
val values = ContentValues()
|
||||||
|
if (equipmentRowId == null) values.putNull("equipment_row_id") else values.put("equipment_row_id", equipmentRowId)
|
||||||
|
db.update(
|
||||||
|
"session_exercises", values,
|
||||||
|
"id = (SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id " +
|
||||||
|
"WHERE s.session_id=? AND se.entry_id=?)",
|
||||||
|
arrayOf(sessionId, entryId),
|
||||||
|
) == 1
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadActiveSessionDraft(
|
private fun loadActiveSessionDraft(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
): ActiveDraftRestore? {
|
): ActiveDraftRestore? {
|
||||||
|
|
@ -1787,6 +2144,7 @@ class TrainlogRepository(
|
||||||
d.distance_text,
|
d.distance_text,
|
||||||
d.updated_at,
|
d.updated_at,
|
||||||
d.selected_exercise_label,
|
d.selected_exercise_label,
|
||||||
|
d.selected_equipment_id,
|
||||||
e.exercise_id,
|
e.exercise_id,
|
||||||
e.name,
|
e.name,
|
||||||
e.normalized_name,
|
e.normalized_name,
|
||||||
|
|
@ -1804,15 +2162,15 @@ class TrainlogRepository(
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
val missingSelection =
|
val missingSelection =
|
||||||
cursor.isNull(8) &&
|
cursor.isNull(9) &&
|
||||||
!cursor.isNull(7)
|
!cursor.isNull(7)
|
||||||
val selected =
|
val selected =
|
||||||
if (cursor.isNull(8)) {
|
if (cursor.isNull(9)) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
exerciseProfileFromCursor(
|
exerciseProfileFromCursor(
|
||||||
cursor,
|
cursor,
|
||||||
8,
|
9,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1823,6 +2181,7 @@ class TrainlogRepository(
|
||||||
),
|
),
|
||||||
form = SessionDraftForm(
|
form = SessionDraftForm(
|
||||||
selectedExercise = selected,
|
selectedExercise = selected,
|
||||||
|
selectedEquipmentId = if (cursor.isNull(8)) null else cursor.getString(8),
|
||||||
setCountText = cursor.getString(1),
|
setCountText = cursor.getString(1),
|
||||||
repsText = cursor.getString(2),
|
repsText = cursor.getString(2),
|
||||||
durationText = cursor.getString(3),
|
durationText = cursor.getString(3),
|
||||||
|
|
@ -1856,10 +2215,14 @@ class TrainlogRepository(
|
||||||
e.normalized_name,
|
e.normalized_name,
|
||||||
de.recording_mode,
|
de.recording_mode,
|
||||||
de.tracking_mode,
|
de.tracking_mode,
|
||||||
de.data_fields
|
de.data_fields,
|
||||||
|
eq.equipment_id,
|
||||||
|
de.entry_id
|
||||||
FROM draft_session_exercises AS de
|
FROM draft_session_exercises AS de
|
||||||
JOIN exercises AS e
|
JOIN exercises AS e
|
||||||
ON e.id = de.exercise_row_id
|
ON e.id = de.exercise_row_id
|
||||||
|
LEFT JOIN equipment AS eq
|
||||||
|
ON eq.id = de.equipment_row_id
|
||||||
WHERE de.draft_id = ?
|
WHERE de.draft_id = ?
|
||||||
ORDER BY de.position ASC;
|
ORDER BY de.position ASC;
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
|
|
@ -1882,9 +2245,11 @@ class TrainlogRepository(
|
||||||
trackingModeFromWire(
|
trackingModeFromWire(
|
||||||
cursor.getString(5)
|
cursor.getString(5)
|
||||||
),
|
),
|
||||||
dataFields =
|
dataFields =
|
||||||
cursor.getInt(6),
|
cursor.getInt(6),
|
||||||
)
|
)
|
||||||
|
val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7)
|
||||||
|
val entryId = cursor.getString(8)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
exercise.recordingMode ==
|
exercise.recordingMode ==
|
||||||
|
|
@ -1909,7 +2274,9 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
|
|
||||||
SessionExerciseDraft(
|
SessionExerciseDraft(
|
||||||
|
entryId = entryId,
|
||||||
exercise = exercise,
|
exercise = exercise,
|
||||||
|
equipmentId = equipmentId,
|
||||||
continuousDurationSeconds =
|
continuousDurationSeconds =
|
||||||
item.getInt(0),
|
item.getInt(0),
|
||||||
speedKmh =
|
speedKmh =
|
||||||
|
|
@ -1935,6 +2302,7 @@ class TrainlogRepository(
|
||||||
arrayOf(
|
arrayOf(
|
||||||
"reps",
|
"reps",
|
||||||
"duration_seconds",
|
"duration_seconds",
|
||||||
|
"weight_kg",
|
||||||
),
|
),
|
||||||
"draft_exercise_row_id = ?",
|
"draft_exercise_row_id = ?",
|
||||||
arrayOf(rowId.toString()),
|
arrayOf(rowId.toString()),
|
||||||
|
|
@ -1957,12 +2325,15 @@ class TrainlogRepository(
|
||||||
} else {
|
} else {
|
||||||
setCursor.getInt(1)
|
setCursor.getInt(1)
|
||||||
},
|
},
|
||||||
|
weightKg = if (setCursor.isNull(2)) null else setCursor.getDouble(2),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
exercises +=
|
exercises +=
|
||||||
SessionExerciseDraft(
|
SessionExerciseDraft(
|
||||||
|
entryId = entryId,
|
||||||
exercise = exercise,
|
exercise = exercise,
|
||||||
|
equipmentId = equipmentId,
|
||||||
sets = sets,
|
sets = sets,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1996,6 +2367,7 @@ class TrainlogRepository(
|
||||||
val values =
|
val values =
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put("session_type", draft.sessionType.wireValue)
|
put("session_type", draft.sessionType.wireValue)
|
||||||
|
putOptionalString("selected_equipment_id", draft.form.selectedEquipmentId)
|
||||||
if (selectedRowId == null) {
|
if (selectedRowId == null) {
|
||||||
putNull("selected_exercise_row_id")
|
putNull("selected_exercise_row_id")
|
||||||
putNull("selected_exercise_label")
|
putNull("selected_exercise_label")
|
||||||
|
|
@ -2064,6 +2436,15 @@ class TrainlogRepository(
|
||||||
"data_fields",
|
"data_fields",
|
||||||
exerciseDraft.exercise.dataFields,
|
exerciseDraft.exercise.dataFields,
|
||||||
)
|
)
|
||||||
|
put("entry_id", exerciseDraft.entryId)
|
||||||
|
val equipmentRowId = lookupEquipmentRowIdOrNull(
|
||||||
|
db,
|
||||||
|
exerciseDraft.equipmentId,
|
||||||
|
)
|
||||||
|
check(exerciseDraft.equipmentId == null || equipmentRowId != null) {
|
||||||
|
"Unknown equipment: ${exerciseDraft.equipmentId}"
|
||||||
|
}
|
||||||
|
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
|
||||||
}
|
}
|
||||||
val draftExerciseRowId =
|
val draftExerciseRowId =
|
||||||
db.insertOrThrow(
|
db.insertOrThrow(
|
||||||
|
|
@ -2109,6 +2490,7 @@ class TrainlogRepository(
|
||||||
draftExerciseRowId,
|
draftExerciseRowId,
|
||||||
)
|
)
|
||||||
put("position", setIndex)
|
put("position", setIndex)
|
||||||
|
set.weightKg?.let { put("weight_kg", it) }
|
||||||
if (
|
if (
|
||||||
exerciseDraft.exercise.trackingMode ==
|
exerciseDraft.exercise.trackingMode ==
|
||||||
TrackingMode.REPS
|
TrackingMode.REPS
|
||||||
|
|
@ -2175,6 +2557,15 @@ class TrainlogRepository(
|
||||||
exerciseDraft.exercise.trackingMode.wireValue,
|
exerciseDraft.exercise.trackingMode.wireValue,
|
||||||
)
|
)
|
||||||
put("data_fields", exerciseDraft.exercise.dataFields)
|
put("data_fields", exerciseDraft.exercise.dataFields)
|
||||||
|
put("entry_id", exerciseDraft.entryId)
|
||||||
|
val equipmentRowId = lookupEquipmentRowIdOrNull(
|
||||||
|
db,
|
||||||
|
exerciseDraft.equipmentId,
|
||||||
|
)
|
||||||
|
check(exerciseDraft.equipmentId == null || equipmentRowId != null) {
|
||||||
|
"Unknown equipment: ${exerciseDraft.equipmentId}"
|
||||||
|
}
|
||||||
|
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
|
||||||
}
|
}
|
||||||
val sessionExerciseRowId =
|
val sessionExerciseRowId =
|
||||||
db.insertOrThrow(
|
db.insertOrThrow(
|
||||||
|
|
@ -2210,6 +2601,7 @@ class TrainlogRepository(
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put("session_exercise_row_id", sessionExerciseRowId)
|
put("session_exercise_row_id", sessionExerciseRowId)
|
||||||
put("position", setIndex)
|
put("position", setIndex)
|
||||||
|
set.weightKg?.let { put("weight_kg", it) }
|
||||||
if (
|
if (
|
||||||
exerciseDraft.exercise.trackingMode ==
|
exerciseDraft.exercise.trackingMode ==
|
||||||
TrackingMode.REPS
|
TrackingMode.REPS
|
||||||
|
|
@ -2407,19 +2799,32 @@ private fun ContentValues.putOptionalDouble(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ContentValues.putOptionalString(
|
||||||
|
key: String,
|
||||||
|
value: String?,
|
||||||
|
) {
|
||||||
|
if (value == null) putNull(key) else put(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun equipmentAliasNormalize(value: String): String =
|
||||||
|
Normalizer.normalize(value, Normalizer.Form.NFD)
|
||||||
|
.replace("\\p{M}+".toRegex(), "")
|
||||||
|
.lowercase(Locale.ROOT)
|
||||||
|
.trim()
|
||||||
|
|
||||||
private const val ANDROID_DATABASE_NAME =
|
private const val ANDROID_DATABASE_NAME =
|
||||||
"trainlog-android.db"
|
"trainlog-android.db"
|
||||||
private const val ACTIVE_DRAFT_ID = 1
|
private const val ACTIVE_DRAFT_ID = 1
|
||||||
private const val MAX_DRAFT_FORM_TEXT_LENGTH = 4096
|
private const val MAX_DRAFT_FORM_TEXT_LENGTH = 4096
|
||||||
|
|
||||||
private class TrainlogDatabaseHelper(
|
private class TrainlogDatabaseHelper(
|
||||||
context: Context,
|
private val appContext: Context,
|
||||||
databaseName: String,
|
databaseName: String,
|
||||||
) : SQLiteOpenHelper(
|
) : SQLiteOpenHelper(
|
||||||
context,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
4,
|
7,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -2438,6 +2843,8 @@ private class TrainlogDatabaseHelper(
|
||||||
createSessionTables(db)
|
createSessionTables(db)
|
||||||
createBodyTable(db)
|
createBodyTable(db)
|
||||||
createActiveDraftTables(db)
|
createActiveDraftTables(db)
|
||||||
|
createEquipmentTables(db)
|
||||||
|
seedEquipment(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUpgrade(
|
override fun onUpgrade(
|
||||||
|
|
@ -2464,6 +2871,41 @@ private class TrainlogDatabaseHelper(
|
||||||
version = 4
|
version = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 5 && newVersion >= 5) {
|
||||||
|
/* CONTRACT: v5 adds only canonical equipment metadata. Existing
|
||||||
|
* exercises, historical rows, and drafts are never rewritten. */
|
||||||
|
createEquipmentTables(db)
|
||||||
|
seedEquipment(db)
|
||||||
|
addEquipmentReferenceColumns(db)
|
||||||
|
version = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version < 6 && newVersion >= 6) {
|
||||||
|
/* v6 keeps historic sets intact while allowing optional per-set load. */
|
||||||
|
db.execSQL("ALTER TABLE performed_sets ADD COLUMN weight_kg REAL CHECK(weight_kg >= 0.0);")
|
||||||
|
db.execSQL("ALTER TABLE draft_performed_sets ADD COLUMN weight_kg REAL CHECK(weight_kg >= 0.0);")
|
||||||
|
version = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version < 7 && newVersion >= 7) {
|
||||||
|
/* WHY: exercise_id is a catalogue identity, not an occurrence identity.
|
||||||
|
* Historic rows get the deterministic same cross-device legacy key. */
|
||||||
|
db.execSQL("ALTER TABLE session_exercises ADD COLUMN entry_id TEXT;")
|
||||||
|
db.execSQL("UPDATE session_exercises SET entry_id = 'sxe_legacy_' || (SELECT session_id FROM sessions WHERE sessions.id = session_exercises.session_row_id) || '_' || (SELECT exercise_id FROM exercises WHERE exercises.id = session_exercises.exercise_row_id);")
|
||||||
|
db.execSQL("CREATE UNIQUE INDEX session_exercises_entry_id_v7 ON session_exercises(entry_id);")
|
||||||
|
db.execSQL("ALTER TABLE draft_performed_sets RENAME TO draft_performed_sets_v6;")
|
||||||
|
db.execSQL("ALTER TABLE draft_continuous_activity RENAME TO draft_continuous_activity_v6;")
|
||||||
|
db.execSQL("ALTER TABLE draft_session_exercises RENAME TO draft_session_exercises_v6;")
|
||||||
|
createActiveDraftTables(db)
|
||||||
|
db.execSQL("INSERT INTO draft_session_exercises(id,draft_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,equipment_row_id,entry_id) SELECT id,draft_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,equipment_row_id,'sxe_draft_legacy_' || id FROM draft_session_exercises_v6;")
|
||||||
|
db.execSQL("INSERT INTO draft_performed_sets(id,draft_exercise_row_id,position,reps,duration_seconds,weight_kg) SELECT id,draft_exercise_row_id,position,reps,duration_seconds,weight_kg FROM draft_performed_sets_v6;")
|
||||||
|
db.execSQL("INSERT INTO draft_continuous_activity(id,draft_exercise_row_id,duration_seconds,speed_kmh,distance_km) SELECT id,draft_exercise_row_id,duration_seconds,speed_kmh,distance_km FROM draft_continuous_activity_v6;")
|
||||||
|
db.execSQL("DROP TABLE draft_performed_sets_v6;")
|
||||||
|
db.execSQL("DROP TABLE draft_continuous_activity_v6;")
|
||||||
|
db.execSQL("DROP TABLE draft_session_exercises_v6;")
|
||||||
|
version = 7
|
||||||
|
}
|
||||||
|
|
||||||
if (version != newVersion) {
|
if (version != newVersion) {
|
||||||
error(
|
error(
|
||||||
"Unsupported Android DB upgrade " +
|
"Unsupported Android DB upgrade " +
|
||||||
|
|
@ -2569,6 +3011,10 @@ private class TrainlogDatabaseHelper(
|
||||||
data_fields >= 0 AND
|
data_fields >= 0 AND
|
||||||
(data_fields & ~3) = 0
|
(data_fields & ~3) = 0
|
||||||
),
|
),
|
||||||
|
equipment_row_id INTEGER
|
||||||
|
REFERENCES equipment(id)
|
||||||
|
ON DELETE SET NULL,
|
||||||
|
entry_id TEXT NOT NULL UNIQUE,
|
||||||
UNIQUE(
|
UNIQUE(
|
||||||
session_row_id,
|
session_row_id,
|
||||||
position
|
position
|
||||||
|
|
@ -2590,6 +3036,8 @@ private class TrainlogDatabaseHelper(
|
||||||
CHECK(reps >= 0),
|
CHECK(reps >= 0),
|
||||||
duration_seconds INTEGER
|
duration_seconds INTEGER
|
||||||
CHECK(duration_seconds > 0),
|
CHECK(duration_seconds > 0),
|
||||||
|
weight_kg REAL
|
||||||
|
CHECK(weight_kg >= 0.0),
|
||||||
CHECK(
|
CHECK(
|
||||||
(
|
(
|
||||||
reps IS NOT NULL AND
|
reps IS NOT NULL AND
|
||||||
|
|
@ -2705,6 +3153,7 @@ private class TrainlogDatabaseHelper(
|
||||||
REFERENCES exercises(id)
|
REFERENCES exercises(id)
|
||||||
ON DELETE SET NULL,
|
ON DELETE SET NULL,
|
||||||
selected_exercise_label TEXT,
|
selected_exercise_label TEXT,
|
||||||
|
selected_equipment_id TEXT,
|
||||||
set_count_text TEXT NOT NULL,
|
set_count_text TEXT NOT NULL,
|
||||||
reps_text TEXT NOT NULL,
|
reps_text TEXT NOT NULL,
|
||||||
duration_text TEXT NOT NULL,
|
duration_text TEXT NOT NULL,
|
||||||
|
|
@ -2746,8 +3195,11 @@ private class TrainlogDatabaseHelper(
|
||||||
data_fields >= 0 AND
|
data_fields >= 0 AND
|
||||||
(data_fields & ~3) = 0
|
(data_fields & ~3) = 0
|
||||||
),
|
),
|
||||||
UNIQUE(draft_id, position),
|
equipment_row_id INTEGER
|
||||||
UNIQUE(draft_id, exercise_row_id)
|
REFERENCES equipment(id)
|
||||||
|
ON DELETE SET NULL,
|
||||||
|
entry_id TEXT NOT NULL UNIQUE,
|
||||||
|
UNIQUE(draft_id, position)
|
||||||
);
|
);
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
|
|
@ -2768,6 +3220,8 @@ private class TrainlogDatabaseHelper(
|
||||||
CHECK(reps >= 0),
|
CHECK(reps >= 0),
|
||||||
duration_seconds INTEGER
|
duration_seconds INTEGER
|
||||||
CHECK(duration_seconds > 0),
|
CHECK(duration_seconds > 0),
|
||||||
|
weight_kg REAL
|
||||||
|
CHECK(weight_kg >= 0.0),
|
||||||
CHECK(
|
CHECK(
|
||||||
(
|
(
|
||||||
reps IS NOT NULL AND
|
reps IS NOT NULL AND
|
||||||
|
|
@ -2799,4 +3253,137 @@ private class TrainlogDatabaseHelper(
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createEquipmentTables(
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS equipment(
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
equipment_id TEXT NOT NULL UNIQUE,
|
||||||
|
label_name TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
equipment_type TEXT NOT NULL,
|
||||||
|
load_semantics TEXT NOT NULL
|
||||||
|
CHECK(load_semantics IN ('external', 'assistance', 'bodyweight', 'cardio'))
|
||||||
|
);
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS equipment_aliases(
|
||||||
|
equipment_row_id INTEGER NOT NULL REFERENCES equipment(id) ON DELETE CASCADE,
|
||||||
|
alias TEXT NOT NULL,
|
||||||
|
normalized_alias TEXT NOT NULL,
|
||||||
|
UNIQUE(equipment_row_id, normalized_alias)
|
||||||
|
);
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS exercise_equipment(
|
||||||
|
exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,
|
||||||
|
equipment_row_id INTEGER NOT NULL REFERENCES equipment(id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE(exercise_row_id, equipment_row_id)
|
||||||
|
);
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
/* WHY: these are logical catalogue capabilities, including exercises
|
||||||
|
* which need not yet exist in a user's mutable exercise catalogue. */
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_exercise_equipment(
|
||||||
|
exercise_id TEXT NOT NULL,
|
||||||
|
equipment_row_id INTEGER NOT NULL
|
||||||
|
REFERENCES equipment(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
load_semantics TEXT NOT NULL
|
||||||
|
CHECK(load_semantics IN ('external', 'assistance', 'bodyweight', 'cardio')),
|
||||||
|
PRIMARY KEY(exercise_id, equipment_row_id)
|
||||||
|
);
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addEquipmentReferenceColumns(
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
) {
|
||||||
|
/* CONTRACT: v5 retains all historic/draft rows unchanged; equipment
|
||||||
|
* is optional because it was not captured before this version. */
|
||||||
|
db.execSQL(
|
||||||
|
"ALTER TABLE session_exercises ADD COLUMN equipment_row_id INTEGER " +
|
||||||
|
"REFERENCES equipment(id) ON DELETE SET NULL;",
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"ALTER TABLE draft_session_exercises ADD COLUMN equipment_row_id INTEGER " +
|
||||||
|
"REFERENCES equipment(id) ON DELETE SET NULL;",
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"ALTER TABLE active_session_draft ADD COLUMN selected_equipment_id TEXT;",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seedEquipment(
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
) {
|
||||||
|
/* INVARIANT: INSERT OR IGNORE makes application startup idempotent;
|
||||||
|
* canonical IDs, rather than display text, are the persistent keys. */
|
||||||
|
EquipmentCatalog.load(appContext).forEach { entry ->
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO equipment(
|
||||||
|
equipment_id, label_name, display_name, equipment_type, load_semantics
|
||||||
|
) VALUES(?, ?, ?, ?, ?);
|
||||||
|
""".trimIndent(),
|
||||||
|
arrayOf(
|
||||||
|
entry.equipmentId,
|
||||||
|
entry.labelName,
|
||||||
|
entry.displayName,
|
||||||
|
entry.type,
|
||||||
|
entry.loadSemantics.name.lowercase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT id FROM equipment WHERE equipment_id = ?;",
|
||||||
|
arrayOf(entry.equipmentId),
|
||||||
|
).use { cursor ->
|
||||||
|
check(cursor.moveToFirst())
|
||||||
|
val rowId = cursor.getLong(0)
|
||||||
|
entry.aliases.forEach { alias ->
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO equipment_aliases(
|
||||||
|
equipment_row_id, alias, normalized_alias
|
||||||
|
) VALUES(?, ?, ?);
|
||||||
|
""".trimIndent(),
|
||||||
|
arrayOf<Any>(
|
||||||
|
rowId,
|
||||||
|
alias,
|
||||||
|
equipmentAliasNormalize(alias),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EquipmentCatalog.exerciseEquipmentRelations(appContext).forEach { relation ->
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT id FROM equipment WHERE equipment_id = ?;",
|
||||||
|
arrayOf(relation.equipmentId),
|
||||||
|
).use { cursor ->
|
||||||
|
check(cursor.moveToFirst()) {
|
||||||
|
"Seeded equipment missing: ${relation.equipmentId}"
|
||||||
|
}
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT OR IGNORE INTO catalog_exercise_equipment(" +
|
||||||
|
"exercise_id, equipment_row_id, load_semantics) VALUES(?, ?, ?);",
|
||||||
|
arrayOf<Any>(
|
||||||
|
relation.exerciseId,
|
||||||
|
cursor.getLong(0),
|
||||||
|
relation.loadSemantics.name.lowercase(Locale.ROOT),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,15 @@ enum class SessionType(
|
||||||
data class SessionSetDraft(
|
data class SessionSetDraft(
|
||||||
val reps: Int = 0,
|
val reps: Int = 0,
|
||||||
val durationSeconds: Int = 0,
|
val durationSeconds: Int = 0,
|
||||||
|
val weightKg: Double? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SessionExerciseDraft(
|
data class SessionExerciseDraft(
|
||||||
|
/** Stable occurrence identity; exercise_id identifies only the catalogue movement. */
|
||||||
|
val entryId: String = "sxe_" + java.util.UUID.randomUUID().toString(),
|
||||||
val exercise: ExerciseProfile,
|
val exercise: ExerciseProfile,
|
||||||
|
/** Stable canonical equipment ID selected for this occurrence, if any. */
|
||||||
|
val equipmentId: String? = null,
|
||||||
val sets: List<SessionSetDraft> = emptyList(),
|
val sets: List<SessionSetDraft> = emptyList(),
|
||||||
val continuousDurationSeconds: Int = 0,
|
val continuousDurationSeconds: Int = 0,
|
||||||
val speedKmh: Double? = null,
|
val speedKmh: Double? = null,
|
||||||
|
|
@ -38,8 +43,13 @@ data class SessionDraft(
|
||||||
|
|
||||||
data class SessionDraftForm(
|
data class SessionDraftForm(
|
||||||
val selectedExercise: ExerciseProfile? = null,
|
val selectedExercise: ExerciseProfile? = null,
|
||||||
|
/** Null means creation; otherwise replace this durable draft entry. */
|
||||||
|
val editingExerciseIndex: Int? = null,
|
||||||
|
val editingEntryId: String? = null,
|
||||||
|
val selectedEquipmentId: String? = null,
|
||||||
val setCountText: String = "3",
|
val setCountText: String = "3",
|
||||||
val repsText: String = "3x10",
|
val repsText: String = "3x10",
|
||||||
|
val weightText: String = "",
|
||||||
val durationText: String = "30",
|
val durationText: String = "30",
|
||||||
val speedText: String = "",
|
val speedText: String = "",
|
||||||
val distanceText: String = "",
|
val distanceText: String = "",
|
||||||
|
|
@ -65,7 +75,11 @@ data class SessionSummary(
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SessionExerciseDetail(
|
data class SessionExerciseDetail(
|
||||||
|
/** Stable completed-session occurrence identity, never catalogue identity. */
|
||||||
|
val entryId: String,
|
||||||
|
val exerciseId: String,
|
||||||
val exerciseName: String,
|
val exerciseName: String,
|
||||||
|
val equipmentDisplayName: String? = null,
|
||||||
val recordingMode: RecordingMode,
|
val recordingMode: RecordingMode,
|
||||||
val trackingMode: TrackingMode,
|
val trackingMode: TrackingMode,
|
||||||
val dataFields: Int,
|
val dataFields: Int,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
|
|
@ -19,8 +22,12 @@ fun SessionDetailScreen(
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
|
|
||||||
|
var revision by remember(sessionId) { mutableStateOf(0) }
|
||||||
|
var editingEntryId by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||||
|
var equipmentQuery by remember(sessionId) { mutableStateOf("") }
|
||||||
|
|
||||||
val detail =
|
val detail =
|
||||||
remember(sessionId) {
|
remember(sessionId, revision) {
|
||||||
sessionId?.let {
|
sessionId?.let {
|
||||||
repository.getSessionDetail(
|
repository.getSessionDetail(
|
||||||
it
|
it
|
||||||
|
|
@ -97,6 +104,45 @@ fun SessionDetailScreen(
|
||||||
title =
|
title =
|
||||||
"${index + 1}. ${exercise.exerciseName}"
|
"${index + 1}. ${exercise.exerciseName}"
|
||||||
) {
|
) {
|
||||||
|
exercise.equipmentDisplayName?.let { equipment ->
|
||||||
|
TrainlogInfo("Équipement : $equipment", color = colors.muted)
|
||||||
|
}
|
||||||
|
if (editingEntryId == exercise.entryId) {
|
||||||
|
TrainlogInputField(
|
||||||
|
label = "Rechercher une machine",
|
||||||
|
value = equipmentQuery,
|
||||||
|
onValueChange = { equipmentQuery = it },
|
||||||
|
)
|
||||||
|
TrainlogAction(
|
||||||
|
label = "Retirer l'équipement",
|
||||||
|
description = "Conserver l'exercice sans machine associée.",
|
||||||
|
accent = colors.warning,
|
||||||
|
onClick = {
|
||||||
|
if (repository.setCompletedSessionEquipment(detail.summary.sessionId, exercise.entryId, null)) {
|
||||||
|
revision++; editingEntryId = null; equipmentQuery = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
repository.searchEquipment(equipmentQuery).take(8).forEach { equipment ->
|
||||||
|
TrainlogAction(
|
||||||
|
label = equipment.displayName,
|
||||||
|
description = equipment.labelName.ifBlank { equipment.type },
|
||||||
|
accent = colors.success,
|
||||||
|
onClick = {
|
||||||
|
if (repository.setCompletedSessionEquipment(detail.summary.sessionId, exercise.entryId, equipment.equipmentId)) {
|
||||||
|
revision++; editingEntryId = null; equipmentQuery = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TrainlogAction(
|
||||||
|
label = "Modifier l'équipement",
|
||||||
|
description = "Choisir, remplacer ou retirer la machine de cette entrée.",
|
||||||
|
accent = colors.muted,
|
||||||
|
onClick = { editingEntryId = exercise.entryId },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
exercise.recordingMode ==
|
exercise.recordingMode ==
|
||||||
RecordingMode.CONTINUOUS
|
RecordingMode.CONTINUOUS
|
||||||
|
|
@ -144,7 +190,13 @@ private fun SetsDetail(
|
||||||
exercise.trackingMode ==
|
exercise.trackingMode ==
|
||||||
TrackingMode.REPS
|
TrackingMode.REPS
|
||||||
) {
|
) {
|
||||||
"Série ${index + 1} : ${set.reps} reps"
|
buildString {
|
||||||
|
append("Série ${index + 1} : ${set.reps} reps")
|
||||||
|
set.weightKg?.let {
|
||||||
|
val rendered = "%.2f".format(java.util.Locale.FRANCE, it).trimEnd('0').trimEnd(',')
|
||||||
|
append(" · $rendered kg")
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
"Série ${index + 1} : ${formatDuration(set.durationSeconds)}"
|
"Série ${index + 1} : ${formatDuration(set.durationSeconds)}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
|
import com.labfytools.trainlog.data.CreateEquipmentResult
|
||||||
|
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
|
|
@ -275,6 +277,20 @@ fun SessionScreen(
|
||||||
colors.text,
|
colors.text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
TrainlogAction(
|
||||||
|
label = "Modifier ${draft.exercise.name}",
|
||||||
|
description = "Corriger cet exercice sans le supprimer de la séance.",
|
||||||
|
accent = colors.accent,
|
||||||
|
onClick = {
|
||||||
|
persistDraft(
|
||||||
|
currentDraft.copy(
|
||||||
|
form = formForExistingExercise(draft, index),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label =
|
label =
|
||||||
"Retirer ${draft.exercise.name}",
|
"Retirer ${draft.exercise.name}",
|
||||||
|
|
@ -330,12 +346,9 @@ fun SessionScreen(
|
||||||
.selectedExercise
|
.selectedExercise
|
||||||
?.exerciseId ==
|
?.exerciseId ==
|
||||||
exercise.exerciseId,
|
exercise.exerciseId,
|
||||||
disabled =
|
disabled = false,
|
||||||
alreadyAdded,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
if (
|
if (true) {
|
||||||
!alreadyAdded
|
|
||||||
) {
|
|
||||||
persistDraft(
|
persistDraft(
|
||||||
currentDraft.copy(
|
currentDraft.copy(
|
||||||
form =
|
form =
|
||||||
|
|
@ -360,6 +373,7 @@ fun SessionScreen(
|
||||||
SessionExerciseForm(
|
SessionExerciseForm(
|
||||||
key =
|
key =
|
||||||
editingExercise.exerciseId,
|
editingExercise.exerciseId,
|
||||||
|
repository = repository,
|
||||||
exercise =
|
exercise =
|
||||||
editingExercise,
|
editingExercise,
|
||||||
initialForm =
|
initialForm =
|
||||||
|
|
@ -368,7 +382,10 @@ fun SessionScreen(
|
||||||
form ->
|
form ->
|
||||||
persistDraft(
|
persistDraft(
|
||||||
currentDraft.copy(
|
currentDraft.copy(
|
||||||
form = form
|
form = form.copy(
|
||||||
|
editingExerciseIndex = currentDraft.form.editingExerciseIndex,
|
||||||
|
editingEntryId = currentDraft.form.editingEntryId,
|
||||||
|
)
|
||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
|
|
@ -384,15 +401,18 @@ fun SessionScreen(
|
||||||
},
|
},
|
||||||
onAdd = {
|
onAdd = {
|
||||||
draft ->
|
draft ->
|
||||||
|
val editIndex = currentDraft.form.editingExerciseIndex
|
||||||
persistDraft(
|
persistDraft(
|
||||||
currentDraft.copy(
|
currentDraft.copy(
|
||||||
exercises =
|
exercises = editIndex?.let { replacingIndex ->
|
||||||
currentDraft.exercises +
|
currentDraft.exercises.mapIndexed { index, existing ->
|
||||||
draft,
|
if (index == replacingIndex) draft else existing
|
||||||
|
}
|
||||||
|
} ?: (currentDraft.exercises + draft),
|
||||||
form =
|
form =
|
||||||
SessionDraftForm(),
|
SessionDraftForm(),
|
||||||
),
|
),
|
||||||
"Exercice ajouté à la séance.",
|
if (editIndex == null) "Exercice ajouté à la séance." else "Exercice modifié.",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -594,6 +614,7 @@ private fun CatalogChoice(
|
||||||
@Composable
|
@Composable
|
||||||
private fun SessionExerciseForm(
|
private fun SessionExerciseForm(
|
||||||
key: String,
|
key: String,
|
||||||
|
repository: TrainlogRepository,
|
||||||
exercise: ExerciseProfile,
|
exercise: ExerciseProfile,
|
||||||
initialForm: SessionDraftForm,
|
initialForm: SessionDraftForm,
|
||||||
onFormChanged: (SessionDraftForm) -> Unit,
|
onFormChanged: (SessionDraftForm) -> Unit,
|
||||||
|
|
@ -619,6 +640,13 @@ private fun SessionExerciseForm(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var weightText by
|
||||||
|
remember(key) {
|
||||||
|
mutableStateOf(
|
||||||
|
initialForm.weightText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var durationText by
|
var durationText by
|
||||||
remember(key) {
|
remember(key) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
|
|
@ -640,6 +668,14 @@ private fun SessionExerciseForm(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var equipmentRevision by remember(key) { mutableStateOf(0) }
|
||||||
|
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
|
||||||
|
var equipmentSearch by remember(key) { mutableStateOf("") }
|
||||||
|
var customEquipmentName by remember(key) { mutableStateOf("") }
|
||||||
|
var selectedEquipmentId by remember(key) {
|
||||||
|
mutableStateOf(initialForm.selectedEquipmentId)
|
||||||
|
}
|
||||||
|
|
||||||
var error by
|
var error by
|
||||||
remember(key) {
|
remember(key) {
|
||||||
mutableStateOf<
|
mutableStateOf<
|
||||||
|
|
@ -659,6 +695,58 @@ private fun SessionExerciseForm(
|
||||||
color = colors.accent,
|
color = colors.accent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
TrainlogInputField(
|
||||||
|
label = "Machine / équipement (optionnel)",
|
||||||
|
value = equipmentSearch,
|
||||||
|
onValueChange = { equipmentSearch = it },
|
||||||
|
)
|
||||||
|
TrainlogInputField(
|
||||||
|
label = "Nouvelle machine",
|
||||||
|
value = customEquipmentName,
|
||||||
|
onValueChange = { customEquipmentName = it },
|
||||||
|
)
|
||||||
|
TrainlogAction(
|
||||||
|
label = "Créer la machine",
|
||||||
|
description = "L'ajouter à votre catalogue puis la sélectionner pour cette entrée.",
|
||||||
|
accent = colors.success,
|
||||||
|
onClick = {
|
||||||
|
when (val result = repository.createCustomEquipment(customEquipmentName)) {
|
||||||
|
is CreateEquipmentResult.Created -> {
|
||||||
|
customEquipmentName = ""
|
||||||
|
equipmentRevision += 1
|
||||||
|
selectedEquipmentId = result.equipment.equipmentId
|
||||||
|
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText))
|
||||||
|
}
|
||||||
|
CreateEquipmentResult.Invalid -> error = "Donnez un nom de machine valide."
|
||||||
|
CreateEquipmentResult.Conflict -> error = "Cette machine existe déjà."
|
||||||
|
is CreateEquipmentResult.DatabaseError -> error = "Machine non créée : ${result.message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
||||||
|
if (selectedEquipment != null) {
|
||||||
|
TrainlogAction(
|
||||||
|
label = "✓ ${selectedEquipment.displayName}",
|
||||||
|
description = selectedEquipment.labelName.ifBlank { "Équipement sélectionné." },
|
||||||
|
accent = colors.success,
|
||||||
|
onClick = {
|
||||||
|
selectedEquipmentId = null
|
||||||
|
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, null, weightText))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
|
||||||
|
TrainlogAction(
|
||||||
|
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
|
||||||
|
description = equipment.labelName.ifBlank { equipment.type },
|
||||||
|
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
|
||||||
|
onClick = {
|
||||||
|
selectedEquipmentId = equipment.equipmentId
|
||||||
|
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
exercise.recordingMode ==
|
exercise.recordingMode ==
|
||||||
RecordingMode.SETS
|
RecordingMode.SETS
|
||||||
|
|
@ -683,6 +771,8 @@ private fun SessionExerciseForm(
|
||||||
durationText,
|
durationText,
|
||||||
speedText,
|
speedText,
|
||||||
distanceText,
|
distanceText,
|
||||||
|
selectedEquipmentId,
|
||||||
|
weightText,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -694,6 +784,29 @@ private fun SessionExerciseForm(
|
||||||
color =
|
color =
|
||||||
colors.muted,
|
colors.muted,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SessionNumberField(
|
||||||
|
label = if (selectedEquipment?.loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
|
||||||
|
"Assistance (kg)"
|
||||||
|
} else {
|
||||||
|
"Charge (kg)"
|
||||||
|
},
|
||||||
|
value = weightText,
|
||||||
|
onValueChange = {
|
||||||
|
weightText = it
|
||||||
|
error = null
|
||||||
|
onFormChanged(
|
||||||
|
currentForm(
|
||||||
|
exercise, setCountText, repsText, durationText,
|
||||||
|
speedText, distanceText, selectedEquipmentId, it,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
TrainlogInfo(
|
||||||
|
text = "Une valeur par série séparée par ; (ex. 12,5;15). Une seule valeur s'applique à toutes les séries.",
|
||||||
|
color = colors.muted,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
SessionNumberField(
|
SessionNumberField(
|
||||||
label =
|
label =
|
||||||
|
|
@ -711,6 +824,8 @@ private fun SessionExerciseForm(
|
||||||
durationText,
|
durationText,
|
||||||
speedText,
|
speedText,
|
||||||
distanceText,
|
distanceText,
|
||||||
|
selectedEquipmentId,
|
||||||
|
weightText,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -732,6 +847,8 @@ private fun SessionExerciseForm(
|
||||||
it,
|
it,
|
||||||
speedText,
|
speedText,
|
||||||
distanceText,
|
distanceText,
|
||||||
|
selectedEquipmentId,
|
||||||
|
weightText,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -752,8 +869,10 @@ private fun SessionExerciseForm(
|
||||||
setCountText,
|
setCountText,
|
||||||
repsText,
|
repsText,
|
||||||
it,
|
it,
|
||||||
speedText,
|
speedText,
|
||||||
distanceText,
|
distanceText,
|
||||||
|
selectedEquipmentId,
|
||||||
|
weightText,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -780,6 +899,8 @@ private fun SessionExerciseForm(
|
||||||
durationText,
|
durationText,
|
||||||
it,
|
it,
|
||||||
distanceText,
|
distanceText,
|
||||||
|
selectedEquipmentId,
|
||||||
|
weightText,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -807,6 +928,8 @@ private fun SessionExerciseForm(
|
||||||
durationText,
|
durationText,
|
||||||
speedText,
|
speedText,
|
||||||
it,
|
it,
|
||||||
|
selectedEquipmentId,
|
||||||
|
weightText,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -836,6 +959,9 @@ private fun SessionExerciseForm(
|
||||||
speedText,
|
speedText,
|
||||||
distanceText =
|
distanceText =
|
||||||
distanceText,
|
distanceText,
|
||||||
|
equipmentId = selectedEquipmentId,
|
||||||
|
weightText = weightText,
|
||||||
|
entryId = initialForm.editingEntryId,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (draft == null) {
|
if (draft == null) {
|
||||||
|
|
@ -877,11 +1003,15 @@ private fun currentForm(
|
||||||
durationText: String,
|
durationText: String,
|
||||||
speedText: String,
|
speedText: String,
|
||||||
distanceText: String,
|
distanceText: String,
|
||||||
|
equipmentId: String? = null,
|
||||||
|
weightText: String = "",
|
||||||
): SessionDraftForm =
|
): SessionDraftForm =
|
||||||
SessionDraftForm(
|
SessionDraftForm(
|
||||||
selectedExercise = exercise,
|
selectedExercise = exercise,
|
||||||
|
selectedEquipmentId = equipmentId,
|
||||||
setCountText = setCountText,
|
setCountText = setCountText,
|
||||||
repsText = repsText,
|
repsText = repsText,
|
||||||
|
weightText = weightText,
|
||||||
durationText = durationText,
|
durationText = durationText,
|
||||||
speedText = speedText,
|
speedText = speedText,
|
||||||
distanceText = distanceText,
|
distanceText = distanceText,
|
||||||
|
|
@ -1062,6 +1192,9 @@ private fun buildSessionExerciseDraft(
|
||||||
durationText: String,
|
durationText: String,
|
||||||
speedText: String,
|
speedText: String,
|
||||||
distanceText: String,
|
distanceText: String,
|
||||||
|
equipmentId: String? = null,
|
||||||
|
weightText: String = "",
|
||||||
|
entryId: String? = null,
|
||||||
): SessionExerciseDraft? {
|
): SessionExerciseDraft? {
|
||||||
return if (
|
return if (
|
||||||
exercise.recordingMode ==
|
exercise.recordingMode ==
|
||||||
|
|
@ -1122,7 +1255,9 @@ private fun buildSessionExerciseDraft(
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
SessionExerciseDraft(
|
SessionExerciseDraft(
|
||||||
|
entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(),
|
||||||
exercise = exercise,
|
exercise = exercise,
|
||||||
|
equipmentId = equipmentId,
|
||||||
continuousDurationSeconds =
|
continuousDurationSeconds =
|
||||||
minutes * 60,
|
minutes * 60,
|
||||||
speedKmh = speed,
|
speedKmh = speed,
|
||||||
|
|
@ -1139,12 +1274,17 @@ private fun buildSessionExerciseDraft(
|
||||||
repsText
|
repsText
|
||||||
) ?: return null
|
) ?: return null
|
||||||
|
|
||||||
|
val weights = parseWeightSequence(weightText, reps.size) ?: return null
|
||||||
|
|
||||||
SessionExerciseDraft(
|
SessionExerciseDraft(
|
||||||
|
entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(),
|
||||||
exercise = exercise,
|
exercise = exercise,
|
||||||
|
equipmentId = equipmentId,
|
||||||
sets =
|
sets =
|
||||||
reps.map {
|
reps.mapIndexed { index, rep ->
|
||||||
SessionSetDraft(
|
SessionSetDraft(
|
||||||
reps = it
|
reps = rep,
|
||||||
|
weightKg = weights[index],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -1166,7 +1306,9 @@ private fun buildSessionExerciseDraft(
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
SessionExerciseDraft(
|
SessionExerciseDraft(
|
||||||
|
entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(),
|
||||||
exercise = exercise,
|
exercise = exercise,
|
||||||
|
equipmentId = equipmentId,
|
||||||
sets =
|
sets =
|
||||||
List(count) {
|
List(count) {
|
||||||
SessionSetDraft(
|
SessionSetDraft(
|
||||||
|
|
@ -1179,6 +1321,50 @@ private fun buildSessionExerciseDraft(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reconstruct editable text from the entry itself; editing never mutates a
|
||||||
|
* different entry or the global exercise definition. */
|
||||||
|
private fun formForExistingExercise(
|
||||||
|
draft: SessionExerciseDraft,
|
||||||
|
index: Int,
|
||||||
|
): SessionDraftForm =
|
||||||
|
SessionDraftForm(
|
||||||
|
selectedExercise = draft.exercise,
|
||||||
|
editingExerciseIndex = index,
|
||||||
|
editingEntryId = draft.entryId,
|
||||||
|
selectedEquipmentId = draft.equipmentId,
|
||||||
|
setCountText = draft.sets.size.toString(),
|
||||||
|
repsText = if (draft.exercise.trackingMode == TrackingMode.REPS) {
|
||||||
|
draft.sets.joinToString(",") { it.reps.toString() }
|
||||||
|
} else {
|
||||||
|
"3x10"
|
||||||
|
},
|
||||||
|
weightText = draft.sets.mapNotNull { it.weightKg }.joinToString(";") { "%g".format(java.util.Locale.FRANCE, it) },
|
||||||
|
durationText = if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) {
|
||||||
|
(draft.continuousDurationSeconds / 60).toString()
|
||||||
|
} else {
|
||||||
|
draft.sets.firstOrNull()?.durationSeconds?.toString() ?: "30"
|
||||||
|
},
|
||||||
|
speedText = draft.speedKmh?.toString().orEmpty(),
|
||||||
|
distanceText = draft.distanceKm?.toString().orEmpty(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Accept French decimal commas without confusing them with the set separator.
|
||||||
|
* CONTRACT: blank means no load recorded; zero is a real explicit value. */
|
||||||
|
private fun parseWeightSequence(text: String, count: Int): List<Double?>? {
|
||||||
|
if (text.trim().isEmpty()) return List(count) { null }
|
||||||
|
val values = text.split(';').map { token ->
|
||||||
|
token.trim().replace(',', '.').toDoubleOrNull()
|
||||||
|
}
|
||||||
|
if (values.any { it == null || !it.isFinite() || it < 0.0 }) return null
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val parsed = values as List<Double>
|
||||||
|
return when {
|
||||||
|
parsed.size == 1 -> List(count) { parsed.single() }
|
||||||
|
parsed.size == count -> parsed
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun draftSummary(
|
private fun draftSummary(
|
||||||
draft: SessionExerciseDraft,
|
draft: SessionExerciseDraft,
|
||||||
): String {
|
): String {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class EquipmentCatalogTest {
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canonicalManifestHasStableCompleteSearchableEntries() {
|
||||||
|
val entries = EquipmentCatalog.load(context)
|
||||||
|
assertEquals(38, entries.size)
|
||||||
|
assertEquals(entries.size, entries.map { it.equipmentId }.toSet().size)
|
||||||
|
assertTrue(entries.all { it.equipmentId.isNotBlank() && it.displayName.isNotBlank() })
|
||||||
|
assertTrue(entries.all { entry -> entry.aliases.all { it.isNotBlank() } })
|
||||||
|
assertTrue(entries.all { it.type.isNotBlank() })
|
||||||
|
assertTrue(EquipmentCatalog.search(entries, "LEG PRESS").any { it.equipmentId == "leg_press" })
|
||||||
|
assertTrue(EquipmentCatalog.search(entries, "Presse à pectoraux").any { it.equipmentId == "vertical_chest_press" })
|
||||||
|
assertTrue(EquipmentCatalog.search(entries, "presse à cuisses").any { it.equipmentId == "leg_press" })
|
||||||
|
assertTrue(EquipmentCatalog.search(entries, "ischio").map { it.equipmentId }.containsAll(listOf("seated_leg_curl", "prone_leg_curl")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun assistedMachineIsOnePhysicalEquipmentWithAssistanceSemantics() {
|
||||||
|
val entries = EquipmentCatalog.load(context)
|
||||||
|
assertEquals(EquipmentLoadSemantics.ASSISTANCE, entries.single { it.equipmentId == "assisted_dip_chin_machine" }.loadSemantics)
|
||||||
|
val relations = EquipmentCatalog.exerciseEquipmentRelations(context)
|
||||||
|
assertEquals(setOf("assisted_chin", "assisted_dip"), relations.filter { it.equipmentId == "assisted_dip_chin_machine" }.map { it.exerciseId }.toSet())
|
||||||
|
assertTrue(relations.filter { it.equipmentId == "assisted_dip_chin_machine" }.all { it.loadSemantics == EquipmentLoadSemantics.ASSISTANCE })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
exercises = listOf(
|
exercises = listOf(
|
||||||
SessionExerciseDraft(
|
SessionExerciseDraft(
|
||||||
exercise = reps,
|
exercise = reps,
|
||||||
|
equipmentId = "leg_press",
|
||||||
sets = listOf(4, 5, 6, 7).map { SessionSetDraft(reps = it) },
|
sets = listOf(4, 5, 6, 7).map { SessionSetDraft(reps = it) },
|
||||||
),
|
),
|
||||||
SessionExerciseDraft(
|
SessionExerciseDraft(
|
||||||
|
|
@ -79,6 +80,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
sessionType = SessionType.MAX_TEST,
|
sessionType = SessionType.MAX_TEST,
|
||||||
form = SessionDraftForm(
|
form = SessionDraftForm(
|
||||||
selectedExercise = reps,
|
selectedExercise = reps,
|
||||||
|
selectedEquipmentId = "treadmill",
|
||||||
setCountText = "4",
|
setCountText = "4",
|
||||||
repsText = "4,5,6,",
|
repsText = "4,5,6,",
|
||||||
durationText = "31",
|
durationText = "31",
|
||||||
|
|
@ -161,6 +163,68 @@ class TrainlogRepositoryDraftTest {
|
||||||
assertEquals(1, repo.listSessions().size)
|
assertEquals(1, repo.listSessions().size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun repeatedContinuousExercisePersistsDistinctOccurrencesAcrossReopenAndFinalize() {
|
||||||
|
val repo = openRepository()
|
||||||
|
val marche = createExercise(repo, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
|
||||||
|
val first = SessionExerciseDraft(exercise = marche, continuousDurationSeconds = 600)
|
||||||
|
val second = SessionExerciseDraft(exercise = marche, continuousDurationSeconds = 900)
|
||||||
|
val draft = ActiveSessionDraft(exercises = listOf(first, second))
|
||||||
|
assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(draft))
|
||||||
|
repo.close(); repository = null
|
||||||
|
val reopened = openRepository()
|
||||||
|
val restored = loadDraft(reopened)
|
||||||
|
assertEquals(listOf(600, 900), restored.exercises.map { it.continuousDurationSeconds })
|
||||||
|
assertEquals(listOf(marche.exerciseId, marche.exerciseId), restored.exercises.map { it.exercise.exerciseId })
|
||||||
|
assertEquals(2, restored.exercises.map { it.entryId }.toSet().size)
|
||||||
|
assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||||
|
val sessionId = reopened.listSessions().single().sessionId
|
||||||
|
val detail = reopened.getSessionDetail(sessionId)!!
|
||||||
|
assertEquals(listOf(600, 900), detail.exercises.map { it.continuousDurationSeconds })
|
||||||
|
assertEquals(2, detail.exercises.map { it.entryId }.toSet().size)
|
||||||
|
assertTrue(
|
||||||
|
reopened.setCompletedSessionEquipment(
|
||||||
|
sessionId,
|
||||||
|
detail.exercises[0].entryId,
|
||||||
|
"treadmill",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
reopened.setCompletedSessionEquipment(
|
||||||
|
sessionId,
|
||||||
|
detail.exercises[1].entryId,
|
||||||
|
"leg_press",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val edited = reopened.getSessionDetail(sessionId)!!
|
||||||
|
assertTrue(edited.exercises[0].equipmentDisplayName != null)
|
||||||
|
assertTrue(edited.exercises[1].equipmentDisplayName != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun weightedMachineSetsAndCustomEquipmentSurviveDraftFinalizeAndReopen() {
|
||||||
|
val repo = openRepository()
|
||||||
|
val exercise = createExercise(repo, "Presse", RecordingMode.SETS, TrackingMode.REPS)
|
||||||
|
val custom = repo.createCustomEquipment("Presse personnelle")
|
||||||
|
assertTrue(custom is CreateEquipmentResult.Created)
|
||||||
|
val equipmentId = (custom as CreateEquipmentResult.Created).equipment.equipmentId
|
||||||
|
val draft = ActiveSessionDraft(
|
||||||
|
exercises = listOf(SessionExerciseDraft(
|
||||||
|
exercise = exercise, equipmentId = equipmentId,
|
||||||
|
sets = listOf(SessionSetDraft(10, weightKg = 12.5), SessionSetDraft(8, weightKg = 15.0)),
|
||||||
|
)),
|
||||||
|
form = SessionDraftForm(selectedExercise = exercise, selectedEquipmentId = equipmentId, weightText = "12,5;15"),
|
||||||
|
)
|
||||||
|
assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(draft))
|
||||||
|
repo.close(); repository = null
|
||||||
|
val reopened = openRepository()
|
||||||
|
assertEquals(draft.exercises, loadDraft(reopened).exercises)
|
||||||
|
assertTrue(reopened.listEquipment().any { it.equipmentId == equipmentId })
|
||||||
|
assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||||
|
val detail = reopened.getSessionDetail(reopened.listSessions().single().sessionId)!!
|
||||||
|
assertEquals(listOf(12.5, 15.0), detail.exercises.single().sets.map { it.weightKg })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun finalizationFailureRollsBackCompletedRowsAndKeepsDraft() {
|
fun finalizationFailureRollsBackCompletedRowsAndKeepsDraft() {
|
||||||
val repo = openRepository()
|
val repo = openRepository()
|
||||||
|
|
@ -452,14 +516,55 @@ class TrainlogRepositoryDraftTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun versionThreeMigrationPreservesCompletedAndBodyData() {
|
fun completedSessionKeepsEquipmentAcrossReopenAndCompanionExport() {
|
||||||
createVersionThreeFixture(context.getDatabasePath(databaseName).path)
|
val first = openRepository()
|
||||||
|
val exercise = createExercise(first, "Presse test", RecordingMode.SETS, TrackingMode.REPS)
|
||||||
|
val saved = first.saveSession(
|
||||||
|
SessionDraft(
|
||||||
|
exercises = listOf(
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
equipmentId = "leg_press",
|
||||||
|
sets = listOf(SessionSetDraft(reps = 10)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(saved is SaveSessionResult.Saved)
|
||||||
|
val sessionId = (saved as SaveSessionResult.Saved).sessionId
|
||||||
|
val exported = JSONObject(first.buildEquipmentAssociationsJson()).getJSONArray("associations")
|
||||||
|
assertEquals("set", exported.getJSONObject(0).getString("state"))
|
||||||
|
assertEquals("leg_press", exported.getJSONObject(0).getString("equipment_id"))
|
||||||
|
first.close()
|
||||||
|
repository = null
|
||||||
|
val reopened = openRepository()
|
||||||
|
val again = JSONObject(reopened.buildEquipmentAssociationsJson()).getJSONArray("associations")
|
||||||
|
assertEquals(sessionId, again.getJSONObject(0).getString("session_id"))
|
||||||
|
assertEquals("leg_press", again.getJSONObject(0).getString("equipment_id"))
|
||||||
|
val cleared = JSONObject()
|
||||||
|
.put("format", "trainlog-equipment-associations")
|
||||||
|
.put("version", 1)
|
||||||
|
.put("generated_at", "2026-01-01T00:00:00+00:00")
|
||||||
|
.put("associations", org.json.JSONArray().put(JSONObject()
|
||||||
|
.put("session_id", sessionId)
|
||||||
|
.put("exercise_id", exercise.exerciseId)
|
||||||
|
.put("state", "cleared")))
|
||||||
|
assertTrue(reopened.applyPcEquipmentAssociationsJson(cleared.toString()) is EquipmentAssociationImportResult.Applied)
|
||||||
|
val afterClear = JSONObject(reopened.buildEquipmentAssociationsJson()).getJSONArray("associations")
|
||||||
|
assertEquals("cleared", afterClear.getJSONObject(0).getString("state"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun versionFourMigrationPreservesCompletedBodyAndDurableDraftData() {
|
||||||
|
createVersionFourFixture(context.getDatabasePath(databaseName).path)
|
||||||
val repo = openRepository()
|
val repo = openRepository()
|
||||||
|
|
||||||
assertEquals(1, repo.listExercises().size)
|
assertEquals(1, repo.listExercises().size)
|
||||||
assertEquals(1, repo.listSessions().size)
|
assertEquals(1, repo.listSessions().size)
|
||||||
assertEquals(1, repo.listBodyObservations().size)
|
assertEquals(1, repo.listBodyObservations().size)
|
||||||
assertEquals(ActiveDraftLoadResult.None, repo.loadActiveSessionDraft())
|
val loadedDraft = loadDraft(repo)
|
||||||
|
assertEquals("Fixture", loadedDraft.form.selectedExercise?.name)
|
||||||
|
assertEquals(1, loadedDraft.exercises.size)
|
||||||
SQLiteDatabase.openDatabase(
|
SQLiteDatabase.openDatabase(
|
||||||
context.getDatabasePath(databaseName).path,
|
context.getDatabasePath(databaseName).path,
|
||||||
null,
|
null,
|
||||||
|
|
@ -467,7 +572,11 @@ 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(4, cursor.getInt(0))
|
assertEquals(7, cursor.getInt(0))
|
||||||
|
}
|
||||||
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertTrue(cursor.isNull(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("PRAGMA foreign_key_check;", null).use { cursor ->
|
db.rawQuery("PRAGMA foreign_key_check;", null).use { cursor ->
|
||||||
assertFalse(cursor.moveToFirst())
|
assertFalse(cursor.moveToFirst())
|
||||||
|
|
@ -499,7 +608,8 @@ class TrainlogRepositoryDraftTest {
|
||||||
return (result as CreateExerciseResult.Created).exercise
|
return (result as CreateExerciseResult.Created).exercise
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createVersionThreeFixture(path: String) {
|
/** A real v4 shape: completed history plus the v4 durable draft tables. */
|
||||||
|
private fun createVersionFourFixture(path: String) {
|
||||||
SQLiteDatabase.openOrCreateDatabase(path, null).use { db ->
|
SQLiteDatabase.openOrCreateDatabase(path, null).use { db ->
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT NOT NULL UNIQUE, " +
|
"CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT NOT NULL UNIQUE, " +
|
||||||
|
|
@ -547,7 +657,33 @@ class TrainlogRepositoryDraftTest {
|
||||||
"INSERT INTO body_observations(id, observation_id, observed_at, body_weight_kg) " +
|
"INSERT INTO body_observations(id, observation_id, observed_at, body_weight_kg) " +
|
||||||
"VALUES(1, 'bo_fixture', '2026-01-02T03:04:05+01:00', 70.5);"
|
"VALUES(1, 'bo_fixture', '2026-01-02T03:04:05+01:00', 70.5);"
|
||||||
)
|
)
|
||||||
db.execSQL("PRAGMA user_version = 3;")
|
db.execSQL(
|
||||||
|
"CREATE TABLE active_session_draft(id INTEGER PRIMARY KEY CHECK(id = 1), " +
|
||||||
|
"session_type TEXT NOT NULL, selected_exercise_row_id INTEGER REFERENCES exercises(id) ON DELETE SET NULL, " +
|
||||||
|
"selected_exercise_label TEXT, set_count_text TEXT NOT NULL, reps_text TEXT NOT NULL, " +
|
||||||
|
"duration_text TEXT NOT NULL, speed_text TEXT NOT NULL, distance_text TEXT NOT NULL, updated_at TEXT NOT NULL);"
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE draft_session_exercises(id INTEGER PRIMARY KEY, draft_id INTEGER NOT NULL " +
|
||||||
|
"REFERENCES active_session_draft(id) ON DELETE CASCADE, exercise_row_id INTEGER NOT NULL " +
|
||||||
|
"REFERENCES exercises(id) ON DELETE RESTRICT, position INTEGER NOT NULL, recording_mode TEXT NOT NULL, " +
|
||||||
|
"tracking_mode TEXT NOT NULL, data_fields INTEGER NOT NULL, UNIQUE(draft_id, position), UNIQUE(draft_id, exercise_row_id));"
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE draft_performed_sets(id INTEGER PRIMARY KEY, draft_exercise_row_id INTEGER NOT NULL " +
|
||||||
|
"REFERENCES draft_session_exercises(id) ON DELETE CASCADE, position INTEGER NOT NULL, reps INTEGER, " +
|
||||||
|
"duration_seconds INTEGER, UNIQUE(draft_exercise_row_id, position));"
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE draft_continuous_activity(id INTEGER PRIMARY KEY, draft_exercise_row_id INTEGER NOT NULL UNIQUE " +
|
||||||
|
"REFERENCES draft_session_exercises(id) ON DELETE CASCADE, duration_seconds INTEGER NOT NULL, speed_kmh REAL, distance_km REAL);"
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT INTO active_session_draft VALUES(1, 'training', 1, 'Fixture', '3', '3x10', '', '', '', '2026-01-02T03:04:05+01:00');"
|
||||||
|
)
|
||||||
|
db.execSQL("INSERT INTO draft_session_exercises VALUES(1, 1, 1, 0, 'sets', 'reps', 0);")
|
||||||
|
db.execSQL("INSERT INTO draft_performed_sets VALUES(1, 1, 0, 10, NULL);")
|
||||||
|
db.execSQL("PRAGMA user_version = 4;")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
catalog/equipment-v1.json
Normal file
48
catalog/equipment-v1.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"format": "trainlog-equipment-catalog",
|
||||||
|
"version": 1,
|
||||||
|
"equipment": [
|
||||||
|
{"id":"diverging_lat_pulldown","label_name":"DIVERGING LAT PULLDOWN","display_name":"Diverging Lat Pulldown — Tirage vertical divergent","aliases":["tirage vertical divergent","lat pulldown divergent","dos"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"lat_pull","label_name":"LAT PULL","display_name":"Lat Pull — Tirage vertical","aliases":["tirage vertical","lat pull","dos"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"lat_pulldown","label_name":"LAT PULLDOWN","display_name":"Lat Pulldown — Tirage vertical","aliases":["tirage vertical","tirage poitrine","lat pulldown"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"seated_row","label_name":"SEATED ROW","display_name":"Seated Row — Tirage horizontal assis","aliases":["rowing assis","tirage horizontal","dos"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"diverging_seated_row","label_name":"DIVERGING SEATED ROW","display_name":"Diverging Seated Row — Tirage horizontal divergent","aliases":["rowing divergent","tirage horizontal divergent","dos"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"low_row","label_name":"LOW ROW","display_name":"Low Row — Tirage horizontal bas","aliases":["rowing bas","tirage bas","dos"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"vertical_chest_press","label_name":"VERTICAL CHEST PRESS","display_name":"Vertical Chest Press — Presse à pectoraux","aliases":["chest press","presse poitrine","développé machine","pectoraux"],"type":"machine","load_semantics":"external"},
|
||||||
|
{"id":"converging_shoulder_press","label_name":"CONVERGING SHOULDER PRESS","display_name":"Converging Shoulder Press — Presse épaules convergente","aliases":["shoulder press","développé épaules","presse épaules","épaules"],"type":"machine","load_semantics":"external"},
|
||||||
|
{"id":"rear_delt_pec_fly","label_name":"REAR DELT / PEC FLY","display_name":"Rear Delt / Pec Fly — Oiseaux / Pec Fly","aliases":["pec fly","butterfly","pec deck","rear delt","reverse fly","oiseaux","deltoïdes postérieurs","pectoraux"],"type":"selectorized_combo_machine","load_semantics":"external"},
|
||||||
|
{"id":"leg_press","label_name":"LEG PRESS","display_name":"Leg Press — Presse à cuisses","aliases":["presse à cuisses","presse jambes","jambes","leg press"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"plate_loaded_leg_press","label_name":"","display_name":"Leg Press à disques — Presse à cuisses","aliases":["presse à disques","plate loaded leg press","leg press"],"type":"plate_loaded_machine","load_semantics":"external"},
|
||||||
|
{"id":"perfect_squat","label_name":"PERFECT SQUAT","display_name":"Perfect Squat — Squat guidé","aliases":["squat machine","squat guidé","jambes","perfect squat"],"type":"plate_loaded_machine","load_semantics":"external"},
|
||||||
|
{"id":"leg_extension","label_name":"LEG EXTENSION","display_name":"Leg Extension — Extension des jambes","aliases":["extension quadriceps","quadriceps","leg extension"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"seated_leg_curl","label_name":"SEATED LEG CURL","display_name":"Seated Leg Curl — Leg curl assis","aliases":["leg curl assis","ischio","ischio-jambiers","flexion jambes"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"prone_leg_curl","label_name":"PRONE LEG CURL","display_name":"Prone Leg Curl — Leg curl allongé","aliases":["leg curl couché","leg curl allongé","ischio","ischio-jambiers"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"hip_abduction","label_name":"HIP ABDUCTION","display_name":"Hip Abduction — Abducteurs","aliases":["abducteurs","ouverture jambes","fessiers","hip abduction"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"hip_adduction","label_name":"HIP ADDUCTION","display_name":"Hip Adduction — Adducteurs","aliases":["adducteurs","fermeture jambes","hip adduction"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"abdominal","label_name":"ABDOMINAL","display_name":"Abdominal — Crunch machine","aliases":["abdos","crunch","abdominal machine"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"back_extension","label_name":"BACK EXTENSION","display_name":"Back Extension — Extension lombaires","aliases":["lombaires","extension dos","bas du dos","back extension"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"rotary_torso","label_name":"ROTARY TORSO","display_name":"Rotary Torso — Rotation du buste","aliases":["obliques","rotation tronc","rotation buste","rotary torso"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"abdominal_bench","label_name":"","display_name":"Banc à abdominaux incliné","aliases":["banc abdos","crunch bench","sit-up bench"],"type":"bench","load_semantics":"bodyweight"},
|
||||||
|
{"id":"arm_curl","label_name":"ARM CURL","display_name":"Arm Curl — Curl biceps","aliases":["biceps","curl","curl machine"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"seated_dip","label_name":"SEATED DIP","display_name":"Seated Dip — Dips assis / Triceps","aliases":["triceps","dips assis","seated dip"],"type":"selectorized_machine","load_semantics":"external"},
|
||||||
|
{"id":"assisted_dip_chin_machine","label_name":"","display_name":"Machine Chin / Dip Assist","aliases":["chin assist","dip assist","traction assistée","dips assistés"],"type":"assisted_bodyweight_machine","load_semantics":"assistance"},
|
||||||
|
{"id":"functional_trainer","label_name":"","display_name":"Poulie réglable / Functional Trainer","aliases":["poulie","câble","cable machine","crossover","functional trainer"],"type":"cable_machine","load_semantics":"external"},
|
||||||
|
{"id":"dual_adjustable_pulley","label_name":"","display_name":"Double poulie réglable","aliases":["double poulie","crossover","cable crossover"],"type":"cable_machine","load_semantics":"external"},
|
||||||
|
{"id":"dumbbells","label_name":"","display_name":"Haltères","aliases":[],"type":"free_weight","load_semantics":"external"},
|
||||||
|
{"id":"kettlebells","label_name":"","display_name":"Kettlebells","aliases":[],"type":"free_weight","load_semantics":"external"},
|
||||||
|
{"id":"weighted_bag","label_name":"","display_name":"Sac lesté","aliases":["sandbag","power bag"],"type":"free_weight","load_semantics":"external"},
|
||||||
|
{"id":"medicine_ball","label_name":"","display_name":"Medicine Ball","aliases":["med ball","ballon lesté"],"type":"functional_equipment","load_semantics":"external"},
|
||||||
|
{"id":"suspension_trainer","label_name":"","display_name":"Sangles de suspension","aliases":["TRX","suspension trainer"],"type":"functional_equipment","load_semantics":"bodyweight"},
|
||||||
|
{"id":"battle_ropes","label_name":"","display_name":"Battle Ropes — Cordes ondulatoires","aliases":["corde","battle rope","cordes"],"type":"functional_equipment","load_semantics":"bodyweight"},
|
||||||
|
{"id":"treadmill","label_name":"","display_name":"Tapis de course","aliases":["treadmill","tapis"],"type":"cardio_machine","load_semantics":"cardio"},
|
||||||
|
{"id":"upright_bike","label_name":"","display_name":"Vélo droit","aliases":["exercise bike","vélo cardio"],"type":"cardio_machine","load_semantics":"cardio"},
|
||||||
|
{"id":"recumbent_bike","label_name":"","display_name":"Vélo semi-allongé","aliases":["vélo couché","recumbent bike"],"type":"cardio_machine","load_semantics":"cardio"},
|
||||||
|
{"id":"indoor_cycle","label_name":"","display_name":"Vélo indoor / Spinning","aliases":["spinning","body bike","indoor bike"],"type":"cardio_machine","load_semantics":"cardio"},
|
||||||
|
{"id":"rowing_machine","label_name":"","display_name":"Rameur","aliases":["rowing ergometer","rower"],"type":"cardio_machine","load_semantics":"cardio"},
|
||||||
|
{"id":"stair_climber","label_name":"","display_name":"Escalier / Stair Climber","aliases":["stairmaster","climber","escalier","stepmill"],"type":"cardio_machine","load_semantics":"cardio"}
|
||||||
|
],
|
||||||
|
"exercise_equipment": [
|
||||||
|
{"exercise_id":"assisted_chin","equipment_id":"assisted_dip_chin_machine","load_semantics":"assistance"},
|
||||||
|
{"exercise_id":"assisted_dip","equipment_id":"assisted_dip_chin_machine","load_semantics":"assistance"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,14 @@ It is a native Kotlin/Jetpack Compose application with local SQLite persistence.
|
||||||
|
|
||||||
The desktop remains the canonical long-term history and analytics store.
|
The desktop remains the canonical long-term history and analytics store.
|
||||||
|
|
||||||
|
## Session exchange V2
|
||||||
|
|
||||||
|
Completed session occurrences persist an `entry_id`; it is never regenerated
|
||||||
|
for exchange. Android publishes `trainlog-mobile-export-v2.json` as the active
|
||||||
|
desktop snapshot and imports `trainlog-pc-mobile-export-v2.json` after the PC
|
||||||
|
catalogue. The artifact preserves occurrence order, continuous metrics, set
|
||||||
|
weights and equipment. The legacy V1 contract remains separate and readable.
|
||||||
|
|
||||||
## 2. Implemented navigation
|
## 2. Implemented navigation
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -25,7 +33,7 @@ Accueil
|
||||||
Android local database version:
|
Android local database version:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
4
|
7
|
||||||
```
|
```
|
||||||
|
|
||||||
Domain tables cover:
|
Domain tables cover:
|
||||||
|
|
@ -41,9 +49,11 @@ body_observations
|
||||||
|
|
||||||
This database is Android-local. It is not copied to the PC.
|
This database is Android-local. It is not copied to the PC.
|
||||||
|
|
||||||
Schema v4 adds `active_session_draft`, `draft_session_exercises`,
|
Schema v4 introduced `active_session_draft`, `draft_session_exercises`,
|
||||||
`draft_performed_sets` and `draft_continuous_activity`. The additive v3 -> v4
|
`draft_performed_sets` and `draft_continuous_activity`. The implemented
|
||||||
migration preserves catalog, completed sessions/actuals and body observations.
|
additive v4 -> v7 chain preserves catalog, completed sessions/actuals, body
|
||||||
|
observations and the draft while adding the shared equipment catalogue,
|
||||||
|
occurrence-level equipment links and stable completed/draft `entry_id` values.
|
||||||
Exactly one active draft is supported; it is separate from completed history.
|
Exactly one active draft is supported; it is separate from completed history.
|
||||||
|
|
||||||
## 4. Exercise catalog
|
## 4. Exercise catalog
|
||||||
|
|
@ -120,6 +130,21 @@ speed or distance.
|
||||||
|
|
||||||
Continuous work does not create fake sets.
|
Continuous work does not create fake sets.
|
||||||
|
|
||||||
|
### Occurrences, equipment and actual loads
|
||||||
|
|
||||||
|
The same catalogue `exercise_id` may be added more than once to a session.
|
||||||
|
Every occurrence receives its own stable `entry_id`, position, actual sets and
|
||||||
|
optional equipment selection. Editing one occurrence replaces only that entry;
|
||||||
|
it does not merge or alter another passage of the same exercise.
|
||||||
|
|
||||||
|
`Machine / équipement (optionnel)` searches the shared manifest by display
|
||||||
|
name, physical-machine label and aliases. A selected equipment identity is
|
||||||
|
stored on that occurrence in both the active draft and completed session.
|
||||||
|
For `SETS + REPS`, `Charge (kg)` accepts one value for all sets or `;`-separated
|
||||||
|
per-set values; French decimal commas are accepted. `Assistance (kg)` is an
|
||||||
|
explicit alternative load semantic, not an external charge. Empty load and an
|
||||||
|
entered zero remain distinct.
|
||||||
|
|
||||||
## 6. Session draft editing
|
## 6. Session draft editing
|
||||||
|
|
||||||
The repository durably saves every meaningful mutation, including session type,
|
The repository durably saves every meaningful mutation, including session type,
|
||||||
|
|
@ -196,11 +221,15 @@ bo_<uuid-v4>
|
||||||
Android maintains:
|
Android maintains:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Download/Trainlog/trainlog-mobile-export-v1.json
|
Download/Trainlog/trainlog-mobile-export-v2.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The snapshot is refreshed after relevant local changes, including exercise,
|
The V2 snapshot is refreshed after relevant local changes, including exercise,
|
||||||
session, body-observation, and PC-catalog updates.
|
session, body-observation, equipment association and PC-catalog updates. It
|
||||||
|
preserves `entry_id`, occurrence position, optional equipment and actual
|
||||||
|
per-set weights. Android also publishes the V2 companion
|
||||||
|
`trainlog-equipment-associations-v2.json`; its `set` and `cleared` states are
|
||||||
|
targeted by `(session_id, entry_id)`.
|
||||||
|
|
||||||
The user does not need a separate manual export step before synchronization.
|
The user does not need a separate manual export step before synchronization.
|
||||||
|
|
||||||
|
|
@ -306,6 +335,31 @@ Android is not intended to own:
|
||||||
|
|
||||||
## 15. Test-max sessions
|
## 15. Test-max sessions
|
||||||
|
|
||||||
|
## 16. Equipment and multi-occurrence exchange V2
|
||||||
|
|
||||||
|
During exercise entry, `Machine / équipement (optionnel)` searches the shared
|
||||||
|
catalogue by display name, physical-machine label and aliases. The selected
|
||||||
|
canonical ID belongs to that session exercise entry, is durable in the active
|
||||||
|
draft and completed session, and is visible in session detail. It may be
|
||||||
|
cleared. The active V2 exchange preserves multiple ordered occurrences of the
|
||||||
|
same exercise in one session through `entry_id`. The frozen V1 artifacts remain
|
||||||
|
readable only as legacy artifacts and keep their historical one-exercise
|
||||||
|
identity assumptions; V1 is not rewritten to claim V2 support.
|
||||||
|
|
||||||
|
For a `SETS + REPS` exercise, selecting equipment never changes that exercise
|
||||||
|
profile: the form retains per-set repetitions and exposes `Charge (kg)`. French
|
||||||
|
decimal input is accepted (`12,5`); one value applies to all sets or values may
|
||||||
|
be separated with `;`. Assisted equipment is explicitly labelled
|
||||||
|
`Assistance (kg)`. Empty load remains distinct from an entered zero.
|
||||||
|
|
||||||
|
`Nouvelle machine` in that same selector creates a persistent local custom
|
||||||
|
equipment entry with a generated stable `eq_…` ID and selects it immediately.
|
||||||
|
The shared bundled catalogue is synchronized by canonical IDs; a custom ID is
|
||||||
|
not silently converted to null on the PC and is rejected until its definition
|
||||||
|
is available to the receiving catalogue.
|
||||||
|
The active-session list exposes `Modifier <exercice>`; saving replaces that
|
||||||
|
entry in place, while cancelling only discards the form and preserves it.
|
||||||
|
|
||||||
Android session entry exposes:
|
Android session entry exposes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,9 @@ Continuous work is persisted separately from performed sets.
|
||||||
|
|
||||||
### Desktop
|
### Desktop
|
||||||
|
|
||||||
Desktop SQLite schema v5 is canonical long-term history.
|
Desktop SQLite schema v7 is canonical long-term history. `session_exercises`
|
||||||
|
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
|
||||||
|
occur more than once in one session without identity fusion.
|
||||||
|
|
||||||
Main tables:
|
Main tables:
|
||||||
|
|
||||||
|
|
@ -135,7 +137,7 @@ body_observations
|
||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
Android has an independent local SQLite schema, currently v4.
|
Android has an independent local SQLite schema, currently v7.
|
||||||
|
|
||||||
It mirrors domain concepts needed for capture, but its schema version is not
|
It mirrors domain concepts needed for capture, but its schema version is not
|
||||||
coupled to the desktop schema.
|
coupled to the desktop schema.
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
|
||||||
|
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V5=PASS
|
DESKTOP_SCHEMA_V7=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V4=PASS
|
ANDROID_LOCAL_DATABASE_V7=PASS
|
||||||
ANDROID_SESSION_DRAFT_V1=PASS
|
ANDROID_SESSION_DRAFT_V1=PASS
|
||||||
ANDROID_DRAFT_DURABLE=PASS
|
ANDROID_DRAFT_DURABLE=PASS
|
||||||
ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS
|
ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS
|
||||||
|
|
@ -46,8 +46,10 @@ ANDROID_TRIGGERED_SYNC=PASS
|
||||||
ANDROID_SYNC_RECEIPT=PASS
|
ANDROID_SYNC_RECEIPT=PASS
|
||||||
TUI_SYNC_LOG_SHOW=PASS
|
TUI_SYNC_LOG_SHOW=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
|
MULTI_OCCURRENCE_SESSION_V2=PASS
|
||||||
|
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
||||||
|
|
||||||
DESKTOP_TESTS=22/22 PASS
|
DESKTOP_TESTS=25/25 PASS
|
||||||
ANDROID_BUILD=PASS
|
ANDROID_BUILD=PASS
|
||||||
HARDWARE_SYNC_VALIDATION=PASS
|
HARDWARE_SYNC_VALIDATION=PASS
|
||||||
```
|
```
|
||||||
|
|
@ -57,13 +59,16 @@ HARDWARE_SYNC_VALIDATION=PASS
|
||||||
Implemented:
|
Implemented:
|
||||||
|
|
||||||
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
|
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
|
||||||
- SQLite schema v5;
|
- SQLite schema v7, with stable ordered `session_exercises.entry_id` and
|
||||||
|
occurrence-level equipment identity;
|
||||||
- direct session entry;
|
- direct session entry;
|
||||||
- persisted session detail and editing;
|
- persisted session detail and editing;
|
||||||
- exercise removal from a session through transactional child replacement;
|
- exercise removal from a session through transactional child replacement;
|
||||||
- exercise catalog;
|
- exercise catalog;
|
||||||
- profile-aware set and continuous activities;
|
- profile-aware set and continuous activities;
|
||||||
- heterogeneous repetition sets;
|
- heterogeneous repetition sets;
|
||||||
|
- multiple occurrences of one catalogue exercise in a session;
|
||||||
|
- per-set actual loads with distinct external/assistance semantics;
|
||||||
- body-observation creation/history/editing;
|
- body-observation creation/history/editing;
|
||||||
- body graphs and normalized overlays;
|
- body graphs and normalized overlays;
|
||||||
- exercise performance history;
|
- exercise performance history;
|
||||||
|
|
@ -87,7 +92,7 @@ Primary navigation:
|
||||||
Implemented:
|
Implemented:
|
||||||
|
|
||||||
- native Kotlin/Compose application;
|
- native Kotlin/Compose application;
|
||||||
- local SQLite database v4, with non-destructive v3 -> v4 migration;
|
- local SQLite database v7, with non-destructive v3 -> v7 migration;
|
||||||
- one durable active-session draft, Home resume and raw-form restoration;
|
- one durable active-session draft, Home resume and raw-form restoration;
|
||||||
- explicit confirmed discard and atomic completed-save/draft-clear;
|
- explicit confirmed discard and atomic completed-save/draft-clear;
|
||||||
- exercise creation;
|
- exercise creation;
|
||||||
|
|
@ -97,6 +102,8 @@ Implemented:
|
||||||
- heterogeneous repetition-set entry;
|
- heterogeneous repetition-set entry;
|
||||||
- exercise removal from the current session draft;
|
- exercise removal from the current session draft;
|
||||||
- continuous activity recording;
|
- continuous activity recording;
|
||||||
|
- shared equipment selection, local custom equipment creation and occurrence
|
||||||
|
equipment persistence;
|
||||||
- local session history/detail;
|
- local session history/detail;
|
||||||
- body measurements;
|
- body measurements;
|
||||||
- automatic mobile snapshot maintenance;
|
- automatic mobile snapshot maintenance;
|
||||||
|
|
@ -127,10 +134,13 @@ Artifacts:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Android -> PC
|
Android -> PC
|
||||||
trainlog-mobile-export-v1.json
|
trainlog-mobile-export-v2.json
|
||||||
|
trainlog-equipment-associations-v2.json
|
||||||
|
|
||||||
PC -> Android
|
PC -> Android
|
||||||
trainlog-pc-catalog-v1.json
|
trainlog-pc-catalog-v1.json
|
||||||
|
trainlog-pc-mobile-export-v2.json
|
||||||
|
trainlog-equipment-associations-v2.json
|
||||||
|
|
||||||
Android -> PC agent
|
Android -> PC agent
|
||||||
trainlog-sync-request-v1.json
|
trainlog-sync-request-v1.json
|
||||||
|
|
@ -141,6 +151,10 @@ PC agent -> Android
|
||||||
|
|
||||||
The desktop TUI and `trainlog-syncd` share `trainlog_sync_run()`.
|
The desktop TUI and `trainlog-syncd` share `trainlog_sync_run()`.
|
||||||
|
|
||||||
|
V2 resolves equipment by `(session_id, entry_id)`, never by display name or
|
||||||
|
catalogue identity alone. V1 files remain legacy-compatible and do not gain
|
||||||
|
multi-occurrence semantics retroactively.
|
||||||
|
|
||||||
No SQLite file is copied.
|
No SQLite file is copied.
|
||||||
|
|
||||||
No mounted Android filesystem is required.
|
No mounted Android filesystem is required.
|
||||||
|
|
@ -150,7 +164,7 @@ No mounted Android filesystem is required.
|
||||||
Desktop:
|
Desktop:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
22/22 Meson tests PASS
|
25/25 Meson tests PASS
|
||||||
frozen JSON validator PASS
|
frozen JSON validator PASS
|
||||||
import-contract validator PASS
|
import-contract validator PASS
|
||||||
git diff --check PASS
|
git diff --check PASS
|
||||||
|
|
@ -159,8 +173,9 @@ git diff --check PASS
|
||||||
Android:
|
Android:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
assembleDebug PASS
|
assembleDebug and Android unit tests are run for every Android delivery.
|
||||||
host repository tests 8/8 PASS
|
The prior device baseline below is hardware evidence, not a claim that every
|
||||||
|
new implementation detail was re-exercised on the device in this document.
|
||||||
device instrumentation 5/5 PASS
|
device instrumentation 5/5 PASS
|
||||||
real Samsung background/process-death/force-stop/resume matrix PASS
|
real Samsung background/process-death/force-stop/resume matrix PASS
|
||||||
real migration and original user-data preservation PASS
|
real migration and original user-data preservation PASS
|
||||||
|
|
@ -191,7 +206,7 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS
|
||||||
WORKING_LOAD_PERCENTAGES=PASS
|
WORKING_LOAD_PERCENTAGES=PASS
|
||||||
ASSISTANCE_DIRECTION_AWARE=PASS
|
ASSISTANCE_DIRECTION_AWARE=PASS
|
||||||
ANDROID_MAX_TEST_SESSION=PASS
|
ANDROID_MAX_TEST_SESSION=PASS
|
||||||
DESKTOP_TESTS=22/22 PASS
|
DESKTOP_TESTS=25/25 PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
A measured maximum is derived only from explicit `max_test` sessions. Ordinary
|
A measured maximum is derived only from explicit `max_test` sessions. Ordinary
|
||||||
|
|
@ -212,7 +227,7 @@ BODY_COMPOSITION_ESTIMATE=PASS
|
||||||
BODY_PROPORTION_RATIOS=PASS
|
BODY_PROPORTION_RATIOS=PASS
|
||||||
BODY_SYMMETRY_ANALYTICS=PASS
|
BODY_SYMMETRY_ANALYTICS=PASS
|
||||||
NO_ESTIMATE_PERSISTENCE=PASS
|
NO_ESTIMATE_PERSISTENCE=PASS
|
||||||
DESKTOP_TESTS=22/22 PASS
|
DESKTOP_TESTS=25/25 PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
Android remains capture-only for this feature.
|
Android remains capture-only for this feature.
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
## 1. Status
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION=5
|
TRAINLOG_DATABASE_SCHEMA_VERSION=7
|
||||||
DATABASE_SCHEMA_V5=PASS
|
DATABASE_SCHEMA_V7=PASS
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -23,13 +23,19 @@ PRAGMA user_version;
|
||||||
Current value:
|
Current value:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
5
|
7
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported historical databases are migrated explicitly through the implemented
|
Supported historical databases are migrated explicitly through the implemented
|
||||||
migration chain. A database newer than the running binary understands is
|
migration chain. A database newer than the running binary understands is
|
||||||
rejected.
|
rejected.
|
||||||
|
|
||||||
|
Version 7 assigns `session_exercises.entry_id` to each stable occurrence.
|
||||||
|
`exercise_id` remains only the catalogue identity and may occur more than once
|
||||||
|
in a session. The v6 → v7 migration rebuilds the obsolete uniqueness
|
||||||
|
constraint while retaining rows, sets, continuous activities, weights and
|
||||||
|
equipment associations.
|
||||||
|
|
||||||
A schema fixture must represent the real historical structure. Rewriting only
|
A schema fixture must represent the real historical structure. Rewriting only
|
||||||
`user_version` is not an acceptable migration test.
|
`user_version` is not an acceptable migration test.
|
||||||
|
|
||||||
|
|
@ -83,6 +89,7 @@ Ordered exercise occurrence inside one session.
|
||||||
```text
|
```text
|
||||||
session_row_id
|
session_row_id
|
||||||
exercise_row_id
|
exercise_row_id
|
||||||
|
entry_id UNIQUE stable occurrence identity
|
||||||
recording_mode
|
recording_mode
|
||||||
data_fields
|
data_fields
|
||||||
position
|
position
|
||||||
|
|
@ -92,6 +99,7 @@ target_sets
|
||||||
target_reps
|
target_reps
|
||||||
target_duration_seconds
|
target_duration_seconds
|
||||||
target_weight_kg
|
target_weight_kg
|
||||||
|
equipment_id nullable canonical equipment identity
|
||||||
notes
|
notes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -296,11 +304,11 @@ Migration-specific regression coverage includes:
|
||||||
schema_v5_migration
|
schema_v5_migration
|
||||||
```
|
```
|
||||||
|
|
||||||
The current normal desktop suite contains 22 tests.
|
The current normal desktop suite contains 25 tests.
|
||||||
|
|
||||||
## 11. Measured-max derivation
|
## 11. Measured-max derivation
|
||||||
|
|
||||||
Measured maxima require no desktop schema v6.
|
Measured maxima require no schema change beyond the current desktop schema v7.
|
||||||
|
|
||||||
The existing `sessions.session_type = max_test` classification plus actual
|
The existing `sessions.session_type = max_test` classification plus actual
|
||||||
`performed_sets` are sufficient.
|
`performed_sets` are sufficient.
|
||||||
|
|
@ -335,7 +343,21 @@ No extra maximum row is persisted; results are derived from canonical history.
|
||||||
|
|
||||||
## 12. Body analytics persistence rule
|
## 12. Body analytics persistence rule
|
||||||
|
|
||||||
Body analytics require no schema v6.
|
## 13. Equipment and occurrence migration
|
||||||
|
|
||||||
|
Desktop schema v6 added nullable `session_exercises.equipment_id`, which stores
|
||||||
|
a canonical manifest ID rather than a local SQLite row ID. Schema v7 adds the
|
||||||
|
non-null stable `entry_id` and removes the obsolete
|
||||||
|
`UNIQUE(session_row_id, exercise_row_id)` constraint. The v6 -> v7 rebuild
|
||||||
|
preserves primary keys, completed sessions, ordered sets, continuous activities,
|
||||||
|
per-set weights and equipment values.
|
||||||
|
|
||||||
|
Android schema v6 added nullable `weight_kg` to completed and durable draft
|
||||||
|
set rows. Schema v7 assigns stable `entry_id` values to completed and draft
|
||||||
|
occurrences. Actual per-set weights remain independent values, so heterogeneous
|
||||||
|
sets and weights survive edit, finalization, reopen and V2 exchange.
|
||||||
|
|
||||||
|
Body analytics require no schema change beyond schema v7.
|
||||||
|
|
||||||
Canonical persistence continues to contain only measurements actually entered
|
Canonical persistence continues to contain only measurements actually entered
|
||||||
by the user.
|
by the user.
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ GATE_1=PASS
|
||||||
GATE_2=PASS
|
GATE_2=PASS
|
||||||
|
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
DESKTOP_SCHEMA_V5=PASS
|
DESKTOP_SCHEMA_V7=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V4=PASS
|
ANDROID_LOCAL_DATABASE_V7=PASS
|
||||||
|
|
||||||
DIRECT_MTP_TRANSPORT=PASS
|
DIRECT_MTP_TRANSPORT=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
|
|
@ -26,7 +26,7 @@ BODY_ANALYTICS_V1=PASS
|
||||||
EXERCISE_EDIT_V1=PASS
|
EXERCISE_EDIT_V1=PASS
|
||||||
ANDROID_BANNER_PARITY_V1=PASS
|
ANDROID_BANNER_PARITY_V1=PASS
|
||||||
|
|
||||||
DESKTOP_TESTS=22/22 PASS
|
DESKTOP_TESTS=25/25 PASS
|
||||||
TUI_NOTCURSES_V1=PASS
|
TUI_NOTCURSES_V1=PASS
|
||||||
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
||||||
NOTCURSES_TRUECOLOR_THEME=PASS
|
NOTCURSES_TRUECOLOR_THEME=PASS
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ ANDROID_TRIGGERED_SYNC=PASS
|
||||||
ANDROID_SYNC_RECEIPT=PASS
|
ANDROID_SYNC_RECEIPT=PASS
|
||||||
TUI_SYNC_LOG_SHOW=PASS
|
TUI_SYNC_LOG_SHOW=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
|
MULTI_OCCURRENCE_SESSION_V2=PASS
|
||||||
|
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
||||||
|
|
||||||
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
|
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
|
||||||
```
|
```
|
||||||
|
|
@ -37,12 +39,23 @@ Framework folder grant.
|
||||||
| Direction | File | Format |
|
| Direction | File | Format |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 |
|
| Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 |
|
||||||
|
| Android -> PC | `trainlog-mobile-export-v2.json` | `trainlog-mobile-export` v2 (active) |
|
||||||
|
| Android -> PC | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
||||||
| PC -> Android | `trainlog-pc-catalog-v1.json` | `trainlog-pc-catalog` v1 |
|
| PC -> Android | `trainlog-pc-catalog-v1.json` | `trainlog-pc-catalog` v1 |
|
||||||
|
| PC -> Android | `trainlog-pc-mobile-export-v2.json` | `trainlog-mobile-export` v2 |
|
||||||
|
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
||||||
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||||
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
|
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
|
||||||
|
|
||||||
No SQLite file is transferred.
|
No SQLite file is transferred.
|
||||||
|
|
||||||
|
V2 is a separate format: every session entry has an `entry_id`, `position`,
|
||||||
|
metrics, actual loads and optional equipment identity. This permits two
|
||||||
|
occurrences of the same exercise without fusion. V1 remains readable with its
|
||||||
|
frozen contract. A V1 historical session is reconciled with V2 only when the
|
||||||
|
exercise/order correspondence is unambiguous; otherwise the importer reports
|
||||||
|
a conflict rather than silently overwriting data.
|
||||||
|
|
||||||
## 4. Android -> PC mobile snapshot
|
## 4. Android -> PC mobile snapshot
|
||||||
|
|
||||||
Header:
|
Header:
|
||||||
|
|
@ -50,7 +63,7 @@ Header:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"format": "trainlog-mobile-export",
|
"format": "trainlog-mobile-export",
|
||||||
"version": 1
|
"version": 2
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -62,6 +75,20 @@ sessions
|
||||||
body_observations
|
body_observations
|
||||||
```
|
```
|
||||||
|
|
||||||
|
V2 session entries additionally carry:
|
||||||
|
|
||||||
|
```text
|
||||||
|
entry_id stable occurrence identity
|
||||||
|
position stable order within session
|
||||||
|
equipment_id optional canonical equipment identity
|
||||||
|
weight_kg optional actual value on each set
|
||||||
|
```
|
||||||
|
|
||||||
|
The desktop imports sessions first, preserving `entry_id`, then applies the
|
||||||
|
equipment companion only after all referenced entries exist. Reimporting either
|
||||||
|
artifact reconciles stable identities; it neither duplicates sessions nor
|
||||||
|
regenerates occurrence IDs.
|
||||||
|
|
||||||
Exercise profile fields:
|
Exercise profile fields:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -234,7 +261,10 @@ One synchronization transaction performs:
|
||||||
```text
|
```text
|
||||||
mobile snapshot download
|
mobile snapshot download
|
||||||
-> mobile import
|
-> mobile import
|
||||||
|
-> equipment companion import by (session_id, entry_id)
|
||||||
-> PC catalog export
|
-> PC catalog export
|
||||||
|
-> PC mobile V2 export
|
||||||
|
-> PC equipment companion V2 export
|
||||||
-> PC catalog MTP publication
|
-> PC catalog MTP publication
|
||||||
-> optional receipt publication
|
-> optional receipt publication
|
||||||
-> structured run history
|
-> structured run history
|
||||||
|
|
@ -315,6 +345,22 @@ overloading frozen Trainlog JSON v1
|
||||||
|
|
||||||
## 14. Hardware validation
|
## 14. Hardware validation
|
||||||
|
|
||||||
|
## 15. Equipment associations V2 and legacy V1
|
||||||
|
|
||||||
|
`TRAINLOG_FORMAT_V1` remains frozen. The active companion is
|
||||||
|
`trainlog-equipment-associations-v2.json`, format
|
||||||
|
`trainlog-equipment-associations`, version `2`. Each row is identified by
|
||||||
|
`(session_id, entry_id)` and contains `exercise_id` as consistency metadata,
|
||||||
|
then either `state: set` with a canonical `equipment_id`, or `state: cleared`
|
||||||
|
for an intentional removal. A missing companion conveys no equipment
|
||||||
|
information and cannot clear a previously known choice. Unknown canonical IDs,
|
||||||
|
unknown entries and ambiguous identities reject the companion transaction
|
||||||
|
explicitly; an unknown equipment reference is never silently changed to null.
|
||||||
|
|
||||||
|
The historical V1 companion remains readable only where its
|
||||||
|
`(session_id, exercise_id)` targeting is unambiguous. It cannot represent two
|
||||||
|
occurrences of the same exercise in one session and is not redefined to do so.
|
||||||
|
|
||||||
Validated on the physical Android device:
|
Validated on the physical Android device:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -63,32 +63,35 @@ Current normal suite:
|
||||||
```text
|
```text
|
||||||
1 database
|
1 database
|
||||||
2 catalog
|
2 catalog
|
||||||
3 session_detail
|
3 equipment_catalog
|
||||||
4 duration
|
4 session_detail
|
||||||
5 body_metrics
|
5 duration
|
||||||
6 bodyviz
|
6 body_metrics
|
||||||
7 exercise_performance
|
7 bodyviz
|
||||||
8 session_type_schema
|
8 exercise_performance
|
||||||
9 session_edit
|
9 session_type_schema
|
||||||
10 body_observation_edit
|
10 session_edit
|
||||||
11 mtp
|
11 body_observation_edit
|
||||||
12 continuous_session
|
12 mtp
|
||||||
13 continuous_detail
|
13 continuous_session
|
||||||
14 reps
|
14 continuous_detail
|
||||||
15 exercise_profile_schema
|
15 exercise_profile_schema
|
||||||
16 usb
|
16 usb
|
||||||
17 variable_sets
|
17 reps
|
||||||
18 schema_v5_migration
|
18 variable_sets
|
||||||
19 measured_max
|
19 schema_v5_migration
|
||||||
20 body_analytics
|
20 measured_max
|
||||||
21 terminal_input_event_type_policy
|
21 body_analytics
|
||||||
22 mobile_import_variable_sets
|
22 terminal_input_event_type_policy
|
||||||
|
23 mobile_import_multi_occurrence
|
||||||
|
24 equipment_associations_exchange
|
||||||
|
25 mobile_import_variable_sets
|
||||||
```
|
```
|
||||||
|
|
||||||
Validated checkpoint:
|
Validated checkpoint:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
22/22 PASS
|
25/25 PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
The desktop executable is additionally smoke-checked in isolated tmux PTYs at
|
The desktop executable is additionally smoke-checked in isolated tmux PTYs at
|
||||||
|
|
@ -104,12 +107,17 @@ Notable regression coverage:
|
||||||
- profile-aware exercise constraints;
|
- profile-aware exercise constraints;
|
||||||
- continuous activity without fake sets;
|
- continuous activity without fake sets;
|
||||||
- repetition shorthand/list/pyramid parsing;
|
- repetition shorthand/list/pyramid parsing;
|
||||||
- direct v4 -> v5 database migration;
|
- direct v4 -> v7 database migration;
|
||||||
- heterogeneous mobile-set import;
|
- heterogeneous mobile-set import;
|
||||||
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
|
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
|
||||||
RELEASE event is consumed without creating a second navigation action.
|
RELEASE event is consumed without creating a second navigation action.
|
||||||
- targetless mobile SETS persistence;
|
- targetless mobile SETS persistence;
|
||||||
- mobile-import idempotence.
|
- mobile-import idempotence.
|
||||||
|
- V2 mobile import with repeated exercise occurrences and stable `entry_id`;
|
||||||
|
- companion equipment import after session import, including idempotent
|
||||||
|
reimport and independent associations for repeated occurrences;
|
||||||
|
- rejection of the historical equipment-import invocation without its required
|
||||||
|
`--database` target, followed by the corrected complete V2 export chain.
|
||||||
- stable-ID mobile-to-desktop rename reconciliation without duplicate catalog
|
- stable-ID mobile-to-desktop rename reconciliation without duplicate catalog
|
||||||
rows or historical-reference replacement.
|
rows or historical-reference replacement.
|
||||||
|
|
||||||
|
|
@ -258,7 +266,7 @@ Coverage proves:
|
||||||
Current normal baseline:
|
Current normal baseline:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
22/22 PASS
|
25/25 PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
## 12. Body analytics regression
|
## 12. Body analytics regression
|
||||||
|
|
@ -283,16 +291,18 @@ Coverage includes:
|
||||||
Current normal baseline:
|
Current normal baseline:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
22/22 PASS
|
25/25 PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
## 13. Android session draft v1
|
## 13. Android session draft v1
|
||||||
|
|
||||||
Android schema v4 adds one durable active draft with an explicit additive v3 ->
|
Android schema v4 introduced one durable active draft; the current additive
|
||||||
v4 migration. The current host suite has **8 tests**, covering all exercise
|
chain reaches schema v7 without clearing completed history or the draft. The
|
||||||
|
current host suite covers exercise
|
||||||
shapes and raw partial text, fresh repository restore, remove/discard, atomic
|
shapes and raw partial text, fresh repository restore, remove/discard, atomic
|
||||||
finalization and repeated-finalize rejection, rollback, catalog reconciliation,
|
finalization and repeated-finalize rejection, rollback, catalog reconciliation,
|
||||||
missing-selection recovery, explicit DB-open failure and historical migration.
|
missing-selection recovery, explicit DB-open failure, historical migration,
|
||||||
|
equipment selection and occurrence identity.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd android
|
cd android
|
||||||
|
|
|
||||||
41
tests/test_equipment_associations_exchange.py
Normal file
41
tests/test_equipment_associations_exchange.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Exercise the real desktop companion exporter/importer on temporary DBs."""
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def make_db(path, equipment):
|
||||||
|
db = sqlite3.connect(path)
|
||||||
|
db.executescript("""
|
||||||
|
CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT UNIQUE);
|
||||||
|
CREATE TABLE sessions(id INTEGER PRIMARY KEY, session_id TEXT UNIQUE, started_at TEXT);
|
||||||
|
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY, entry_id TEXT UNIQUE, session_row_id INTEGER, exercise_row_id INTEGER, position INTEGER, equipment_id TEXT);
|
||||||
|
""")
|
||||||
|
db.execute("INSERT INTO exercises VALUES(1,'ex_fixture')")
|
||||||
|
db.execute("INSERT INTO sessions VALUES(1,'se_fixture','2026-01-01T00:00:00+00:00')")
|
||||||
|
db.execute("INSERT INTO session_exercises VALUES(1,'sxe_fixture',1,1,0,?)", (equipment,))
|
||||||
|
db.execute("PRAGMA user_version=7")
|
||||||
|
db.commit(); db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
directory = Path(directory)
|
||||||
|
source, target, artifact = directory / 'source.db', directory / 'target.db', directory / 'equipment.json'
|
||||||
|
make_db(source, 'leg_press'); make_db(target, None)
|
||||||
|
subprocess.run([sys.executable, ROOT / 'tools/export_equipment_associations.py', artifact, '--database', source], check=True)
|
||||||
|
subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], check=True)
|
||||||
|
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press'
|
||||||
|
payload = json.loads(artifact.read_text()); payload['associations'][0] = {'session_id':'se_fixture','entry_id':'sxe_fixture','exercise_id':'ex_fixture','state':'cleared'}
|
||||||
|
artifact.write_text(json.dumps(payload))
|
||||||
|
subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], check=True)
|
||||||
|
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] is None
|
||||||
|
print('PASS equipment association exchange')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': main()
|
||||||
127
tests/test_mobile_import_multi_occurrence.py
Normal file
127
tests/test_mobile_import_multi_occurrence.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""End-to-end V2 import regression: Walk, other movement, Walk again."""
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
IMPORTER = ROOT / "tools/import_mobile_export.py"
|
||||||
|
EQUIPMENT_IMPORTER = ROOT / "tools/import_equipment_associations.py"
|
||||||
|
CATALOG_EXPORTER = ROOT / "tools/export_pc_catalog.py"
|
||||||
|
MOBILE_EXPORTER = ROOT / "tools/export_pc_mobile.py"
|
||||||
|
EQUIPMENT_EXPORTER = ROOT / "tools/export_equipment_associations.py"
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,normalized_name TEXT UNIQUE,tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER);
|
||||||
|
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 NOT NULL UNIQUE,session_row_id INTEGER,exercise_row_id INTEGER,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,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);
|
||||||
|
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER UNIQUE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);
|
||||||
|
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);
|
||||||
|
PRAGMA user_version=7;
|
||||||
|
"""
|
||||||
|
|
||||||
|
def payload():
|
||||||
|
catalog = [
|
||||||
|
{"exercise_id":"ex_walk","name":"Marche","recording_mode":"continuous","tracking_mode":"duration","data_fields":0},
|
||||||
|
{"exercise_id":"ex_push","name":"Pompes","recording_mode":"sets","tracking_mode":"reps","data_fields":0},
|
||||||
|
]
|
||||||
|
entries = [
|
||||||
|
{"entry_id":"sxe_walk_10","position":0,"exercise_id":"ex_walk","name":"Marche","recording_mode":"continuous","tracking_mode":"duration","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":None,"continuous":{"duration_seconds":600}},
|
||||||
|
{"entry_id":"sxe_push","position":1,"exercise_id":"ex_push","name":"Pompes","recording_mode":"sets","tracking_mode":"reps","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":"leg_press","sets":[{"reps":12,"weight_kg":20.0}]},
|
||||||
|
{"entry_id":"sxe_walk_15","position":2,"exercise_id":"ex_walk","name":"Marche","recording_mode":"continuous","tracking_mode":"duration","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":None,"continuous":{"duration_seconds":900}},
|
||||||
|
]
|
||||||
|
return {"format":"trainlog-mobile-export","version":2,"generated_at":"2026-09-07T10:00:00+02:00","exercises":catalog,"sessions":[{"session_id":"se_multi","started_at":"2026-09-07T10:00:00+02:00","session_type":"training","exercises":entries}],"body_observations":[]}
|
||||||
|
|
||||||
|
def invoke(path, db):
|
||||||
|
result = subprocess.run([sys.executable, str(IMPORTER), str(path), "--database", str(db)], text=True, capture_output=True)
|
||||||
|
if result.returncode: raise AssertionError(result.stdout + result.stderr)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def invoke_equipment(path, db):
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(EQUIPMENT_IMPORTER), str(path), "--database", str(db)],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise AssertionError(result.stdout + result.stderr)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with tempfile.TemporaryDirectory(prefix="trainlog-multi-") as temp:
|
||||||
|
root = Path(temp); db = root / "db.sqlite"; artifact = root / "mobile-v2.json"
|
||||||
|
equipment = root / "equipment-v2.json"
|
||||||
|
con = sqlite3.connect(db); con.executescript(SCHEMA); con.close()
|
||||||
|
artifact.write_text(json.dumps(payload()), encoding="utf-8")
|
||||||
|
assert "sessions_imported=1" in invoke(artifact, db)
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
rows = con.execute("SELECT e.exercise_id,se.entry_id,se.position,ca.duration_seconds FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id ORDER BY se.position").fetchall()
|
||||||
|
assert rows == [("ex_walk","sxe_walk_10",0,600),("ex_push","sxe_push",1,None),("ex_walk","sxe_walk_15",2,900)], rows
|
||||||
|
assert con.execute("SELECT weight_kg FROM performed_sets").fetchone()[0] == 20.0
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
equipment.write_text(json.dumps({
|
||||||
|
"format": "trainlog-equipment-associations",
|
||||||
|
"version": 2,
|
||||||
|
"generated_at": "2026-09-07T10:00:00+02:00",
|
||||||
|
"associations": [
|
||||||
|
{"session_id": "se_multi", "entry_id": "sxe_walk_10", "exercise_id": "ex_walk", "state": "set", "equipment_id": "treadmill"},
|
||||||
|
{"session_id": "se_multi", "entry_id": "sxe_push", "exercise_id": "ex_push", "state": "set", "equipment_id": "leg_press"},
|
||||||
|
{"session_id": "se_multi", "entry_id": "sxe_walk_15", "exercise_id": "ex_walk", "state": "cleared"},
|
||||||
|
],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
# Regression for the real failure: the historic TUI invocation passed
|
||||||
|
# only the artifact, while the importer requires the target database.
|
||||||
|
rejected = subprocess.run(
|
||||||
|
[sys.executable, str(EQUIPMENT_IMPORTER), str(equipment)],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
assert rejected.returncode != 0
|
||||||
|
assert "the following arguments are required: --database" in rejected.stderr
|
||||||
|
|
||||||
|
assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in invoke_equipment(equipment, db)
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
rows = con.execute(
|
||||||
|
"SELECT entry_id,equipment_id FROM session_exercises ORDER BY position"
|
||||||
|
).fetchall()
|
||||||
|
assert rows == [
|
||||||
|
("sxe_walk_10", "treadmill"),
|
||||||
|
("sxe_push", "leg_press"),
|
||||||
|
("sxe_walk_15", None),
|
||||||
|
], rows
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
assert "sessions_reconciled=1" in invoke(artifact, db)
|
||||||
|
assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in invoke_equipment(equipment, db)
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
assert con.execute("SELECT count(*) FROM session_exercises").fetchone()[0] == 3
|
||||||
|
assert con.execute(
|
||||||
|
"SELECT entry_id,equipment_id FROM session_exercises ORDER BY position"
|
||||||
|
).fetchall() == rows
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
catalog_output = root / "pc-catalog.json"
|
||||||
|
mobile_output = root / "pc-mobile-v2.json"
|
||||||
|
equipment_output = root / "pc-equipment-v2.json"
|
||||||
|
for tool, output, marker in (
|
||||||
|
(CATALOG_EXPORTER, catalog_output, "PC_CATALOG_EXPORT=PASS"),
|
||||||
|
(MOBILE_EXPORTER, mobile_output, "PC_MOBILE_EXPORT=PASS"),
|
||||||
|
(EQUIPMENT_EXPORTER, equipment_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS"),
|
||||||
|
):
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(tool), str(output), "--database", str(db)],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
assert marker in result.stdout
|
||||||
|
print("PASS mobile_import_multi_occurrence")
|
||||||
|
|
||||||
|
if __name__ == "__main__": main()
|
||||||
45
tools/export_equipment_associations.py
Normal file
45
tools/export_equipment_associations.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Export stable desktop session/exercise equipment associations."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("output", type=Path)
|
||||||
|
parser.add_argument("--database", type=Path,
|
||||||
|
default=Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db")
|
||||||
|
args = parser.parse_args()
|
||||||
|
connection = sqlite3.connect(args.database)
|
||||||
|
try:
|
||||||
|
if connection.execute("PRAGMA user_version;").fetchone()[0] != 7:
|
||||||
|
raise ValueError("schema desktop v7 requis")
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT s.session_id,se.entry_id,e.exercise_id,se.equipment_id FROM session_exercises se "
|
||||||
|
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
||||||
|
"ORDER BY s.started_at,s.id,se.position").fetchall()
|
||||||
|
payload = {"format": "trainlog-equipment-associations", "version": 2,
|
||||||
|
"generated_at": datetime.now().astimezone().isoformat(), "associations": []}
|
||||||
|
for session_id, entry_id, exercise_id, equipment_id in rows:
|
||||||
|
item = {"session_id": session_id, "entry_id": entry_id, "exercise_id": exercise_id,
|
||||||
|
"state": "set" if equipment_id is not None else "cleared"}
|
||||||
|
if equipment_id is not None:
|
||||||
|
item["equipment_id"] = equipment_id
|
||||||
|
payload["associations"].append(item)
|
||||||
|
args.output.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||||
|
print("EQUIPMENT_ASSOCIATIONS_EXPORT=PASS")
|
||||||
|
print(f"associations={len(rows)}")
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as error:
|
||||||
|
print(f"EQUIPMENT_ASSOCIATIONS_EXPORT=FAIL {error}")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
@ -61,7 +61,7 @@ def main():
|
||||||
"PRAGMA user_version;"
|
"PRAGMA user_version;"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
|
||||||
if version != 5:
|
if version != 7:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
66
tools/export_pc_mobile.py
Normal file
66
tools/export_pc_mobile.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish desktop sessions through the occurrence-aware mobile export V2."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def default_database():
|
||||||
|
return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("output", type=Path)
|
||||||
|
parser.add_argument("--database", type=Path, default=default_database())
|
||||||
|
args = parser.parse_args()
|
||||||
|
con = sqlite3.connect(args.database)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
if con.execute("PRAGMA user_version").fetchone()[0] != 7:
|
||||||
|
raise ValueError("schema desktop v7 requis")
|
||||||
|
root = {"format": "trainlog-mobile-export", "version": 2,
|
||||||
|
"generated_at": datetime.now().astimezone().isoformat(),
|
||||||
|
"exercises": [], "sessions": [], "body_observations": []}
|
||||||
|
for row in con.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises ORDER BY exercise_id"):
|
||||||
|
root["exercises"].append(dict(row))
|
||||||
|
for session in con.execute("SELECT id,session_id,started_at,session_type FROM sessions ORDER BY started_at,id"):
|
||||||
|
payload = {key: session[key] for key in ("session_id", "started_at", "session_type")}
|
||||||
|
payload["exercises"] = []
|
||||||
|
# INVARIANT: tracking mode is catalogue metadata. v7 occurrences
|
||||||
|
# retain their stable entry_id but do not duplicate that field.
|
||||||
|
sql = "SELECT se.id,se.entry_id,se.position,se.recording_mode,e.tracking_mode,se.data_fields,se.equipment_id,e.exercise_id,e.name FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position"
|
||||||
|
for entry in con.execute(sql, (session["id"],)):
|
||||||
|
item = {"entry_id": entry["entry_id"], "position": entry["position"],
|
||||||
|
"exercise_id": entry["exercise_id"], "name": entry["name"],
|
||||||
|
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
|
||||||
|
"data_fields": entry["data_fields"], "load_mode": "none", "rest_seconds": 0,
|
||||||
|
"equipment_id": entry["equipment_id"]}
|
||||||
|
if entry["recording_mode"] == "continuous":
|
||||||
|
activity = con.execute("SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", (entry["id"],)).fetchone()
|
||||||
|
if activity is None: raise ValueError("activité continue absente")
|
||||||
|
item["continuous"] = {key: activity[key] for key in activity.keys() if activity[key] is not None}
|
||||||
|
else:
|
||||||
|
item["sets"] = []
|
||||||
|
for value in con.execute("SELECT reps,duration_seconds,weight_kg FROM performed_sets WHERE session_exercise_row_id=? ORDER BY position", (entry["id"],)):
|
||||||
|
metric = "reps" if entry["tracking_mode"] == "reps" else "duration_seconds"
|
||||||
|
set_value = {metric: value[metric]}
|
||||||
|
if value["weight_kg"] is not None: set_value["weight_kg"] = value["weight_kg"]
|
||||||
|
item["sets"].append(set_value)
|
||||||
|
payload["exercises"].append(item)
|
||||||
|
root["sessions"].append(payload)
|
||||||
|
args.output.write_text(json.dumps(root, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||||
|
print("PC_MOBILE_EXPORT=PASS")
|
||||||
|
print("sessions=" + str(len(root["sessions"])))
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try: main()
|
||||||
|
except Exception as exc:
|
||||||
|
print("PC_MOBILE_EXPORT=FAIL " + str(exc))
|
||||||
|
raise SystemExit(1)
|
||||||
72
tools/generate_equipment_catalog.py
Normal file
72
tools/generate_equipment_catalog.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the desktop C catalogue from the one versioned JSON source."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
src, out = sys.argv[1:]
|
||||||
|
root = json.load(open(src, encoding="utf-8"))
|
||||||
|
if root.get("format") != "trainlog-equipment-catalog" or root.get("version") != 1:
|
||||||
|
raise SystemExit("unsupported equipment catalogue")
|
||||||
|
items = root.get("equipment")
|
||||||
|
if not isinstance(items, list):
|
||||||
|
raise SystemExit("equipment must be a list")
|
||||||
|
ids = set()
|
||||||
|
for item in items:
|
||||||
|
required = ("id", "label_name", "display_name", "aliases", "type", "load_semantics")
|
||||||
|
if not all(key in item for key in required) or not isinstance(item["aliases"], list):
|
||||||
|
raise SystemExit("invalid equipment entry")
|
||||||
|
if not item["id"] or item["id"] in ids:
|
||||||
|
raise SystemExit("empty or duplicate equipment id")
|
||||||
|
ids.add(item["id"])
|
||||||
|
if item["load_semantics"] not in {"external", "assistance", "bodyweight", "cardio"}:
|
||||||
|
raise SystemExit("invalid load semantics")
|
||||||
|
for relation in root.get("exercise_equipment", []):
|
||||||
|
if relation.get("equipment_id") not in ids:
|
||||||
|
raise SystemExit("relation references unknown equipment")
|
||||||
|
|
||||||
|
def c(value):
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
|
||||||
|
with open(out, "w", encoding="utf-8") as f:
|
||||||
|
f.write('#include "trainlog/equipment_catalog.h"\n#include <string.h>\n\n')
|
||||||
|
f.write('static const TrainlogEquipment entries[] = {\n')
|
||||||
|
for item in items:
|
||||||
|
f.write(' {%s, %s, %s, %s, %s, %s},\n' % (
|
||||||
|
c(item['id']), c(item['label_name']), c(item['display_name']),
|
||||||
|
c(item['type']), c(item['load_semantics']), c('\n'.join(item['aliases']))))
|
||||||
|
f.write('};\nstatic const TrainlogExerciseEquipmentRelation relations[] = {\n')
|
||||||
|
for rel in root.get('exercise_equipment', []):
|
||||||
|
f.write(' {%s, %s, %s},\n' % (c(rel['exercise_id']), c(rel['equipment_id']), c(rel['load_semantics'])))
|
||||||
|
f.write('};\n')
|
||||||
|
f.write(r'''
|
||||||
|
static int folded_contains(const char *haystack, const char *needle) {
|
||||||
|
size_t i, j;
|
||||||
|
if (needle[0] == '\0') return 1;
|
||||||
|
for (i = 0; haystack[i] != '\0'; ++i) {
|
||||||
|
for (j = 0; needle[j] != '\0'; ++j) {
|
||||||
|
unsigned char a = (unsigned char)haystack[i + j];
|
||||||
|
unsigned char b = (unsigned char)needle[j];
|
||||||
|
if (a >= 'A' && a <= 'Z') a = (unsigned char)(a + ('a' - 'A'));
|
||||||
|
if (b >= 'A' && b <= 'Z') b = (unsigned char)(b + ('a' - 'A'));
|
||||||
|
if (a == '\0' || a != b) break;
|
||||||
|
}
|
||||||
|
if (needle[j] == '\0') return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
size_t trainlog_equipment_catalog_count(void) { return sizeof(entries) / sizeof(entries[0]); }
|
||||||
|
const TrainlogEquipment *trainlog_equipment_catalog_at(size_t i) { return i < trainlog_equipment_catalog_count() ? &entries[i] : NULL; }
|
||||||
|
const TrainlogEquipment *trainlog_equipment_catalog_lookup(const char *id) {
|
||||||
|
size_t i; if (id == NULL) return NULL;
|
||||||
|
for (i = 0; i < trainlog_equipment_catalog_count(); ++i) if (strcmp(entries[i].equipment_id, id) == 0) return &entries[i];
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
size_t trainlog_equipment_catalog_search(const char *q, const TrainlogEquipment **out, size_t cap) {
|
||||||
|
size_t i, found = 0; if (q == NULL || out == NULL) return 0;
|
||||||
|
for (i = 0; i < trainlog_equipment_catalog_count(); ++i) if (folded_contains(entries[i].label_name, q) || folded_contains(entries[i].display_name, q) || folded_contains(entries[i].aliases, q)) { if (found < cap) out[found] = &entries[i]; ++found; }
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
size_t trainlog_equipment_catalog_relation_count(void) { return sizeof(relations) / sizeof(relations[0]); }
|
||||||
|
const TrainlogExerciseEquipmentRelation *trainlog_equipment_catalog_relation_at(size_t i) { return i < trainlog_equipment_catalog_relation_count() ? &relations[i] : NULL; }
|
||||||
|
''')
|
||||||
80
tools/import_equipment_associations.py
Normal file
80
tools/import_equipment_associations.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply the versioned equipment companion artifact to a desktop database."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FORMAT = "trainlog-equipment-associations"
|
||||||
|
VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message):
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def load_catalog(path):
|
||||||
|
root = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if root.get("format") != "trainlog-equipment-catalog" or root.get("version") != 1:
|
||||||
|
fail("catalogue équipement non supporté")
|
||||||
|
return {item["id"] for item in root["equipment"]}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("artifact", type=Path)
|
||||||
|
parser.add_argument("--database", type=Path, required=True)
|
||||||
|
parser.add_argument("--catalog", type=Path,
|
||||||
|
default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||||
|
if payload.get("format") != FORMAT or payload.get("version") != VERSION:
|
||||||
|
fail("extension équipement non supportée")
|
||||||
|
if set(payload) != {"format", "version", "generated_at", "associations"}:
|
||||||
|
fail("clés extension équipement invalides")
|
||||||
|
known = load_catalog(args.catalog)
|
||||||
|
connection = sqlite3.connect(args.database)
|
||||||
|
try:
|
||||||
|
if connection.execute("PRAGMA user_version;").fetchone()[0] != 7:
|
||||||
|
fail("schema desktop v7 requis")
|
||||||
|
seen = set()
|
||||||
|
with connection:
|
||||||
|
for item in payload["associations"]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
fail("association invalide")
|
||||||
|
session_id, entry_id, exercise_id, state = (item.get(k) for k in ("session_id", "entry_id", "exercise_id", "state"))
|
||||||
|
key = (session_id, entry_id)
|
||||||
|
if not isinstance(session_id, str) or not session_id or not isinstance(entry_id, str) or not entry_id or not isinstance(exercise_id, str) or not exercise_id or key in seen:
|
||||||
|
fail("identité association invalide ou dupliquée")
|
||||||
|
seen.add(key)
|
||||||
|
if state == "set":
|
||||||
|
if set(item) != {"session_id", "entry_id", "exercise_id", "state", "equipment_id"}:
|
||||||
|
fail("association set invalide")
|
||||||
|
equipment_id = item["equipment_id"]
|
||||||
|
if not isinstance(equipment_id, str) or equipment_id not in known:
|
||||||
|
fail(f"équipement inconnu: {equipment_id}")
|
||||||
|
elif state == "cleared":
|
||||||
|
if set(item) != {"session_id", "entry_id", "exercise_id", "state"}:
|
||||||
|
fail("association cleared invalide")
|
||||||
|
equipment_id = None
|
||||||
|
else:
|
||||||
|
fail("état association invalide")
|
||||||
|
cursor = connection.execute(
|
||||||
|
"UPDATE session_exercises SET equipment_id=? WHERE id=("
|
||||||
|
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id "
|
||||||
|
"WHERE s.session_id=? AND se.entry_id=?)",
|
||||||
|
(equipment_id, session_id, entry_id))
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
fail(f"entrée séance inconnue: {session_id}/{entry_id}")
|
||||||
|
print("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS")
|
||||||
|
print(f"associations={len(seen)}")
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as error:
|
||||||
|
print(f"EQUIPMENT_ASSOCIATIONS_IMPORT=FAIL {error}")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
@ -48,6 +48,10 @@ SESSION_EXERCISE_KEYS = {
|
||||||
"continuous",
|
"continuous",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | {
|
||||||
|
"entry_id", "position", "equipment_id"
|
||||||
|
}
|
||||||
|
|
||||||
BODY_BASE_KEYS = {
|
BODY_BASE_KEYS = {
|
||||||
"observation_id",
|
"observation_id",
|
||||||
"observed_at",
|
"observed_at",
|
||||||
|
|
@ -209,7 +213,7 @@ def load_payload(path):
|
||||||
"format mobile export invalide"
|
"format mobile export invalide"
|
||||||
)
|
)
|
||||||
|
|
||||||
if payload["version"] != VERSION:
|
if payload["version"] not in (1, 2):
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
"version mobile export non supportée"
|
"version mobile export non supportée"
|
||||||
)
|
)
|
||||||
|
|
@ -322,10 +326,11 @@ def validate_set_item(
|
||||||
tracking_mode,
|
tracking_mode,
|
||||||
label,
|
label,
|
||||||
):
|
):
|
||||||
|
allowed_weight = {"weight_kg"}
|
||||||
if tracking_mode == "reps":
|
if tracking_mode == "reps":
|
||||||
require_exact_keys(
|
require_exact_keys(
|
||||||
value,
|
value,
|
||||||
{"reps"},
|
{"reps"} | allowed_weight,
|
||||||
{"reps"},
|
{"reps"},
|
||||||
label,
|
label,
|
||||||
)
|
)
|
||||||
|
|
@ -341,7 +346,7 @@ def validate_set_item(
|
||||||
|
|
||||||
require_exact_keys(
|
require_exact_keys(
|
||||||
value,
|
value,
|
||||||
{"duration_seconds"},
|
{"duration_seconds"} | allowed_weight,
|
||||||
{"duration_seconds"},
|
{"duration_seconds"},
|
||||||
label,
|
label,
|
||||||
)
|
)
|
||||||
|
|
@ -361,14 +366,21 @@ def validate_session_exercise(
|
||||||
label,
|
label,
|
||||||
known_exercise_ids,
|
known_exercise_ids,
|
||||||
):
|
):
|
||||||
|
is_v2 = "entry_id" in item or "position" in item or "equipment_id" in item
|
||||||
require_exact_keys(
|
require_exact_keys(
|
||||||
item,
|
item,
|
||||||
SESSION_EXERCISE_KEYS,
|
V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS,
|
||||||
SESSION_EXERCISE_KEYS
|
(V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS)
|
||||||
- {"sets", "continuous"},
|
- {"sets", "continuous"},
|
||||||
label,
|
label,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if is_v2:
|
||||||
|
require_nonempty_string(item["entry_id"], f"{label}.entry_id")
|
||||||
|
require_int(item["position"], 0, 100000, f"{label}.position")
|
||||||
|
if item["equipment_id"] is not None:
|
||||||
|
require_nonempty_string(item["equipment_id"], f"{label}.equipment_id")
|
||||||
|
|
||||||
exercise_id = require_nonempty_string(
|
exercise_id = require_nonempty_string(
|
||||||
item["exercise_id"],
|
item["exercise_id"],
|
||||||
f"{label}.exercise_id",
|
f"{label}.exercise_id",
|
||||||
|
|
@ -492,6 +504,8 @@ def validate_session_exercise(
|
||||||
)
|
)
|
||||||
|
|
||||||
for set_index, set_item in enumerate(sets):
|
for set_index, set_item in enumerate(sets):
|
||||||
|
if not is_v2 and "weight_kg" in set_item:
|
||||||
|
raise ImportFailure(f"{label}.sets[{set_index}]: poids interdit en v1")
|
||||||
validate_set_item(
|
validate_set_item(
|
||||||
set_item,
|
set_item,
|
||||||
tracking_mode,
|
tracking_mode,
|
||||||
|
|
@ -553,6 +567,7 @@ def validate_sessions(
|
||||||
)
|
)
|
||||||
|
|
||||||
seen_session_exercises = set()
|
seen_session_exercises = set()
|
||||||
|
seen_positions = set()
|
||||||
|
|
||||||
for exercise_index, exercise in enumerate(
|
for exercise_index, exercise in enumerate(
|
||||||
exercises
|
exercises
|
||||||
|
|
@ -568,15 +583,20 @@ def validate_sessions(
|
||||||
)
|
)
|
||||||
|
|
||||||
exercise_id = exercise["exercise_id"]
|
exercise_id = exercise["exercise_id"]
|
||||||
|
identity = exercise.get("entry_id", exercise_id)
|
||||||
|
|
||||||
if exercise_id in seen_session_exercises:
|
if identity in seen_session_exercises:
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"{exercise_label}: exercice dupliqué dans la séance"
|
f"{exercise_label}: identité d'entrée dupliquée dans la séance"
|
||||||
)
|
)
|
||||||
|
|
||||||
seen_session_exercises.add(
|
seen_session_exercises.add(
|
||||||
exercise_id
|
identity
|
||||||
)
|
)
|
||||||
|
if "position" in exercise:
|
||||||
|
if exercise["position"] in seen_positions:
|
||||||
|
raise ImportFailure(f"{exercise_label}: position dupliquée")
|
||||||
|
seen_positions.add(exercise["position"])
|
||||||
|
|
||||||
|
|
||||||
def validate_body(payload):
|
def validate_body(payload):
|
||||||
|
|
@ -647,9 +667,9 @@ def require_schema_v5(connection):
|
||||||
"PRAGMA user_version;"
|
"PRAGMA user_version;"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
|
||||||
if version != 5:
|
if version not in (5, 6, 7):
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"base desktop schema v5 attendue, version trouvée: {version}"
|
f"base desktop schema v5, v6 ou v7 attendue, version trouvée: {version}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -874,10 +894,21 @@ def import_set_session_exercise(
|
||||||
sets = item["sets"]
|
sets = item["sets"]
|
||||||
tracking = item["tracking_mode"]
|
tracking = item["tracking_mode"]
|
||||||
|
|
||||||
|
entry_id = item.get("entry_id")
|
||||||
|
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||||
|
if entry_id is None and schema_version >= 7:
|
||||||
|
entry_id = "sxe_v1_" + str(session_row_id) + "_" + item["exercise_id"]
|
||||||
|
columns = "entry_id, " if entry_id is not None else ""
|
||||||
|
values = "?, " if entry_id is not None else ""
|
||||||
|
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
||||||
|
equipment_values = ", ?" if schema_version >= 6 else ""
|
||||||
|
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position]
|
||||||
|
if schema_version >= 6:
|
||||||
|
arguments.append(item.get("equipment_id"))
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO session_exercises(
|
INSERT INTO session_exercises(
|
||||||
session_row_id,
|
""" + columns + """session_row_id,
|
||||||
exercise_row_id,
|
exercise_row_id,
|
||||||
recording_mode,
|
recording_mode,
|
||||||
data_fields,
|
data_fields,
|
||||||
|
|
@ -887,19 +918,13 @@ def import_set_session_exercise(
|
||||||
target_sets,
|
target_sets,
|
||||||
target_reps,
|
target_reps,
|
||||||
target_duration_seconds,
|
target_duration_seconds,
|
||||||
target_weight_kg,
|
target_weight_kg, notes""" + equipment_columns + """
|
||||||
notes
|
|
||||||
) VALUES(
|
) VALUES(
|
||||||
?, ?, 'sets', ?, ?, 'none', 0,
|
""" + values + """?, ?, 'sets', ?, ?, 'none', 0,
|
||||||
NULL, NULL, NULL, NULL, NULL
|
NULL, NULL, NULL, NULL, NULL""" + equipment_values + """
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
(
|
arguments,
|
||||||
session_row_id,
|
|
||||||
exercise_row,
|
|
||||||
item["data_fields"],
|
|
||||||
position,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
session_exercise_row_id = (
|
session_exercise_row_id = (
|
||||||
|
|
@ -924,13 +949,13 @@ def import_set_session_exercise(
|
||||||
reps,
|
reps,
|
||||||
duration_seconds,
|
duration_seconds,
|
||||||
weight_kg
|
weight_kg
|
||||||
) VALUES(?, ?, ?, ?, NULL);
|
) VALUES(?, ?, ?, ?, ?);
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
session_exercise_row_id,
|
session_exercise_row_id,
|
||||||
set_index,
|
set_index,
|
||||||
reps,
|
reps,
|
||||||
duration,
|
duration, set_item.get("weight_kg"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -941,10 +966,21 @@ def import_continuous_session_exercise(
|
||||||
item,
|
item,
|
||||||
exercise_row,
|
exercise_row,
|
||||||
):
|
):
|
||||||
|
entry_id = item.get("entry_id")
|
||||||
|
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||||
|
if entry_id is None and schema_version >= 7:
|
||||||
|
entry_id = "sxe_v1_" + str(session_row_id) + "_" + item["exercise_id"]
|
||||||
|
columns = "entry_id, " if entry_id is not None else ""
|
||||||
|
values = "?, " if entry_id is not None else ""
|
||||||
|
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
||||||
|
equipment_values = ", ?" if schema_version >= 6 else ""
|
||||||
|
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position]
|
||||||
|
if schema_version >= 6:
|
||||||
|
arguments.append(item.get("equipment_id"))
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO session_exercises(
|
INSERT INTO session_exercises(
|
||||||
session_row_id,
|
""" + columns + """session_row_id,
|
||||||
exercise_row_id,
|
exercise_row_id,
|
||||||
recording_mode,
|
recording_mode,
|
||||||
data_fields,
|
data_fields,
|
||||||
|
|
@ -954,19 +990,13 @@ def import_continuous_session_exercise(
|
||||||
target_sets,
|
target_sets,
|
||||||
target_reps,
|
target_reps,
|
||||||
target_duration_seconds,
|
target_duration_seconds,
|
||||||
target_weight_kg,
|
target_weight_kg, notes""" + equipment_columns + """
|
||||||
notes
|
|
||||||
) VALUES(
|
) VALUES(
|
||||||
?, ?, 'continuous', ?, ?, 'none', 0,
|
""" + values + """?, ?, 'continuous', ?, ?, 'none', 0,
|
||||||
NULL, NULL, NULL, NULL, NULL
|
NULL, NULL, NULL, NULL, NULL""" + equipment_values + """
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
(
|
arguments,
|
||||||
session_row_id,
|
|
||||||
exercise_row,
|
|
||||||
item["data_fields"],
|
|
||||||
position,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
continuous = item["continuous"]
|
continuous = item["continuous"]
|
||||||
|
|
@ -1000,31 +1030,53 @@ def import_sessions(
|
||||||
connection,
|
connection,
|
||||||
session["session_id"],
|
session["session_id"],
|
||||||
):
|
):
|
||||||
report["sessions_skipped"] += 1
|
if payload["version"] == 1:
|
||||||
continue
|
report["sessions_skipped"] += 1
|
||||||
|
continue
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT s.id FROM sessions s WHERE s.session_id=?;",
|
||||||
|
(session["session_id"],)).fetchone()
|
||||||
|
session_row_id = existing[0]
|
||||||
|
incoming = [(x["entry_id"], x["exercise_id"]) for x in session["exercises"]]
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT se.entry_id,e.exercise_id FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position;",
|
||||||
|
(session_row_id,)).fetchall()
|
||||||
|
current = [(row[0], row[1]) for row in rows]
|
||||||
|
legacy = all(value[0].startswith("sxe_legacy_") or value[0].startswith("sxe_v1_") for value in current)
|
||||||
|
# A v1 history may be upgraded only when exercise/order mapping is
|
||||||
|
# unique. Any other identity disagreement is an explicit conflict.
|
||||||
|
if current != incoming and not (legacy and [x[1] for x in current] == [x[1] for x in incoming]):
|
||||||
|
raise ImportFailure("conflit d'identités d'entrées pour " + session["session_id"])
|
||||||
|
# Explicit child deletion makes reconciliation safe even for old
|
||||||
|
# databases which were created without enforced foreign keys.
|
||||||
|
connection.execute("DELETE FROM performed_sets WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
|
||||||
|
connection.execute("DELETE FROM continuous_activity WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
|
||||||
|
connection.execute("DELETE FROM session_exercises WHERE session_row_id=?;", (session_row_id,))
|
||||||
|
report["sessions_reconciled"] += 1
|
||||||
|
else:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions(
|
||||||
|
session_id,
|
||||||
|
started_at,
|
||||||
|
ended_at,
|
||||||
|
session_type,
|
||||||
|
notes
|
||||||
|
) VALUES(?, ?, NULL, ?, NULL);
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
session["session_id"],
|
||||||
|
session["started_at"],
|
||||||
|
session["session_type"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
cursor = connection.execute(
|
session_row_id = cursor.lastrowid
|
||||||
"""
|
|
||||||
INSERT INTO sessions(
|
|
||||||
session_id,
|
|
||||||
started_at,
|
|
||||||
ended_at,
|
|
||||||
session_type,
|
|
||||||
notes
|
|
||||||
) VALUES(?, ?, NULL, ?, NULL);
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
session["session_id"],
|
|
||||||
session["started_at"],
|
|
||||||
session["session_type"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
session_row_id = cursor.lastrowid
|
|
||||||
|
|
||||||
for position, item in enumerate(
|
for position, item in enumerate(
|
||||||
session["exercises"]
|
session["exercises"]
|
||||||
):
|
):
|
||||||
|
position = item.get("position", position)
|
||||||
mobile_id = item["exercise_id"]
|
mobile_id = item["exercise_id"]
|
||||||
|
|
||||||
desktop_id = exercise_mapping.get(
|
desktop_id = exercise_mapping.get(
|
||||||
|
|
@ -1188,6 +1240,7 @@ def run_import(
|
||||||
"exercises_reconciled": 0,
|
"exercises_reconciled": 0,
|
||||||
"exercises_skipped": 0,
|
"exercises_skipped": 0,
|
||||||
"sessions_imported": 0,
|
"sessions_imported": 0,
|
||||||
|
"sessions_reconciled": 0,
|
||||||
"sessions_skipped": 0,
|
"sessions_skipped": 0,
|
||||||
"body_imported": 0,
|
"body_imported": 0,
|
||||||
"body_skipped": 0,
|
"body_skipped": 0,
|
||||||
|
|
@ -1255,6 +1308,7 @@ def print_report(
|
||||||
"exercises_reconciled",
|
"exercises_reconciled",
|
||||||
"exercises_skipped",
|
"exercises_skipped",
|
||||||
"sessions_imported",
|
"sessions_imported",
|
||||||
|
"sessions_reconciled",
|
||||||
"sessions_skipped",
|
"sessions_skipped",
|
||||||
"body_imported",
|
"body_imported",
|
||||||
"body_skipped",
|
"body_skipped",
|
||||||
|
|
|
||||||
|
|
@ -210,10 +210,7 @@ def validate_semantics(document: dict[str, Any]) -> None:
|
||||||
validate_load_mode(workout, index)
|
validate_load_mode(workout, index)
|
||||||
|
|
||||||
if "notes" in workout:
|
if "notes" in workout:
|
||||||
require_non_blank(
|
require_non_blank(workout["notes"], f"session.exercises[{index}].notes")
|
||||||
workout["notes"],
|
|
||||||
f"session.exercises[{index}].notes",
|
|
||||||
)
|
|
||||||
|
|
||||||
catalog_ids = set(catalog_by_id)
|
catalog_ids = set(catalog_by_id)
|
||||||
if catalog_ids != workout_ids:
|
if catalog_ids != workout_ids:
|
||||||
|
|
@ -224,11 +221,39 @@ def validate_semantics(document: dict[str, Any]) -> None:
|
||||||
details.append(f"unreferenced catalog ids: {unreferenced}")
|
details.append(f"unreferenced catalog ids: {unreferenced}")
|
||||||
if missing:
|
if missing:
|
||||||
details.append(f"missing catalog ids: {missing}")
|
details.append(f"missing catalog ids: {missing}")
|
||||||
raise TrainlogSemanticError(
|
raise TrainlogSemanticError("catalog/reference set mismatch: " + "; ".join(details))
|
||||||
"catalog/reference set mismatch: " + "; ".join(details)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mobile_export_v2(document: Any) -> None:
|
||||||
|
"""Validate occurrence identity/order without weakening frozen v1 rules."""
|
||||||
|
if not isinstance(document, dict) or document.get("format") != "trainlog-mobile-export" or document.get("version") != 2:
|
||||||
|
raise TrainlogSemanticError("mobile export V2: format/version invalid")
|
||||||
|
catalog = document.get("exercises")
|
||||||
|
sessions = document.get("sessions")
|
||||||
|
if not isinstance(catalog, list) or not isinstance(sessions, list):
|
||||||
|
raise TrainlogSemanticError("mobile export V2: arrays required")
|
||||||
|
ids = {item.get("exercise_id") for item in catalog if isinstance(item, dict)}
|
||||||
|
if len(ids) != len(catalog) or None in ids:
|
||||||
|
raise TrainlogSemanticError("mobile export V2: duplicate/invalid catalogue identity")
|
||||||
|
seen_sessions: set[str] = set()
|
||||||
|
for session in sessions:
|
||||||
|
if not isinstance(session, dict) or not isinstance(session.get("session_id"), str) or not session["session_id"]:
|
||||||
|
raise TrainlogSemanticError("mobile export V2: invalid session identity")
|
||||||
|
if session["session_id"] in seen_sessions:
|
||||||
|
raise TrainlogSemanticError("mobile export V2: duplicate session identity")
|
||||||
|
seen_sessions.add(session["session_id"])
|
||||||
|
entries = session.get("exercises")
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise TrainlogSemanticError("mobile export V2: entries array required")
|
||||||
|
entry_ids: set[str] = set(); positions: set[int] = set()
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict) or not isinstance(entry.get("entry_id"), str) or not entry["entry_id"]:
|
||||||
|
raise TrainlogSemanticError("mobile export V2: invalid entry identity")
|
||||||
|
if entry["entry_id"] in entry_ids or entry.get("exercise_id") not in ids:
|
||||||
|
raise TrainlogSemanticError("mobile export V2: duplicate entry or unknown exercise")
|
||||||
|
if isinstance(entry.get("position"), bool) or not isinstance(entry.get("position"), int) or entry["position"] < 0 or entry["position"] in positions:
|
||||||
|
raise TrainlogSemanticError("mobile export V2: invalid/duplicate entry position")
|
||||||
|
entry_ids.add(entry["entry_id"]); positions.add(entry["position"])
|
||||||
def structural_errors(
|
def structural_errors(
|
||||||
validator: jsonschema.Draft202012Validator,
|
validator: jsonschema.Draft202012Validator,
|
||||||
document: Any,
|
document: Any,
|
||||||
|
|
@ -259,12 +284,17 @@ def validate_document(
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
return [str(exc)]
|
return [str(exc)]
|
||||||
|
|
||||||
errors = structural_errors(validator, document)
|
is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2
|
||||||
if errors:
|
if not is_mobile_v2:
|
||||||
return errors
|
errors = structural_errors(validator, document)
|
||||||
|
if errors:
|
||||||
|
return errors
|
||||||
|
|
||||||
try:
|
try:
|
||||||
validate_semantics(document)
|
if is_mobile_v2:
|
||||||
|
validate_mobile_export_v2(document)
|
||||||
|
else:
|
||||||
|
validate_semantics(document)
|
||||||
except TrainlogSemanticError as exc:
|
except TrainlogSemanticError as exc:
|
||||||
return [str(exc)]
|
return [str(exc)]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 5
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 7
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
@ -20,6 +20,22 @@ TrainlogStatus trainlog_database_open(
|
||||||
TrainlogDatabase **output_database
|
TrainlogDatabase **output_database
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Open a database and report the backend failure that prevented it.
|
||||||
|
*
|
||||||
|
* CONTRACT: @p output_diagnostic is optional. When supplied with a non-zero
|
||||||
|
* capacity, it receives a NUL-terminated explanation of the failed SQLite
|
||||||
|
* operation; callers still use the returned TrainlogStatus for control flow.
|
||||||
|
* This preserves the stable application status surface without hiding the
|
||||||
|
* path-specific reason needed to repair a user's durable database safely.
|
||||||
|
*/
|
||||||
|
TrainlogStatus trainlog_database_open_with_diagnostic(
|
||||||
|
const char *path,
|
||||||
|
TrainlogDatabase **output_database,
|
||||||
|
char *output_diagnostic,
|
||||||
|
size_t output_diagnostic_capacity
|
||||||
|
);
|
||||||
|
|
||||||
void trainlog_database_close(TrainlogDatabase *database);
|
void trainlog_database_close(TrainlogDatabase *database);
|
||||||
|
|
||||||
TrainlogStatus trainlog_database_schema_version(
|
TrainlogStatus trainlog_database_schema_version(
|
||||||
|
|
@ -107,7 +123,10 @@ TrainlogStatus trainlog_database_list_weight_points(
|
||||||
#define TRAINLOG_SET_SUMMARY_MAX 1024U
|
#define TRAINLOG_SET_SUMMARY_MAX 1024U
|
||||||
|
|
||||||
typedef struct TrainlogPersistedExerciseDetail {
|
typedef struct TrainlogPersistedExerciseDetail {
|
||||||
|
/* Stable occurrence identity; exercise_id is catalogue identity only. */
|
||||||
|
char entry_id[TRAINLOG_ID_MAX + 1U];
|
||||||
char name[TRAINLOG_NAME_MAX + 1U];
|
char name[TRAINLOG_NAME_MAX + 1U];
|
||||||
|
char equipment_id[TRAINLOG_ID_MAX + 1U];
|
||||||
TrainlogTrackingMode tracking_mode;
|
TrainlogTrackingMode tracking_mode;
|
||||||
TrainlogRecordingMode recording_mode;
|
TrainlogRecordingMode recording_mode;
|
||||||
TrainlogExerciseDataFields data_fields;
|
TrainlogExerciseDataFields data_fields;
|
||||||
|
|
@ -231,7 +250,9 @@ TrainlogStatus trainlog_database_list_exercise_performance(
|
||||||
/* TRAINLOG_SESSION_EDIT_API */
|
/* TRAINLOG_SESSION_EDIT_API */
|
||||||
|
|
||||||
typedef struct TrainlogEditableExerciseRecord {
|
typedef struct TrainlogEditableExerciseRecord {
|
||||||
|
char entry_id[TRAINLOG_ID_MAX + 1U];
|
||||||
char exercise_id[TRAINLOG_ID_MAX + 1U];
|
char exercise_id[TRAINLOG_ID_MAX + 1U];
|
||||||
|
char equipment_id[TRAINLOG_ID_MAX + 1U];
|
||||||
char name[TRAINLOG_NAME_MAX + 1U];
|
char name[TRAINLOG_NAME_MAX + 1U];
|
||||||
TrainlogTrackingMode tracking_mode;
|
TrainlogTrackingMode tracking_mode;
|
||||||
TrainlogLoadMode load_mode;
|
TrainlogLoadMode load_mode;
|
||||||
|
|
|
||||||
31
tui/include/trainlog/equipment_catalog.h
Normal file
31
tui/include/trainlog/equipment_catalog.h
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#ifndef TRAINLOG_EQUIPMENT_CATALOG_H
|
||||||
|
#define TRAINLOG_EQUIPMENT_CATALOG_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
/* CONTRACT: data is generated at build time from catalog/equipment-v1.json.
|
||||||
|
* Callers borrow returned strings for process lifetime and must not free them. */
|
||||||
|
typedef struct TrainlogEquipment {
|
||||||
|
const char *equipment_id;
|
||||||
|
const char *label_name;
|
||||||
|
const char *display_name;
|
||||||
|
const char *equipment_type;
|
||||||
|
const char *load_semantics;
|
||||||
|
const char *aliases; /* newline-separated canonical aliases */
|
||||||
|
} TrainlogEquipment;
|
||||||
|
|
||||||
|
typedef struct TrainlogExerciseEquipmentRelation {
|
||||||
|
const char *exercise_id;
|
||||||
|
const char *equipment_id;
|
||||||
|
const char *load_semantics;
|
||||||
|
} TrainlogExerciseEquipmentRelation;
|
||||||
|
|
||||||
|
size_t trainlog_equipment_catalog_count(void);
|
||||||
|
const TrainlogEquipment *trainlog_equipment_catalog_at(size_t index);
|
||||||
|
const TrainlogEquipment *trainlog_equipment_catalog_lookup(const char *equipment_id);
|
||||||
|
/* Search is deterministic and ASCII-case-insensitive over label, display and aliases. */
|
||||||
|
size_t trainlog_equipment_catalog_search(const char *query, const TrainlogEquipment **output, size_t capacity);
|
||||||
|
size_t trainlog_equipment_catalog_relation_count(void);
|
||||||
|
const TrainlogExerciseEquipmentRelation *trainlog_equipment_catalog_relation_at(size_t index);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -60,7 +60,11 @@ typedef struct TrainlogSetInput {
|
||||||
} TrainlogSetInput;
|
} TrainlogSetInput;
|
||||||
|
|
||||||
typedef struct TrainlogSessionExerciseInput {
|
typedef struct TrainlogSessionExerciseInput {
|
||||||
|
/* Empty means local creation; the database allocates a stable sxe UUID. */
|
||||||
|
char entry_id[TRAINLOG_ID_MAX + 1U];
|
||||||
char exercise_id[TRAINLOG_ID_MAX + 1U];
|
char exercise_id[TRAINLOG_ID_MAX + 1U];
|
||||||
|
/* Optional canonical ID from equipment-v1.json, never a SQLite row ID. */
|
||||||
|
char equipment_id[TRAINLOG_ID_MAX + 1U];
|
||||||
TrainlogRecordingMode recording_mode;
|
TrainlogRecordingMode recording_mode;
|
||||||
TrainlogExerciseDataFields data_fields;
|
TrainlogExerciseDataFields data_fields;
|
||||||
TrainlogLoadMode load_mode;
|
TrainlogLoadMode load_mode;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ m_dep = cc.find_library('m', required: true)
|
||||||
|
|
||||||
trainlog_include = include_directories('include')
|
trainlog_include = include_directories('include')
|
||||||
|
|
||||||
|
equipment_catalog_generated = custom_target(
|
||||||
|
'equipment_catalog_generated',
|
||||||
|
input: meson.project_source_root() / 'catalog/equipment-v1.json',
|
||||||
|
output: 'equipment_catalog_generated.c',
|
||||||
|
command: [find_program('python3'), meson.project_source_root() / 'tools/generate_equipment_catalog.py', '@INPUT@', '@OUTPUT@'],
|
||||||
|
)
|
||||||
|
|
||||||
strict_c_args = [
|
strict_c_args = [
|
||||||
'-D_POSIX_C_SOURCE=200809L',
|
'-D_POSIX_C_SOURCE=200809L',
|
||||||
'-Wconversion',
|
'-Wconversion',
|
||||||
|
|
@ -37,6 +44,7 @@ trainlog_core_sources = files(
|
||||||
'src/measured_max.c',
|
'src/measured_max.c',
|
||||||
'src/sync.c',
|
'src/sync.c',
|
||||||
)
|
)
|
||||||
|
trainlog_core_sources += equipment_catalog_generated
|
||||||
|
|
||||||
trainlog_core = static_library(
|
trainlog_core = static_library(
|
||||||
'trainlog_core',
|
'trainlog_core',
|
||||||
|
|
@ -108,6 +116,14 @@ test(
|
||||||
test_catalog,
|
test_catalog,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test_equipment_catalog = executable(
|
||||||
|
'test_equipment_catalog',
|
||||||
|
'tests/test_equipment_catalog.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
test('equipment_catalog', test_equipment_catalog)
|
||||||
|
|
||||||
test_session_detail = executable(
|
test_session_detail = executable(
|
||||||
'test_session_detail',
|
'test_session_detail',
|
||||||
'tests/test_session_detail.c',
|
'tests/test_session_detail.c',
|
||||||
|
|
@ -335,6 +351,18 @@ test(
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
'mobile_import_multi_occurrence',
|
||||||
|
python3_trainlog_tests,
|
||||||
|
args: [meson.project_source_root() / 'tests/test_mobile_import_multi_occurrence.py'],
|
||||||
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
'equipment_associations_exchange',
|
||||||
|
python3_trainlog_tests,
|
||||||
|
args: [meson.project_source_root() / 'tests/test_equipment_associations_exchange.py'],
|
||||||
|
)
|
||||||
|
|
||||||
trainlog_sync_once = executable(
|
trainlog_sync_once = executable(
|
||||||
'trainlog-sync-once',
|
'trainlog-sync-once',
|
||||||
'tools/sync_once.c',
|
'tools/sync_once.c',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
|
|
||||||
#include "trainlog/database.h"
|
#include "trainlog/database.h"
|
||||||
#include "trainlog/duration.h"
|
#include "trainlog/duration.h"
|
||||||
|
#include "trainlog/equipment_catalog.h"
|
||||||
|
#include "trainlog/id.h"
|
||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
|
@ -17,7 +19,27 @@ struct TrainlogDatabase {
|
||||||
sqlite3 *connection;
|
sqlite3 *connection;
|
||||||
};
|
};
|
||||||
|
|
||||||
static const char *const SCHEMA_V5_SQL_A =
|
static void set_open_diagnostic(
|
||||||
|
char *output,
|
||||||
|
size_t capacity,
|
||||||
|
const char *operation,
|
||||||
|
sqlite3 *connection,
|
||||||
|
int sqlite_status
|
||||||
|
)
|
||||||
|
{
|
||||||
|
const char *message;
|
||||||
|
|
||||||
|
if (output == NULL || capacity == 0U) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message = connection != NULL
|
||||||
|
? sqlite3_errmsg(connection)
|
||||||
|
: sqlite3_errstr(sqlite_status);
|
||||||
|
(void)snprintf(output, capacity, "%s: %s", operation, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *const SCHEMA_V7_SQL_A =
|
||||||
"BEGIN IMMEDIATE;"
|
"BEGIN IMMEDIATE;"
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS exercises ("
|
"CREATE TABLE IF NOT EXISTS exercises ("
|
||||||
|
|
@ -47,6 +69,7 @@ static const char *const SCHEMA_V5_SQL_A =
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS session_exercises ("
|
"CREATE TABLE IF NOT EXISTS session_exercises ("
|
||||||
" id INTEGER PRIMARY KEY,"
|
" id INTEGER PRIMARY KEY,"
|
||||||
|
" entry_id TEXT NOT NULL UNIQUE,"
|
||||||
" session_row_id INTEGER NOT NULL"
|
" session_row_id INTEGER NOT NULL"
|
||||||
" REFERENCES sessions(id) ON DELETE CASCADE,"
|
" REFERENCES sessions(id) ON DELETE CASCADE,"
|
||||||
" exercise_row_id INTEGER NOT NULL"
|
" exercise_row_id INTEGER NOT NULL"
|
||||||
|
|
@ -64,9 +87,9 @@ static const char *const SCHEMA_V5_SQL_A =
|
||||||
" target_duration_seconds INTEGER"
|
" target_duration_seconds INTEGER"
|
||||||
" CHECK (target_duration_seconds > 0),"
|
" CHECK (target_duration_seconds > 0),"
|
||||||
" target_weight_kg REAL CHECK (target_weight_kg > 0.0),"
|
" target_weight_kg REAL CHECK (target_weight_kg > 0.0),"
|
||||||
|
" equipment_id TEXT,"
|
||||||
" notes TEXT,"
|
" notes TEXT,"
|
||||||
" UNIQUE (session_row_id, position),"
|
" UNIQUE (session_row_id, position),"
|
||||||
" UNIQUE (session_row_id, exercise_row_id),"
|
|
||||||
" CHECK ("
|
" CHECK ("
|
||||||
" (recording_mode = 'sets' AND"
|
" (recording_mode = 'sets' AND"
|
||||||
" ("
|
" ("
|
||||||
|
|
@ -109,7 +132,7 @@ static const char *const SCHEMA_V5_SQL_A =
|
||||||
" )"
|
" )"
|
||||||
");";
|
");";
|
||||||
|
|
||||||
static const char *const SCHEMA_V5_SQL_B =
|
static const char *const SCHEMA_V7_SQL_B =
|
||||||
"CREATE TABLE IF NOT EXISTS continuous_activity ("
|
"CREATE TABLE IF NOT EXISTS continuous_activity ("
|
||||||
" id INTEGER PRIMARY KEY,"
|
" id INTEGER PRIMARY KEY,"
|
||||||
" session_exercise_row_id INTEGER NOT NULL UNIQUE"
|
" session_exercise_row_id INTEGER NOT NULL UNIQUE"
|
||||||
|
|
@ -152,9 +175,41 @@ static const char *const SCHEMA_V5_SQL_B =
|
||||||
" )"
|
" )"
|
||||||
");"
|
");"
|
||||||
|
|
||||||
"PRAGMA user_version = 5;"
|
"PRAGMA user_version = 7;"
|
||||||
"COMMIT;";
|
"COMMIT;";
|
||||||
|
|
||||||
|
static const char *const MIGRATE_V5_TO_V6_SQL =
|
||||||
|
"BEGIN IMMEDIATE;"
|
||||||
|
"ALTER TABLE session_exercises ADD COLUMN equipment_id TEXT;"
|
||||||
|
"PRAGMA user_version = 6;"
|
||||||
|
"COMMIT;";
|
||||||
|
|
||||||
|
/* WHY: a catalogue exercise can occur twice in one completed session. v7
|
||||||
|
* introduces a persistent occurrence ID and removes the invalid uniqueness
|
||||||
|
* constraint while retaining primary keys and all dependent measurements. */
|
||||||
|
static const char *const MIGRATE_V6_TO_V7_SQL =
|
||||||
|
"PRAGMA foreign_keys = OFF;"
|
||||||
|
"BEGIN IMMEDIATE;"
|
||||||
|
"ALTER TABLE session_exercises RENAME TO session_exercises_v6;"
|
||||||
|
"CREATE TABLE session_exercises ("
|
||||||
|
"id INTEGER PRIMARY KEY, entry_id TEXT NOT NULL UNIQUE,"
|
||||||
|
"session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,"
|
||||||
|
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,"
|
||||||
|
"recording_mode TEXT NOT NULL, data_fields INTEGER NOT NULL,"
|
||||||
|
"position INTEGER NOT NULL, load_mode TEXT NOT NULL, rest_seconds INTEGER NOT NULL,"
|
||||||
|
"target_sets INTEGER, target_reps INTEGER, target_duration_seconds INTEGER,"
|
||||||
|
"target_weight_kg REAL, equipment_id TEXT, notes TEXT, UNIQUE(session_row_id,position));"
|
||||||
|
"INSERT INTO session_exercises(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) "
|
||||||
|
"SELECT id,'sxe_legacy_' || 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 FROM session_exercises_v6;"
|
||||||
|
"ALTER TABLE performed_sets RENAME TO performed_sets_v6;"
|
||||||
|
"CREATE TABLE performed_sets (id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,position INTEGER NOT NULL,reps INTEGER,duration_seconds INTEGER,weight_kg REAL,UNIQUE(session_exercise_row_id,position));"
|
||||||
|
"INSERT INTO performed_sets SELECT * FROM performed_sets_v6;"
|
||||||
|
"ALTER TABLE continuous_activity RENAME TO continuous_activity_v6;"
|
||||||
|
"CREATE TABLE continuous_activity (id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id) ON DELETE CASCADE,duration_seconds INTEGER NOT NULL,speed_kmh REAL,distance_km REAL);"
|
||||||
|
"INSERT INTO continuous_activity SELECT * FROM continuous_activity_v6;"
|
||||||
|
"DROP TABLE performed_sets_v6;DROP TABLE continuous_activity_v6;DROP TABLE session_exercises_v6;"
|
||||||
|
"PRAGMA user_version = 7;COMMIT;PRAGMA foreign_keys = ON;";
|
||||||
|
|
||||||
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 "
|
||||||
|
|
@ -489,7 +544,9 @@ static TrainlogStatus read_single_int_pragma(
|
||||||
}
|
}
|
||||||
|
|
||||||
static TrainlogStatus initialize_or_validate_schema(
|
static TrainlogStatus initialize_or_validate_schema(
|
||||||
TrainlogDatabase *database
|
TrainlogDatabase *database,
|
||||||
|
char *output_diagnostic,
|
||||||
|
size_t output_diagnostic_capacity
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
int version = 0;
|
int version = 0;
|
||||||
|
|
@ -505,6 +562,13 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
status !=
|
status !=
|
||||||
TRAINLOG_STATUS_OK
|
TRAINLOG_STATUS_OK
|
||||||
) {
|
) {
|
||||||
|
set_open_diagnostic(
|
||||||
|
output_diagnostic,
|
||||||
|
output_diagnostic_capacity,
|
||||||
|
"read schema version",
|
||||||
|
database->connection,
|
||||||
|
SQLITE_ERROR
|
||||||
|
);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -512,6 +576,13 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
version >
|
version >
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION
|
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||||
) {
|
) {
|
||||||
|
(void)snprintf(
|
||||||
|
output_diagnostic,
|
||||||
|
output_diagnostic_capacity,
|
||||||
|
"schema version %d is newer than supported version %d",
|
||||||
|
version,
|
||||||
|
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||||
|
);
|
||||||
return
|
return
|
||||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||||
}
|
}
|
||||||
|
|
@ -527,7 +598,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
status =
|
status =
|
||||||
execute_sql(
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
SCHEMA_V5_SQL_A
|
SCHEMA_V7_SQL_A
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -537,7 +608,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
status =
|
status =
|
||||||
execute_sql(
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
SCHEMA_V5_SQL_B
|
SCHEMA_V7_SQL_B
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -617,6 +688,10 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
} else if (version == 4) {
|
} else if (version == 4) {
|
||||||
status =
|
status =
|
||||||
TRAINLOG_STATUS_OK;
|
TRAINLOG_STATUS_OK;
|
||||||
|
} else if (version == 5) {
|
||||||
|
status = TRAINLOG_STATUS_OK;
|
||||||
|
} else if (version == 6) {
|
||||||
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||||
|
|
@ -643,12 +718,26 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
MIGRATE_V4_TO_V5_SQL_B
|
MIGRATE_V4_TO_V5_SQL_B
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status == TRAINLOG_STATUS_OK && version < 6) {
|
||||||
|
status = execute_sql(database, MIGRATE_V5_TO_V6_SQL);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = execute_sql(database, MIGRATE_V6_TO_V7_SQL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
status !=
|
status !=
|
||||||
TRAINLOG_STATUS_OK
|
TRAINLOG_STATUS_OK
|
||||||
) {
|
) {
|
||||||
|
set_open_diagnostic(
|
||||||
|
output_diagnostic,
|
||||||
|
output_diagnostic_capacity,
|
||||||
|
version == 0 ? "create schema v7" : "migrate database to schema v7",
|
||||||
|
database->connection,
|
||||||
|
SQLITE_ERROR
|
||||||
|
);
|
||||||
(void)sqlite3_exec(
|
(void)sqlite3_exec(
|
||||||
database->connection,
|
database->connection,
|
||||||
"ROLLBACK;",
|
"ROLLBACK;",
|
||||||
|
|
@ -665,19 +754,51 @@ TrainlogStatus trainlog_database_open(
|
||||||
const char *path,
|
const char *path,
|
||||||
TrainlogDatabase **output_database
|
TrainlogDatabase **output_database
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
return trainlog_database_open_with_diagnostic(
|
||||||
|
path,
|
||||||
|
output_database,
|
||||||
|
NULL,
|
||||||
|
0U
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_database_open_with_diagnostic(
|
||||||
|
const char *path,
|
||||||
|
TrainlogDatabase **output_database,
|
||||||
|
char *output_diagnostic,
|
||||||
|
size_t output_diagnostic_capacity
|
||||||
|
)
|
||||||
{
|
{
|
||||||
TrainlogDatabase *database;
|
TrainlogDatabase *database;
|
||||||
int rc;
|
int rc;
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
|
|
||||||
if (path == NULL || path[0] == '\0' || output_database == NULL) {
|
if (path == NULL || path[0] == '\0' || output_database == NULL) {
|
||||||
|
set_open_diagnostic(
|
||||||
|
output_diagnostic,
|
||||||
|
output_diagnostic_capacity,
|
||||||
|
"validate database path",
|
||||||
|
NULL,
|
||||||
|
SQLITE_MISUSE
|
||||||
|
);
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
*output_database = NULL;
|
*output_database = NULL;
|
||||||
|
if (output_diagnostic != NULL && output_diagnostic_capacity > 0U) {
|
||||||
|
output_diagnostic[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
database = calloc(1U, sizeof(*database));
|
database = calloc(1U, sizeof(*database));
|
||||||
if (database == NULL) {
|
if (database == NULL) {
|
||||||
|
set_open_diagnostic(
|
||||||
|
output_diagnostic,
|
||||||
|
output_diagnostic_capacity,
|
||||||
|
"allocate database handle",
|
||||||
|
NULL,
|
||||||
|
SQLITE_NOMEM
|
||||||
|
);
|
||||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -688,22 +809,47 @@ TrainlogStatus trainlog_database_open(
|
||||||
NULL
|
NULL
|
||||||
);
|
);
|
||||||
if (rc != SQLITE_OK) {
|
if (rc != SQLITE_OK) {
|
||||||
|
set_open_diagnostic(
|
||||||
|
output_diagnostic,
|
||||||
|
output_diagnostic_capacity,
|
||||||
|
"open database",
|
||||||
|
database->connection,
|
||||||
|
rc
|
||||||
|
);
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
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(
|
||||||
|
output_diagnostic,
|
||||||
|
output_diagnostic_capacity,
|
||||||
|
"configure database busy timeout",
|
||||||
|
database->connection,
|
||||||
|
SQLITE_ERROR
|
||||||
|
);
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
status = execute_sql(database, "PRAGMA foreign_keys = ON;");
|
status = execute_sql(database, "PRAGMA foreign_keys = ON;");
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
set_open_diagnostic(
|
||||||
|
output_diagnostic,
|
||||||
|
output_diagnostic_capacity,
|
||||||
|
"enable foreign-key enforcement",
|
||||||
|
database->connection,
|
||||||
|
SQLITE_ERROR
|
||||||
|
);
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
status = initialize_or_validate_schema(database);
|
status = initialize_or_validate_schema(
|
||||||
|
database,
|
||||||
|
output_diagnostic,
|
||||||
|
output_diagnostic_capacity
|
||||||
|
);
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
return status;
|
return status;
|
||||||
|
|
@ -1415,6 +1561,8 @@ 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;
|
||||||
|
char generated_entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||||
|
const char *entry_id;
|
||||||
int rc;
|
int rc;
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
|
|
||||||
|
|
@ -1425,6 +1573,25 @@ static TrainlogStatus insert_session_exercise(
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input->equipment_id[0] != '\0' &&
|
||||||
|
trainlog_equipment_catalog_lookup(input->equipment_id) == NULL) {
|
||||||
|
/* INVARIANT: a persisted occurrence only names a manifest identity. */
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Local TUI creation has no transport-supplied identity; allocate it once
|
||||||
|
* at persistence, while imported V2 identities pass through unchanged. */
|
||||||
|
if (input->entry_id[0] == '\0') {
|
||||||
|
status = trainlog_id_generate("sy", generated_entry_id,
|
||||||
|
sizeof(generated_entry_id));
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
entry_id = generated_entry_id;
|
||||||
|
} else {
|
||||||
|
entry_id = input->entry_id;
|
||||||
|
}
|
||||||
|
|
||||||
load_mode =
|
load_mode =
|
||||||
load_mode_to_sql(
|
load_mode_to_sql(
|
||||||
input->load_mode
|
input->load_mode
|
||||||
|
|
@ -1522,15 +1689,15 @@ static TrainlogStatus insert_session_exercise(
|
||||||
rc = sqlite3_prepare_v2(
|
rc = sqlite3_prepare_v2(
|
||||||
database->connection,
|
database->connection,
|
||||||
"INSERT INTO session_exercises("
|
"INSERT INTO session_exercises("
|
||||||
"session_row_id, exercise_row_id, "
|
"entry_id, session_row_id, exercise_row_id, "
|
||||||
"recording_mode, data_fields, "
|
"recording_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"
|
"target_weight_kg, notes, equipment_id"
|
||||||
") VALUES("
|
") VALUES("
|
||||||
"?1, ?2, ?3, ?4, ?5, ?6, "
|
"?1, ?2, ?3, ?4, ?5, ?6, ?7, "
|
||||||
"?7, ?8, ?9, ?10, ?11, ?12"
|
"?8, ?9, ?10, ?11, ?12, ?13, ?14"
|
||||||
");",
|
");",
|
||||||
-1,
|
-1,
|
||||||
&statement,
|
&statement,
|
||||||
|
|
@ -1541,16 +1708,20 @@ static TrainlogStatus insert_session_exercise(
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rc = sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT);
|
||||||
|
|
||||||
|
if (rc == SQLITE_OK) {
|
||||||
rc = sqlite3_bind_int64(
|
rc = sqlite3_bind_int64(
|
||||||
statement,
|
statement,
|
||||||
1,
|
2,
|
||||||
session_row_id
|
session_row_id
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = sqlite3_bind_int64(
|
rc = sqlite3_bind_int64(
|
||||||
statement,
|
statement,
|
||||||
2,
|
3,
|
||||||
exercise_row_id
|
exercise_row_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1558,7 +1729,7 @@ static TrainlogStatus insert_session_exercise(
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = sqlite3_bind_text(
|
rc = sqlite3_bind_text(
|
||||||
statement,
|
statement,
|
||||||
3,
|
4,
|
||||||
recording_mode,
|
recording_mode,
|
||||||
-1,
|
-1,
|
||||||
SQLITE_STATIC
|
SQLITE_STATIC
|
||||||
|
|
@ -1568,7 +1739,7 @@ static TrainlogStatus insert_session_exercise(
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = sqlite3_bind_int64(
|
rc = sqlite3_bind_int64(
|
||||||
statement,
|
statement,
|
||||||
4,
|
5,
|
||||||
(sqlite3_int64)input->data_fields
|
(sqlite3_int64)input->data_fields
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1576,7 +1747,7 @@ static TrainlogStatus insert_session_exercise(
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = sqlite3_bind_int64(
|
rc = sqlite3_bind_int64(
|
||||||
statement,
|
statement,
|
||||||
5,
|
6,
|
||||||
(sqlite3_int64)position
|
(sqlite3_int64)position
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1584,7 +1755,7 @@ static TrainlogStatus insert_session_exercise(
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = sqlite3_bind_text(
|
rc = sqlite3_bind_text(
|
||||||
statement,
|
statement,
|
||||||
6,
|
7,
|
||||||
load_mode,
|
load_mode,
|
||||||
-1,
|
-1,
|
||||||
SQLITE_STATIC
|
SQLITE_STATIC
|
||||||
|
|
@ -1594,30 +1765,17 @@ static TrainlogStatus insert_session_exercise(
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = sqlite3_bind_int(
|
rc = sqlite3_bind_int(
|
||||||
statement,
|
statement,
|
||||||
7,
|
8,
|
||||||
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,
|
|
||||||
8,
|
|
||||||
input->target_sets
|
|
||||||
)
|
|
||||||
: sqlite3_bind_null(
|
|
||||||
statement,
|
|
||||||
8
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rc == SQLITE_OK) {
|
|
||||||
rc = input->target_reps > 0
|
|
||||||
? sqlite3_bind_int(
|
? sqlite3_bind_int(
|
||||||
statement,
|
statement,
|
||||||
9,
|
9,
|
||||||
input->target_reps
|
input->target_sets
|
||||||
)
|
)
|
||||||
: sqlite3_bind_null(
|
: sqlite3_bind_null(
|
||||||
statement,
|
statement,
|
||||||
|
|
@ -1626,11 +1784,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,
|
||||||
10,
|
10,
|
||||||
input->target_duration_seconds
|
input->target_reps
|
||||||
)
|
)
|
||||||
: sqlite3_bind_null(
|
: sqlite3_bind_null(
|
||||||
statement,
|
statement,
|
||||||
|
|
@ -1639,11 +1797,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,
|
||||||
11,
|
11,
|
||||||
input->target_weight_kg
|
input->target_duration_seconds
|
||||||
)
|
)
|
||||||
: sqlite3_bind_null(
|
: sqlite3_bind_null(
|
||||||
statement,
|
statement,
|
||||||
|
|
@ -1651,23 +1809,43 @@ static TrainlogStatus insert_session_exercise(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rc == SQLITE_OK) {
|
||||||
|
rc = input->target_has_weight
|
||||||
|
? sqlite3_bind_double(
|
||||||
|
statement,
|
||||||
|
12,
|
||||||
|
input->target_weight_kg
|
||||||
|
)
|
||||||
|
: sqlite3_bind_null(
|
||||||
|
statement,
|
||||||
|
12
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
12,
|
13,
|
||||||
input->notes,
|
input->notes,
|
||||||
-1,
|
-1,
|
||||||
SQLITE_TRANSIENT
|
SQLITE_TRANSIENT
|
||||||
)
|
)
|
||||||
: sqlite3_bind_null(
|
: sqlite3_bind_null(
|
||||||
statement,
|
statement,
|
||||||
12
|
13
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rc == SQLITE_OK) {
|
||||||
|
rc = input->equipment_id[0] != '\0'
|
||||||
|
? sqlite3_bind_text(statement, 14, input->equipment_id, -1,
|
||||||
|
SQLITE_TRANSIENT)
|
||||||
|
: sqlite3_bind_null(statement, 14);
|
||||||
|
}
|
||||||
|
|
||||||
if (rc != SQLITE_OK) {
|
if (rc != SQLITE_OK) {
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
|
@ -2276,6 +2454,7 @@ TrainlogStatus trainlog_database_insert_body_observation(
|
||||||
SQLITE_TRANSIENT
|
SQLITE_TRANSIENT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
rc = observation->session_id != NULL &&
|
rc = observation->session_id != NULL &&
|
||||||
observation->session_id[0] != '\0'
|
observation->session_id[0] != '\0'
|
||||||
|
|
@ -2672,6 +2851,8 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
"COALESCE(se.target_duration_seconds, 0), "
|
"COALESCE(se.target_duration_seconds, 0), "
|
||||||
"se.target_weight_kg, "
|
"se.target_weight_kg, "
|
||||||
"ca.duration_seconds, ca.speed_kmh, ca.distance_km, "
|
"ca.duration_seconds, ca.speed_kmh, ca.distance_km, "
|
||||||
|
"se.equipment_id, "
|
||||||
|
"se.entry_id, "
|
||||||
"se.id "
|
"se.id "
|
||||||
"FROM session_exercises AS se "
|
"FROM session_exercises AS se "
|
||||||
"JOIN sessions AS s "
|
"JOIN sessions AS s "
|
||||||
|
|
@ -2842,7 +3023,10 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
sqlite3_column_text(exercises, 4);
|
sqlite3_column_text(exercises, 4);
|
||||||
|
|
||||||
sqlite3_int64 session_exercise_row_id =
|
sqlite3_int64 session_exercise_row_id =
|
||||||
sqlite3_column_int64(exercises, 13);
|
sqlite3_column_int64(exercises, 15);
|
||||||
|
const unsigned char *equipment_id =
|
||||||
|
sqlite3_column_text(exercises, 13);
|
||||||
|
const unsigned char *entry_id = sqlite3_column_text(exercises, 14);
|
||||||
|
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
|
|
||||||
|
|
@ -2865,6 +3049,17 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
sizeof(*detail)
|
sizeof(*detail)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (equipment_id != NULL) {
|
||||||
|
(void)snprintf(detail->equipment_id,
|
||||||
|
sizeof(detail->equipment_id), "%s",
|
||||||
|
(const char *)equipment_id);
|
||||||
|
}
|
||||||
|
if (entry_id == NULL) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
(void)snprintf(detail->entry_id, sizeof(detail->entry_id), "%s",
|
||||||
|
(const char *)entry_id);
|
||||||
|
|
||||||
(void)snprintf(
|
(void)snprintf(
|
||||||
detail->name,
|
detail->name,
|
||||||
sizeof(detail->name),
|
sizeof(detail->name),
|
||||||
|
|
@ -3676,7 +3871,8 @@ TrainlogStatus trainlog_database_load_session_editable(
|
||||||
"COALESCE(se.target_reps, 0), "
|
"COALESCE(se.target_reps, 0), "
|
||||||
"COALESCE(se.target_duration_seconds, 0), "
|
"COALESCE(se.target_duration_seconds, 0), "
|
||||||
"se.target_weight_kg, "
|
"se.target_weight_kg, "
|
||||||
"COALESCE(se.notes, '') "
|
"COALESCE(se.notes, ''), "
|
||||||
|
"COALESCE(se.equipment_id, ''), se.entry_id "
|
||||||
"FROM session_exercises AS se "
|
"FROM session_exercises AS se "
|
||||||
"JOIN sessions AS s "
|
"JOIN sessions AS s "
|
||||||
"ON s.id = se.session_row_id "
|
"ON s.id = se.session_row_id "
|
||||||
|
|
@ -3909,6 +4105,23 @@ TrainlogStatus trainlog_database_load_session_editable(
|
||||||
sizeof(*record)
|
sizeof(*record)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (sqlite3_column_type(exercise_statement, 11) != SQLITE_NULL) {
|
||||||
|
const unsigned char *equipment_id = sqlite3_column_text(exercise_statement, 11);
|
||||||
|
if (equipment_id == NULL) {
|
||||||
|
(void)sqlite3_finalize(exercise_statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
(void)snprintf(record->equipment_id, sizeof(record->equipment_id),
|
||||||
|
"%s", (const char *)equipment_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sqlite3_column_type(exercise_statement, 12) == SQLITE_NULL) {
|
||||||
|
(void)sqlite3_finalize(exercise_statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
(void)snprintf(record->entry_id, sizeof(record->entry_id), "%s",
|
||||||
|
(const char *)sqlite3_column_text(exercise_statement, 12));
|
||||||
|
|
||||||
(void)snprintf(
|
(void)snprintf(
|
||||||
record->exercise_id,
|
record->exercise_id,
|
||||||
sizeof(record->exercise_id),
|
sizeof(record->exercise_id),
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ static int build_database_path(char *output, size_t output_size)
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
char database_path[PATH_MAX];
|
char database_path[PATH_MAX];
|
||||||
|
char database_diagnostic[256];
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
int result;
|
int result;
|
||||||
|
|
@ -92,12 +93,21 @@ int main(void)
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
status = trainlog_database_open(database_path, &database);
|
status = trainlog_database_open_with_diagnostic(
|
||||||
|
database_path,
|
||||||
|
&database,
|
||||||
|
database_diagnostic,
|
||||||
|
sizeof(database_diagnostic)
|
||||||
|
);
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
(void)fprintf(
|
(void)fprintf(
|
||||||
stderr,
|
stderr,
|
||||||
"trainlog: unable to open database (%d)\n",
|
"trainlog: unable to open database '%s' (status %d): %s\n",
|
||||||
(int)status
|
database_path,
|
||||||
|
(int)status,
|
||||||
|
database_diagnostic[0] != '\0'
|
||||||
|
? database_diagnostic
|
||||||
|
: "no SQLite diagnostic available"
|
||||||
);
|
);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
141
tui/src/sync.c
141
tui/src/sync.c
|
|
@ -28,11 +28,20 @@
|
||||||
#define SYNC_REQUEST_TEXT_MAX 4095U
|
#define SYNC_REQUEST_TEXT_MAX 4095U
|
||||||
|
|
||||||
static const char *const MOBILE_EXPORT_NAME =
|
static const char *const MOBILE_EXPORT_NAME =
|
||||||
|
"trainlog-mobile-export-v2.json";
|
||||||
|
|
||||||
|
static const char *const MOBILE_EXPORT_V1_NAME =
|
||||||
"trainlog-mobile-export-v1.json";
|
"trainlog-mobile-export-v1.json";
|
||||||
|
|
||||||
|
static const char *const PC_MOBILE_EXPORT_NAME =
|
||||||
|
"trainlog-pc-mobile-export-v2.json";
|
||||||
|
|
||||||
static const char *const PC_CATALOG_NAME =
|
static const char *const PC_CATALOG_NAME =
|
||||||
"trainlog-pc-catalog-v1.json";
|
"trainlog-pc-catalog-v1.json";
|
||||||
|
|
||||||
|
static const char *const EQUIPMENT_ASSOCIATIONS_NAME =
|
||||||
|
"trainlog-equipment-associations-v2.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";
|
||||||
|
|
||||||
|
|
@ -40,11 +49,17 @@ static const char *const SYNC_RECEIPT_NAME =
|
||||||
"trainlog-sync-receipt-v1.json";
|
"trainlog-sync-receipt-v1.json";
|
||||||
|
|
||||||
static const char *const MOBILE_EXPORT_LOCAL =
|
static const char *const MOBILE_EXPORT_LOCAL =
|
||||||
"/tmp/trainlog-mobile-export-v1.json";
|
"/tmp/trainlog-mobile-export-v2.json";
|
||||||
|
|
||||||
|
static const char *const PC_MOBILE_EXPORT_LOCAL =
|
||||||
|
"/tmp/trainlog-pc-mobile-export-v2.json";
|
||||||
|
|
||||||
static const char *const PC_CATALOG_LOCAL =
|
static const char *const PC_CATALOG_LOCAL =
|
||||||
"/tmp/trainlog-pc-catalog-v1.json";
|
"/tmp/trainlog-pc-catalog-v1.json";
|
||||||
|
|
||||||
|
static const char *const EQUIPMENT_ASSOCIATIONS_LOCAL =
|
||||||
|
"/tmp/trainlog-equipment-associations-v2.json";
|
||||||
|
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -57,6 +72,9 @@ static const char *const MOBILE_IMPORT_RESULT =
|
||||||
static const char *const PC_CATALOG_RESULT =
|
static const char *const PC_CATALOG_RESULT =
|
||||||
"/tmp/trainlog-pc-catalog-result.txt";
|
"/tmp/trainlog-pc-catalog-result.txt";
|
||||||
|
|
||||||
|
static const char *const EQUIPMENT_ASSOCIATIONS_RESULT =
|
||||||
|
"/tmp/trainlog-equipment-associations-result.txt";
|
||||||
|
|
||||||
typedef struct SyncSilence {
|
typedef struct SyncSilence {
|
||||||
int saved_stdout;
|
int saved_stdout;
|
||||||
int saved_stderr;
|
int saved_stderr;
|
||||||
|
|
@ -947,6 +965,7 @@ static bool sync_read_text(
|
||||||
static TrainlogStatus sync_run_python_tool(
|
static TrainlogStatus sync_run_python_tool(
|
||||||
const char *tool_name,
|
const char *tool_name,
|
||||||
const char *argument,
|
const char *argument,
|
||||||
|
const char *database_path,
|
||||||
const char *result_path,
|
const char *result_path,
|
||||||
char *output,
|
char *output,
|
||||||
size_t output_size
|
size_t output_size
|
||||||
|
|
@ -1028,13 +1047,28 @@ static TrainlogStatus sync_run_python_tool(
|
||||||
result_fd
|
result_fd
|
||||||
);
|
);
|
||||||
|
|
||||||
execlp(
|
if (database_path != NULL) {
|
||||||
"python3",
|
/* CONTRACT: helpers which mutate the desktop store receive its
|
||||||
"python3",
|
* explicit XDG-resolved path. They must never infer a different
|
||||||
tool,
|
* user's database from Python's process environment. */
|
||||||
argument,
|
execlp(
|
||||||
(char *)NULL
|
"python3",
|
||||||
);
|
"python3",
|
||||||
|
tool,
|
||||||
|
argument,
|
||||||
|
"--database",
|
||||||
|
database_path,
|
||||||
|
(char *)NULL
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
execlp(
|
||||||
|
"python3",
|
||||||
|
"python3",
|
||||||
|
tool,
|
||||||
|
argument,
|
||||||
|
(char *)NULL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
_exit(127);
|
_exit(127);
|
||||||
}
|
}
|
||||||
|
|
@ -2192,6 +2226,10 @@ TrainlogStatus trainlog_sync_run(
|
||||||
SYNC_REQUEST_TEXT_MAX + 1U
|
SYNC_REQUEST_TEXT_MAX + 1U
|
||||||
];
|
];
|
||||||
|
|
||||||
|
char database_path[
|
||||||
|
PATH_MAX + 1U
|
||||||
|
];
|
||||||
|
|
||||||
uint32_t folder_id = 0U;
|
uint32_t folder_id = 0U;
|
||||||
uint64_t ignored_size = 0U;
|
uint64_t ignored_size = 0U;
|
||||||
int lock_fd = -1;
|
int lock_fd = -1;
|
||||||
|
|
@ -2400,6 +2438,16 @@ TrainlogStatus trainlog_sync_run(
|
||||||
|
|
||||||
run_started = true;
|
run_started = true;
|
||||||
|
|
||||||
|
/* INVARIANT: every local import/export in one run addresses the same
|
||||||
|
* XDG-resolved database as the TUI, never a Python-derived fallback. */
|
||||||
|
if (!sync_data_file("trainlog.db", database_path,
|
||||||
|
sizeof(database_path))) {
|
||||||
|
(void)snprintf(output->error, sizeof(output->error),
|
||||||
|
"Synchronisation : chemin de base introuvable.");
|
||||||
|
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
goto finalize;
|
||||||
|
}
|
||||||
|
|
||||||
status =
|
status =
|
||||||
sync_receive_named(
|
sync_receive_named(
|
||||||
&device,
|
&device,
|
||||||
|
|
@ -2413,6 +2461,12 @@ TrainlogStatus trainlog_sync_run(
|
||||||
status !=
|
status !=
|
||||||
TRAINLOG_STATUS_OK
|
TRAINLOG_STATUS_OK
|
||||||
) {
|
) {
|
||||||
|
/* Explicit historic fallback only: a V1 file is never mistaken for
|
||||||
|
* V2, and V2 remains the default path for all current Android apps. */
|
||||||
|
status = sync_receive_named(&device, folder_id, MOBILE_EXPORT_V1_NAME,
|
||||||
|
MOBILE_EXPORT_LOCAL, &ignored_size);
|
||||||
|
}
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
(void)snprintf(
|
(void)snprintf(
|
||||||
output->error,
|
output->error,
|
||||||
sizeof(output->error),
|
sizeof(output->error),
|
||||||
|
|
@ -2428,6 +2482,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
sync_run_python_tool(
|
sync_run_python_tool(
|
||||||
"import_mobile_export.py",
|
"import_mobile_export.py",
|
||||||
MOBILE_EXPORT_LOCAL,
|
MOBILE_EXPORT_LOCAL,
|
||||||
|
database_path,
|
||||||
MOBILE_IMPORT_RESULT,
|
MOBILE_IMPORT_RESULT,
|
||||||
tool_output,
|
tool_output,
|
||||||
sizeof(tool_output)
|
sizeof(tool_output)
|
||||||
|
|
@ -2470,10 +2525,43 @@ TrainlogStatus trainlog_sync_run(
|
||||||
output
|
output
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* V1 exports carry no equipment signal. A missing companion therefore
|
||||||
|
* preserves existing desktop associations rather than clearing them. */
|
||||||
|
status = sync_receive_named(&device, folder_id, EQUIPMENT_ASSOCIATIONS_NAME,
|
||||||
|
EQUIPMENT_ASSOCIATIONS_LOCAL, &ignored_size);
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
char useful[
|
||||||
|
TRAINLOG_SYNC_ERROR_MAX + 1U
|
||||||
|
];
|
||||||
|
|
||||||
|
status = sync_run_python_tool("import_equipment_associations.py",
|
||||||
|
EQUIPMENT_ASSOCIATIONS_LOCAL,
|
||||||
|
database_path,
|
||||||
|
EQUIPMENT_ASSOCIATIONS_RESULT,
|
||||||
|
tool_output, sizeof(tool_output));
|
||||||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS") == NULL) {
|
||||||
|
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
|
||||||
|
(void)snprintf(output->error, sizeof(output->error),
|
||||||
|
"Android→PC : import équipement : %s",
|
||||||
|
useful[0] != '\0'
|
||||||
|
? useful
|
||||||
|
: "échec sans diagnostic du script");
|
||||||
|
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 extension équipement échouée.");
|
||||||
|
final_status = status;
|
||||||
|
goto finalize;
|
||||||
|
}
|
||||||
|
|
||||||
status =
|
status =
|
||||||
sync_run_python_tool(
|
sync_run_python_tool(
|
||||||
"export_pc_catalog.py",
|
"export_pc_catalog.py",
|
||||||
PC_CATALOG_LOCAL,
|
PC_CATALOG_LOCAL,
|
||||||
|
database_path,
|
||||||
PC_CATALOG_RESULT,
|
PC_CATALOG_RESULT,
|
||||||
tool_output,
|
tool_output,
|
||||||
sizeof(tool_output)
|
sizeof(tool_output)
|
||||||
|
|
@ -2517,6 +2605,21 @@ TrainlogStatus trainlog_sync_run(
|
||||||
"exercises"
|
"exercises"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
status = sync_run_python_tool("export_pc_mobile.py", PC_MOBILE_EXPORT_LOCAL,
|
||||||
|
database_path,
|
||||||
|
PC_CATALOG_RESULT, tool_output, sizeof(tool_output));
|
||||||
|
if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "PC_MOBILE_EXPORT=PASS") == NULL) {
|
||||||
|
(void)snprintf(output->error, sizeof(output->error), "PC→Android : export séances V2 échoué.");
|
||||||
|
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
goto finalize;
|
||||||
|
}
|
||||||
|
status = sync_publish_named(&device, folder_id, PC_MOBILE_EXPORT_LOCAL, PC_MOBILE_EXPORT_NAME);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
(void)snprintf(output->error, sizeof(output->error), "PC→Android : publication séances V2 échouée.");
|
||||||
|
final_status = status;
|
||||||
|
goto finalize;
|
||||||
|
}
|
||||||
|
|
||||||
status =
|
status =
|
||||||
sync_publish_named(
|
sync_publish_named(
|
||||||
&device,
|
&device,
|
||||||
|
|
@ -2540,6 +2643,28 @@ TrainlogStatus trainlog_sync_run(
|
||||||
goto finalize;
|
goto finalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
status = sync_run_python_tool("export_equipment_associations.py",
|
||||||
|
EQUIPMENT_ASSOCIATIONS_LOCAL,
|
||||||
|
database_path,
|
||||||
|
EQUIPMENT_ASSOCIATIONS_RESULT,
|
||||||
|
tool_output, sizeof(tool_output));
|
||||||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS") == NULL) {
|
||||||
|
(void)snprintf(output->error, sizeof(output->error),
|
||||||
|
"PC→Android : export équipement échoué.");
|
||||||
|
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
goto finalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
status = sync_publish_named(&device, folder_id, EQUIPMENT_ASSOCIATIONS_LOCAL,
|
||||||
|
EQUIPMENT_ASSOCIATIONS_NAME);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
(void)snprintf(output->error, sizeof(output->error),
|
||||||
|
"PC→Android : publication équipement échouée.");
|
||||||
|
final_status = status;
|
||||||
|
goto finalize;
|
||||||
|
}
|
||||||
|
|
||||||
output->success = true;
|
output->success = true;
|
||||||
final_status =
|
final_status =
|
||||||
TRAINLOG_STATUS_OK;
|
TRAINLOG_STATUS_OK;
|
||||||
|
|
|
||||||
|
|
@ -7172,89 +7172,9 @@ static void screen_session_detail(
|
||||||
|
|
||||||
static void history_ascii_header(void)
|
static void history_ascii_header(void)
|
||||||
{
|
{
|
||||||
static const char *const logo[] = {
|
/* CONTRACT: history shares the current page shell; the old local logo
|
||||||
"TTTTT RRRR AAA IIIII N N L OOO GGG ",
|
* occupied six unrelated rows and made this screen an exception. */
|
||||||
" T R R A A I NN N L O O G ",
|
section_ascii_header("Historique");
|
||||||
" T RRRR AAAAA I N N N L O O G GG",
|
|
||||||
" T R R A A I N NN L O O G G",
|
|
||||||
" T R R A A IIIII N N LLLLL OOO GGG "
|
|
||||||
};
|
|
||||||
|
|
||||||
const size_t line_count =
|
|
||||||
sizeof(logo) /
|
|
||||||
sizeof(logo[0]);
|
|
||||||
|
|
||||||
size_t index;
|
|
||||||
|
|
||||||
trainlog_terminal_style_on(tui_terminal,
|
|
||||||
TRAINLOG_TEXT_BOLD |
|
|
||||||
trainlog_theme_style(
|
|
||||||
TRAINLOG_COLOR_ACCENT
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (index = 0U;
|
|
||||||
index < line_count;
|
|
||||||
++index) {
|
|
||||||
int width =
|
|
||||||
(int)strlen(logo[index]);
|
|
||||||
|
|
||||||
int column =
|
|
||||||
(trainlog_terminal_columns(tui_terminal) - width) / 2;
|
|
||||||
|
|
||||||
if (column < 2) {
|
|
||||||
column = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
trainlog_terminal_printf(tui_terminal,
|
|
||||||
1 + (int)index,
|
|
||||||
column,
|
|
||||||
"%.*s",
|
|
||||||
trainlog_terminal_columns(tui_terminal) - column - 2,
|
|
||||||
logo[index]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
trainlog_terminal_style_off(tui_terminal,
|
|
||||||
TRAINLOG_TEXT_BOLD |
|
|
||||||
trainlog_theme_style(
|
|
||||||
TRAINLOG_COLOR_ACCENT
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
trainlog_terminal_style_on(tui_terminal,
|
|
||||||
trainlog_theme_style(
|
|
||||||
TRAINLOG_COLOR_MUTED
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
{
|
|
||||||
const char *label =
|
|
||||||
":: H I S T O R I Q U E ::";
|
|
||||||
|
|
||||||
int width =
|
|
||||||
(int)strlen(label);
|
|
||||||
|
|
||||||
int column =
|
|
||||||
(trainlog_terminal_columns(tui_terminal) - width) / 2;
|
|
||||||
|
|
||||||
if (column < 2) {
|
|
||||||
column = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
trainlog_terminal_printf(tui_terminal,
|
|
||||||
6,
|
|
||||||
column,
|
|
||||||
"%s",
|
|
||||||
label
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
trainlog_terminal_style_off(tui_terminal,
|
|
||||||
trainlog_theme_style(
|
|
||||||
TRAINLOG_COLOR_MUTED
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void history_scrollbar(
|
static void history_scrollbar(
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,27 @@ static bool test_database_open_and_schema(void)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool test_database_open_diagnostic(void)
|
||||||
|
{
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
char diagnostic[256];
|
||||||
|
|
||||||
|
/* CONTRACT: a launcher must retain the stable status code while showing
|
||||||
|
* the SQLite operation that blocked access to the user's real database. */
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_open_with_diagnostic(
|
||||||
|
"/",
|
||||||
|
&database,
|
||||||
|
diagnostic,
|
||||||
|
sizeof(diagnostic)
|
||||||
|
) == TRAINLOG_STATUS_DATABASE_ERROR
|
||||||
|
);
|
||||||
|
CHECK(database == NULL);
|
||||||
|
CHECK(strstr(diagnostic, "open database:") != NULL);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool test_generated_ids(void)
|
static bool test_generated_ids(void)
|
||||||
{
|
{
|
||||||
char first[TRAINLOG_GENERATED_ID_CAPACITY];
|
char first[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||||
|
|
@ -262,6 +283,7 @@ int main(void)
|
||||||
{
|
{
|
||||||
static const struct TestCase tests[] = {
|
static const struct TestCase tests[] = {
|
||||||
{"database_open_and_schema", test_database_open_and_schema},
|
{"database_open_and_schema", test_database_open_and_schema},
|
||||||
|
{"database_open_diagnostic", test_database_open_diagnostic},
|
||||||
{"generated_ids", test_generated_ids},
|
{"generated_ids", test_generated_ids},
|
||||||
{"session_insert", test_session_insert},
|
{"session_insert", test_session_insert},
|
||||||
{"body_weight_history", test_body_weight_history},
|
{"body_weight_history", test_body_weight_history},
|
||||||
|
|
|
||||||
24
tui/tests/test_equipment_catalog.c
Normal file
24
tui/tests/test_equipment_catalog.c
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#include <assert.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "trainlog/equipment_catalog.h"
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
const TrainlogEquipment *matches[8];
|
||||||
|
const TrainlogEquipment *machine;
|
||||||
|
size_t count;
|
||||||
|
|
||||||
|
assert(trainlog_equipment_catalog_count() == 38U);
|
||||||
|
machine = trainlog_equipment_catalog_lookup("assisted_dip_chin_machine");
|
||||||
|
assert(machine != NULL);
|
||||||
|
assert(strcmp(machine->load_semantics, "assistance") == 0);
|
||||||
|
count = trainlog_equipment_catalog_search("TIRAGE", matches, 8U);
|
||||||
|
assert(count >= 6U);
|
||||||
|
count = trainlog_equipment_catalog_search("ischio", matches, 8U);
|
||||||
|
assert(count >= 2U);
|
||||||
|
assert(trainlog_equipment_catalog_relation_count() == 2U);
|
||||||
|
assert(strcmp(trainlog_equipment_catalog_relation_at(0U)->equipment_id,
|
||||||
|
"assisted_dip_chin_machine") == 0);
|
||||||
|
assert(trainlog_equipment_catalog_lookup("missing") == NULL);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* @file test_schema_v5_migration.c
|
* @file test_schema_v5_migration.c
|
||||||
* @brief Direct v4 -> v5 migration regression test.
|
* @brief Direct v4 -> v7 migration regression test.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
} \
|
} \
|
||||||
} while (0)
|
} while (0)
|
||||||
|
|
||||||
static bool test_v4_to_v5_preserves_session(void)
|
static bool test_v4_to_v7_preserves_session(void)
|
||||||
{
|
{
|
||||||
char path[] =
|
char path[] =
|
||||||
"/tmp/trainlog-schema-v4-v5-XXXXXX";
|
"/tmp/trainlog-schema-v4-v5-XXXXXX";
|
||||||
|
|
@ -188,7 +188,7 @@ static bool test_v4_to_v5_preserves_session(void)
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK(
|
||||||
version == 5
|
version == 7
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(
|
CHECK(
|
||||||
|
|
@ -228,7 +228,7 @@ static bool test_v4_to_v5_preserves_session(void)
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
CHECK(
|
CHECK(
|
||||||
test_v4_to_v5_preserves_session()
|
test_v4_to_v7_preserves_session()
|
||||||
);
|
);
|
||||||
|
|
||||||
(void)printf(
|
(void)printf(
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ static bool test_session_details(void)
|
||||||
"%s",
|
"%s",
|
||||||
"ex_detail"
|
"ex_detail"
|
||||||
);
|
);
|
||||||
|
(void)snprintf(exercise.equipment_id, sizeof(exercise.equipment_id),
|
||||||
|
"%s", "leg_press");
|
||||||
|
|
||||||
exercise.load_mode =
|
exercise.load_mode =
|
||||||
TRAINLOG_LOAD_EXTERNAL;
|
TRAINLOG_LOAD_EXTERNAL;
|
||||||
|
|
@ -144,6 +146,7 @@ static bool test_session_details(void)
|
||||||
CHECK(details[0].target_sets == 3);
|
CHECK(details[0].target_sets == 3);
|
||||||
CHECK(details[0].target_reps == 5);
|
CHECK(details[0].target_reps == 5);
|
||||||
CHECK(details[0].rest_seconds == 60);
|
CHECK(details[0].rest_seconds == 60);
|
||||||
|
CHECK(strcmp(details[0].equipment_id, "leg_press") == 0);
|
||||||
CHECK(details[0].actual_set_count == 3U);
|
CHECK(details[0].actual_set_count == 3U);
|
||||||
CHECK(
|
CHECK(
|
||||||
strcmp(
|
strcmp(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue