diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt index 2fdab0b..5c05363 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -219,7 +219,7 @@ internal class AndroidBackupService(private val context: Context) { manifest.getInt("version") == VERSION && manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") && - manifest.getInt("schema") in 17..21 + manifest.getInt("schema") in 17..22 ) { "Unsupported backup." } @@ -313,7 +313,7 @@ internal class AndroidBackupService(private val context: Context) { } ) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) - require(db.version in 17..21) { "Unsupported Android schema ${db.version}." } + require(db.version in 17..22) { "Unsupported Android schema ${db.version}." } return db.version } finally { db.close() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index d951ac1..aeb4c69 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -69,7 +69,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) ) private val SUPPORTED = (ANDROID_KINDS.values + - Kind("trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", false)) + Kind("trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", false) + + Kind("trainlog-session-preparations", 1, "session-preparations-v1.json", false)) .map { it.format to it.version } .toSet() @@ -835,6 +836,12 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) apply("ai-proposals") { repository.applyAiSessionDraftsJson(checkNotNull(a["ai-proposals"])) } + if ("session-preparations" in a) + apply("session-preparations") { + repository.applySessionPreparationsJson( + checkNotNull(a["session-preparations"]) + ) + } val consumedAt = OffsetDateTime.now().toString() val ack = JSONObject() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 0f5ddc3..b314bf5 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -24,6 +24,7 @@ import com.labfytools.trainlog.model.ExerciseFeedback import com.labfytools.trainlog.model.DraftExerciseFeedback import com.labfytools.trainlog.model.LatestExerciseMax import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.PreparedSession import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.SessionDraft import com.labfytools.trainlog.model.SessionDraftForm @@ -1874,6 +1875,147 @@ class TrainlogRepository( return output } + /** + * Consumes immutable manual-preparation deliveries without touching AI + * proposals or the active draft. GenerationService owns the outer + * transaction, so any later participant failure rolls these rows back. + */ + internal fun applySessionPreparationsJson(json: String): AiSessionDraftImportResult { + val root = try { JSONObject(json) } catch (_: Exception) { + return AiSessionDraftImportResult.Invalid("Artifact de préparations invalide.") + } + if (!root.hasExactKeys(setOf("format", "version", "generated_at", "deliveries")) || + root.optString("format") != "trainlog-session-preparations" || root.optInt("version") != 1) + return AiSessionDraftImportResult.Invalid("Artifact de préparations v1 non supporté.") + val values = root.optJSONArray("deliveries") + ?: return AiSessionDraftImportResult.Invalid("Livraisons de préparations manquantes.") + if (values.length() > 128) return AiSessionDraftImportResult.Invalid("Trop de préparations.") + val db = database.writableDatabase + var imported = 0 + var skipped = 0 + return try { + for (deliveryIndex in 0 until values.length()) { + val delivery = values.getJSONObject(deliveryIndex) + val required = setOf("delivery_id", "preparation_id", "revision_id", "execution_session_id", + "state", "title", "session_type", "planned_for", "notes", "source_proposal_id", + "source_payload_sha256", "occurrences") + require(delivery.hasExactKeys(required)) { "Forme de préparation invalide." } + val deliveryId = delivery.getString("delivery_id") + val preparationId = delivery.getString("preparation_id") + val revisionId = delivery.getString("revision_id") + val executionId = delivery.getString("execution_session_id") + require(deliveryId.startsWith("spd_") && preparationId.startsWith("sp_") && + revisionId.startsWith("spr_") && executionId.startsWith("se_")) { "Identité de préparation invalide." } + require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." } + val occurrences = delivery.getJSONArray("occurrences") + require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." } + parsePreparedOccurrences(db, occurrences) + val existing = db.rawQuery( + "SELECT revision_id,payload_json FROM session_preparation_deliveries WHERE delivery_id=?", + arrayOf(deliveryId), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) to cursor.getString(1) else null } + val canonical = delivery.toString() + if (existing == null) { + db.insertOrThrow("session_preparation_deliveries", null, ContentValues().apply { + put("delivery_id", deliveryId); put("preparation_id", preparationId) + put("revision_id", revisionId); put("execution_session_id", executionId) + put("state", "pending"); put("payload_json", canonical) + put("received_at", OffsetDateTime.now(java.time.ZoneOffset.UTC).toString()) + }) + imported++ + } else { + require(existing.first == revisionId && JSONObject(existing.second).toString() == canonical) { + "Identité de livraison réutilisée avec un contenu différent." + } + skipped++ + } + } + AiSessionDraftImportResult.Applied(imported, skipped) + } catch (error: IllegalArgumentException) { + AiSessionDraftImportResult.Invalid(error.message ?: "Préparation invalide.") + } catch (_: Exception) { + AiSessionDraftImportResult.DatabaseError + } + } + + private fun parsePreparedOccurrences(db: SQLiteDatabase, values: JSONArray): List = + buildList { + val identities = mutableSetOf() + for (itemIndex in 0 until values.length()) { + val item = values.getJSONObject(itemIndex) + val entryId = item.getString("entry_id") + require(entryId.startsWith("spe_") && identities.add(entryId)) { "Occurrence dupliquée." } + val exerciseId = item.getString("exercise_id") + val exercise = readExerciseProfileExact(db, exerciseId) + ?: throw IllegalArgumentException("Exercice de préparation inconnu : $exerciseId") + require(item.getString("recording_mode") == exercise.recordingMode.wireValue && + item.getString("tracking_mode") == exercise.trackingMode.wireValue && + item.getInt("data_fields") == exercise.dataFields) { "Profil de préparation divergent." } + val equipmentId = if (item.isNull("equipment_id")) null else item.getString("equipment_id") + require(equipmentId == null || lookupEquipmentRowIdOrNull(db, equipmentId) != null) { + "Équipement de préparation inconnu." + } + val loadMode = SessionLoadMode.fromWire(item.getString("load_mode")) + val sets = if (item.isNull("target_sets")) null else item.getInt("target_sets") + val reps = if (item.isNull("target_reps")) null else item.getInt("target_reps") + val duration = if (item.isNull("target_duration_seconds")) null else item.getInt("target_duration_seconds") + val weight = if (item.isNull("target_weight_kg")) null else item.getDouble("target_weight_kg") + require(item.getInt("position") == size && item.getInt("rest_seconds") in 0..MAX_PLAN_REST_SECONDS) + require(if (exercise.recordingMode == RecordingMode.CONTINUOUS) { + sets == null && reps == null && duration != null && loadMode == SessionLoadMode.NONE && weight == null + } else { + sets != null && sets in 1..99 && ((reps != null) xor (duration != null)) && + ((loadMode == SessionLoadMode.NONE) == (weight == null)) + }) { "Objectifs de préparation invalides." } + add(SessionExerciseDraft(entryId = entryId, exercise = exercise, equipmentId = equipmentId, + plan = SessionExercisePlan(sets ?: 0, reps = reps, durationSeconds = duration, + weightKg = weight, loadMode = loadMode, restSeconds = item.getInt("rest_seconds")))) + } + } + + fun listPreparedSessions(): List { + val db = database.readableDatabase + return buildList { + db.rawQuery("SELECT delivery_id,preparation_id,revision_id,execution_session_id,payload_json " + + "FROM session_preparation_deliveries WHERE state='pending' ORDER BY received_at,delivery_id", null) + .use { cursor -> while (cursor.moveToNext()) { + val payload = JSONObject(cursor.getString(4)) + add(PreparedSession(cursor.getString(0), cursor.getString(1), cursor.getString(2), + cursor.getString(3), if (payload.isNull("planned_for")) null else payload.getString("planned_for"), + payload.getString("title"), if (payload.isNull("notes")) null else payload.getString("notes"), + parsePreparedOccurrences(db, payload.getJSONArray("occurrences")))) + }} + } + } + + fun startPreparedSession(deliveryId: String): StartAiSessionDraftResult { + val db = database.writableDatabase + return try { + db.beginTransaction() + try { + if (db.rawQuery("SELECT 1 FROM active_session_draft WHERE id=1", null).use { it.moveToFirst() }) + return StartAiSessionDraftResult.ExistingActiveDraft + val row = db.rawQuery("SELECT execution_session_id,payload_json,state FROM session_preparation_deliveries WHERE delivery_id=?", + arrayOf(deliveryId)).use { cursor -> + if (!cursor.moveToFirst()) null else Triple(cursor.getString(0), cursor.getString(1), cursor.getString(2)) + } ?: return StartAiSessionDraftResult.NotPending + if (row.third != "pending") return StartAiSessionDraftResult.NotPending + val payload = JSONObject(row.second) + val entries = parsePreparedOccurrences(db, payload.getJSONArray("occurrences")) + val type = if (payload.getString("session_type") == "max_test") SessionType.MAX_TEST else SessionType.TRAINING + /* INVARIANT: the desktop-reserved execution identity survives + * start and replay; no performed value is manufactured. */ + persistActiveSessionDraft(db, ActiveSessionDraft(sessionId = row.first, exercises = entries, sessionType = type)) + db.execSQL("UPDATE session_preparation_deliveries SET state='started',started_at=? WHERE delivery_id=? AND state='pending'", + arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), deliveryId)) + db.setTransactionSuccessful() + StartAiSessionDraftResult.Started + } finally { db.endTransaction() } + } catch (error: Exception) { + StartAiSessionDraftResult.Error(error.message ?: "Démarrage de la préparation impossible.") + } + } + fun deleteAiSessionDraft(draftId: String): ActiveDraftMutationResult = try { val db = database.writableDatabase db.beginTransaction() @@ -7934,7 +8076,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 21, + 22, ) { override fun onConfigure( db: SQLiteDatabase, @@ -7980,6 +8122,7 @@ private class TrainlogDatabaseHelper( createCausalDeleteTables(db) createSyncGenerationTables(db) createSyncGenerationArchiveTable(db) + createSessionPreparationTables(db) seedEquipment(db) } @@ -8155,6 +8298,12 @@ private class TrainlogDatabaseHelper( createSyncGenerationArchiveTable(db) version = 21 } + if (version < 22 && newVersion >= 22) { + /* CONTRACT: prepared deliveries are separate from proposals and + * the active singleton. Migration creates no delivery or start. */ + createSessionPreparationTables(db) + version = 22 + } if (version != newVersion) { error( @@ -8262,6 +8411,18 @@ private class TrainlogDatabaseHelper( ) } + private fun createSessionPreparationTables(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS session_preparation_deliveries(" + + "delivery_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL,revision_id TEXT NOT NULL," + + "execution_session_id TEXT NOT NULL UNIQUE,state TEXT NOT NULL " + + "CHECK(state IN('pending','started','cancelled')),payload_json TEXT NOT NULL," + + "received_at TEXT NOT NULL,started_at TEXT);", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS session_preparation_identity ON " + + "session_preparation_deliveries(preparation_id,revision_id);") + } + private fun createAiSessionDraftTables(db: SQLiteDatabase) { db.execSQL( "CREATE TABLE IF NOT EXISTS ai_session_drafts(" + diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt index 73a94e6..50e933d 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt @@ -130,6 +130,18 @@ data class AiSessionDraft( val entries: List, ) +/** Immutable desktop preparation delivery waiting for an explicit start. */ +data class PreparedSession( + val deliveryId: String, + val preparationId: String, + val revisionId: String, + val executionSessionId: String, + val plannedFor: String?, + val title: String, + val notes: String?, + val entries: List, +) + data class SessionSummary( val sessionId: String, val startedAt: String, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt index 45bcf12..40a0948 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt @@ -83,9 +83,36 @@ fun AiSessionDraftsScreen( var message by remember { mutableStateOf(null) } var pendingDeletion by remember { mutableStateOf?>(null) } val drafts = remember(externalRevision, revision) { repository.listAiSessionDrafts() } + val preparations = remember(externalRevision, revision) { repository.listPreparedSessions() } TrainlogScreen(strings.getString(R.string.route_ai_drafts)) { message?.let { TrainlogInfo(it) } - if (drafts.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none)) + if (drafts.isEmpty() && preparations.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none)) + preparations.forEach { preparation -> + TrainlogFrame(preparation.title, active = true) { + TrainlogInfo(listOfNotNull( + strings.getString(R.string.manual_preparation), + preparation.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) }, + strings.resources.getQuantityString(R.plurals.exercise_count, preparation.entries.size, preparation.entries.size), + ).joinToString(" · ")) + preparation.notes?.let { TrainlogInfo(it) } + preparation.entries.forEach { entry -> + val plan = checkNotNull(entry.plan) + val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) } + ?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty() + val weight = plan.weightKg?.let { " · $it kg" }.orEmpty() + TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds)) + } + TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_preparation_description)) { + when (val result = repository.startPreparedSession(preparation.deliveryId)) { + StartAiSessionDraftResult.Started -> { onPendingChanged(); onStarted() } + StartAiSessionDraftResult.ExistingActiveDraft -> + message = strings.getString(R.string.draft_already_active) + StartAiSessionDraftResult.NotPending -> { message = strings.getString(R.string.draft_missing); revision++; onPendingChanged() } + is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message) + } + } + } + } drafts.forEach { draft -> TrainlogFrame(draft.title ?: strings.getString(R.string.session_proposal), active = true) { TrainlogInfo(listOfNotNull( diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index ecd8ad7..f6d1002 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -62,7 +62,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() } val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft val pendingAiDraftCount = remember(draftRevision, catalogRevision) { - repository.listAiSessionDrafts().size + repository.listAiSessionDrafts().size + repository.listPreparedSessions().size } fun open(route: AppRoute) { navigationController.open(route) } diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index 0a7c7d2..90353ed 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -295,6 +295,8 @@ Session proposalPlanned for %1$s %d repetition%d repetitions StartCopy this proposal into the current session. + Manual preparation + Start this preparation without replacing an active session. A session is already in progress. The imported draft was preserved. Draft “%1$s” will be deleted.\nThis action cannot be undone. TrainingResume current session diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8d14940..466ee88 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -295,6 +295,8 @@ Proposition de séancePrévue le %1$s %d répétition%d répétitions DémarrerCopier cette proposition dans la séance en cours. + Préparation manuelle + Démarrer cette préparation sans écraser une séance en cours. Une séance est déjà en cours. Le brouillon importé a été conservé. Le brouillon « %1$s » sera supprimé.\nCette action est irréversible. EntraînementReprendre la séance en cours diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index dc29876..3278586 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(21, scalar(db, "PRAGMA user_version")) + assertEquals(22, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index fc8d091..68effde 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(21, scalar(db, "PRAGMA user_version")) + assertEquals(22, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index cfb5aa2..9ec11ea 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(21, scalar(db, "PRAGMA user_version")) + assertEquals(22, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 85c34e0..d14966d 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(21, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(22, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt index c121ab3..e6d7e8b 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt @@ -43,7 +43,7 @@ class BridgeBackupMigrationTest { null, android.database.sqlite.SQLiteDatabase.OPEN_READONLY, ) - .use { assertEquals(21, it.version) } + .use { assertEquals(22, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt new file mode 100644 index 0000000..b46faaf --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt @@ -0,0 +1,112 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.TrackingMode +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SessionPreparationExchangeTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun artifact(repository: TrainlogRepository): String { + val exercise = repository.listExercises().first { it.recordingMode.wireValue == "sets" } + val occurrence = + JSONObject() + .put("entry_id", "spe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + .put("position", 0) + .put("exercise_id", exercise.exerciseId) + .put("equipment_id", JSONObject.NULL) + .put("recording_mode", exercise.recordingMode.wireValue) + .put("tracking_mode", exercise.trackingMode.wireValue) + .put("data_fields", exercise.dataFields) + .put("load_mode", "none") + .put("rest_seconds", 60) + .put("target_sets", 2) + .put("target_reps", if (exercise.trackingMode.wireValue == "reps") 8 else JSONObject.NULL) + .put( + "target_duration_seconds", + if (exercise.trackingMode.wireValue == "duration") 45 else JSONObject.NULL, + ) + .put("target_weight_kg", JSONObject.NULL) + .put("notes", JSONObject.NULL) + val delivery = + JSONObject() + .put("delivery_id", "spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + .put("preparation_id", "sp_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + .put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc") + .put("execution_session_id", "se_dddddddd-dddd-4ddd-8ddd-dddddddddddd") + .put("state", "pending") + .put("title", "Préparation contrôlée") + .put("session_type", "training") + .put("planned_for", "2026-09-20") + .put("notes", "Sans faits réalisés") + .put("source_proposal_id", JSONObject.NULL) + .put("source_payload_sha256", JSONObject.NULL) + .put("occurrences", JSONArray().put(occurrence)) + return JSONObject() + .put("format", "trainlog-session-preparations") + .put("version", 1) + .put("generated_at", "2026-09-17T20:00:00Z") + .put("deliveries", JSONArray().put(delivery)) + .toString() + } + + @Test + fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() { + val name = "prepared-${System.nanoTime()}.db" + var repository = TrainlogRepository(context, name) + try { + assertTrue(repository.createExercise(NewExerciseProfile( + name = "Test preparation exercise", + recordingMode = RecordingMode.SETS, + trackingMode = TrackingMode.REPS, + dataFields = 0, + )) is CreateExerciseResult.Created) + val payload = artifact(repository) + assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(payload)) + assertEquals(AiSessionDraftImportResult.Applied(0, 1), repository.applySessionPreparationsJson(payload)) + assertEquals(1, repository.listPreparedSessions().size) + assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft()) + assertEquals( + StartAiSessionDraftResult.ExistingActiveDraft, + repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + ) + repository.close() + context.deleteDatabase(name) + repository = TrainlogRepository(context, name) + assertTrue(repository.createExercise(NewExerciseProfile( + name = "Test preparation exercise", + recordingMode = RecordingMode.SETS, + trackingMode = TrackingMode.REPS, + dataFields = 0, + )) is CreateExerciseResult.Created) + val reloadedPayload = artifact(repository) + assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(reloadedPayload)) + assertEquals( + StartAiSessionDraftResult.Started, + repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + ) + val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + assertEquals("se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", active.draft.sessionId) + assertTrue(active.draft.exercises.all { it.sets.isEmpty() }) + assertEquals( + StartAiSessionDraftResult.ExistingActiveDraft, + repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + ) + } finally { + repository.close() + context.deleteDatabase(name) + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index 7d8592b..cca9603 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -63,7 +63,7 @@ class SyncGenerationServiceTest { .use { db -> db.rawQuery("PRAGMA user_version", null).use { assertTrue(it.moveToFirst()) - assertEquals(21, it.getInt(0)) + assertEquals(22, it.getInt(0)) } db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { assertTrue(it.moveToFirst()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 18d0565..6fa47d4 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(21, cursor.getInt(0)) + assertEquals(22, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(21, cursor.getInt(0)) + assertEquals(22, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/tests/test_sync_generation_exchange.py b/tests/test_sync_generation_exchange.py index 8e98975..80d70d2 100644 --- a/tests/test_sync_generation_exchange.py +++ b/tests/test_sync_generation_exchange.py @@ -23,6 +23,8 @@ CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEX CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT); CREATE TABLE sync_generation_archives(generation_id TEXT PRIMARY KEY,archive_path TEXT UNIQUE,manifest_sha256 TEXT,archive_sha256 TEXT,archived_at TEXT,audit_json TEXT); CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id)); +CREATE TABLE session_preparation_deliveries(delivery_id TEXT PRIMARY KEY,preparation_id TEXT,revision_id TEXT,execution_session_id TEXT,state TEXT,created_at TEXT,generation_id TEXT,acknowledged_at TEXT); +CREATE TABLE session_preparations(preparation_id TEXT PRIMARY KEY,current_revision_id TEXT,delivery_state TEXT); PRAGMA user_version=23; """ diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py new file mode 100644 index 0000000..e8a324d --- /dev/null +++ b/tools/export_session_preparations.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Export immutable ready preparation deliveries for Android.""" + +import argparse +import datetime as dt +import json +import os +import sqlite3 +import tempfile +from pathlib import Path + +from trainlog_sqlite import connect_database + +MAX_DELIVERIES = 128 +MAX_ENTRIES = 64 + + +def build_export(connection: sqlite3.Connection) -> dict: + if connection.execute("PRAGMA user_version").fetchone()[0] != 23: + raise ValueError("desktop schema v23 required") + deliveries = [] + rows = connection.execute( + "SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state," + "r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256 " + "FROM session_preparation_deliveries d " + "JOIN session_preparations p ON p.preparation_id=d.preparation_id " + "JOIN session_preparation_revisions r ON r.revision_id=d.revision_id " + "WHERE d.state IN('pending','remote_unknown') " + "ORDER BY d.created_at,d.delivery_id LIMIT ?", + (MAX_DELIVERIES + 1,), + ).fetchall() + if len(rows) > MAX_DELIVERIES: + raise ValueError("preparation delivery capacity exceeded") + for row in rows: + entries = connection.execute( + "SELECT entry_id,position,exercise_id,equipment_id,recording_mode,tracking_mode," + "data_fields,load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds," + "target_weight_kg,notes FROM session_preparation_entries " + "WHERE revision_id=? ORDER BY position,entry_id LIMIT ?", + (row[2], MAX_ENTRIES + 1), + ).fetchall() + if not entries or len(entries) > MAX_ENTRIES: + raise ValueError("invalid preparation occurrence count") + deliveries.append({ + "delivery_id": row[0], "preparation_id": row[1], "revision_id": row[2], + "execution_session_id": row[3], "state": row[4], "title": row[5], + "session_type": row[6], "planned_for": row[7], "notes": row[8], + "source_proposal_id": row[9], "source_payload_sha256": row[10], + "occurrences": [{ + "entry_id": entry[0], "position": entry[1], "exercise_id": entry[2], + "equipment_id": entry[3], "recording_mode": entry[4], + "tracking_mode": entry[5], "data_fields": entry[6], + "load_mode": entry[7], "rest_seconds": entry[8], + "target_sets": entry[9], "target_reps": entry[10], + "target_duration_seconds": entry[11], "target_weight_kg": entry[12], + "notes": entry[13], + } for entry in entries], + }) + return { + "format": "trainlog-session-preparations", "version": 1, + "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(), + "deliveries": deliveries, + } + + +def write_atomic(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(payload, stream, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except Exception: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("output", type=Path) + parser.add_argument("--database", required=True, type=Path) + arguments = parser.parse_args() + try: + with connect_database(arguments.database) as connection: + payload = build_export(connection) + write_atomic(arguments.output, payload) + print(f"SESSION_PREPARATION_EXPORT=PASS deliveries={len(payload['deliveries'])}") + return 0 + except (OSError, sqlite3.Error, ValueError) as error: + print(f"SESSION_PREPARATION_EXPORT=FAIL {error}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index 94041b6..eb1ba47 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -37,6 +37,7 @@ RUNTIME_TOOLS = [ "export_exercise_body_zones.py", "export_training_feedback.py", "export_ai_session_drafts.py", + "export_session_preparations.py", ] @@ -96,7 +97,7 @@ def main() -> int: ).strip(), "product_version": "0.1.2", "desktop_schema": 23, - "android_schema": 21, + "android_schema": 22, "protocols": [ "mobile-export-v3", "mobile-history-v4", diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index c9625ff..be22e04 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -70,6 +70,8 @@ ARTIFACTS = ( "causal_delete_exchange.py", ("export",)), ("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False, "export_ai_session_drafts.py", ()), + ("session-preparations", "trainlog-session-preparations", 1, + "session-preparations-v1.json", False, "export_session_preparations.py", ()), ) SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} @@ -403,6 +405,22 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str, [(generation, value["logical_name"], value["format"], value["version"], value["filename"], value["size"], value["sha256"], int(value["required"])) for value in descriptors]) + # CONTRACT: the exact generation relation is recorded only after + # its complete immutable artifact has been captured successfully. + preparation_path = stage / "session-preparations-v1.json" + if preparation_path.is_file(): + captured_preparations = strict_json( + preparation_path.read_bytes(), MAX_ARTIFACT + )["deliveries"] + for delivery in captured_preparations: + cursor = db.execute( + "UPDATE session_preparation_deliveries SET generation_id=? " + "WHERE delivery_id=? AND revision_id=? AND " + "state IN('pending','remote_unknown') AND generation_id IS NULL", + (generation, delivery["delivery_id"], delivery["revision_id"]), + ) + if cursor.rowcount != 1: + raise GenerationError("preparation delivery changed during capture") causal_path = stage / "causal-deletions-v1.json" if causal_path.is_file(): for operation in json.loads(causal_path.read_text())["operations"]: @@ -661,6 +679,20 @@ def accept_ack(database: Path, path: Path) -> str: raise GenerationError("generation is not awaiting ACK") db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?", (status, ack["consumed_at"], ack["generation_id"])) + if status == "acknowledged": + db.execute( + "UPDATE session_preparation_deliveries SET state='acknowledged',acknowledged_at=? " + "WHERE generation_id=? AND state IN('pending','remote_unknown')", + (ack["consumed_at"], ack["generation_id"]), + ) + db.execute( + "UPDATE session_preparations SET delivery_state='acknowledged' WHERE EXISTS(" + "SELECT 1 FROM session_preparation_deliveries d WHERE " + "d.preparation_id=session_preparations.preparation_id AND " + "d.revision_id=session_preparations.current_revision_id AND " + "d.generation_id=? AND d.state='acknowledged')", + (ack["generation_id"],), + ) db.commit(); return status diff --git a/tui/include/trainlog/web_sessions.h b/tui/include/trainlog/web_sessions.h index 0126e76..159688b 100644 --- a/tui/include/trainlog/web_sessions.h +++ b/tui/include/trainlog/web_sessions.h @@ -65,4 +65,12 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, char **output_json, size_t *output_size); +/* Creates one explicit immutable delivery for a ready current revision. */ +TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database, + const char *preparation_id, + const char *expected_revision, + const char *request_id, + char **output_json, + size_t *output_size); + #endif diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 3dec20c..274270c 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -810,12 +810,15 @@ static enum MHD_Result handle_request(void *closure, const char *identity = NULL; const char *expected = ""; char expected_buffer[TRAINLOG_ID_MAX + 1U]; + char identity_buffer[TRAINLOG_ID_MAX + 1U]; + bool delivery = false; TrainlogStatus status; if ((strcmp(url, "/api/v1/sessions/preparations") == 0 && strcmp(method, MHD_HTTP_METHOD_POST) != 0) || (strcmp(url, "/api/v1/sessions/preparations") != 0 && - strcmp(method, MHD_HTTP_METHOD_PUT) != 0)) { + strcmp(method, MHD_HTTP_METHOD_PUT) != 0 && + strcmp(method, MHD_HTTP_METHOD_POST) != 0)) { return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED, "{\"error\":\"method_not_allowed\"}\n", @@ -837,6 +840,28 @@ static enum MHD_Result handle_request(void *closure, if (strcmp(url, "/api/v1/sessions/preparations") != 0) { size_t length; identity = url + strlen("/api/v1/sessions/preparation/"); + length = strlen(identity); + if (length > strlen("/deliver") && + strcmp(identity + length - strlen("/deliver"), "/deliver") == 0) { + size_t identity_length = length - strlen("/deliver"); + if (identity_length == 0U || identity_length > TRAINLOG_ID_MAX) { + return queue_json(connection, + MHD_HTTP_BAD_REQUEST, + "{\"error\":\"invalid_identity\"}\n", + NULL); + } + (void)memcpy(identity_buffer, identity, identity_length); + identity_buffer[identity_length] = '\0'; + identity = identity_buffer; + delivery = true; + } + if ((delivery && strcmp(method, MHD_HTTP_METHOD_POST) != 0) || + (!delivery && strcmp(method, MHD_HTTP_METHOD_PUT) != 0)) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + delivery ? "POST" : "PUT"); + } if (if_match == NULL || if_match[0] != '"') { return queue_json(connection, MHD_HTTP_PRECONDITION_REQUIRED, @@ -855,14 +880,23 @@ static enum MHD_Result handle_request(void *closure, expected_buffer[length - 2U] = '\0'; expected = expected_buffer; } - status = trainlog_web_sessions_save_json(context->database, - identity, - expected, - request_id, - state->body, - state->body_size, - &json, - &json_size); + if (delivery) { + status = trainlog_web_sessions_deliver_json(context->database, + identity, + expected, + request_id, + &json, + &json_size); + } else { + status = trainlog_web_sessions_save_json(context->database, + identity, + expected, + request_id, + state->body, + state->body_size, + &json, + &json_size); + } if (status == TRAINLOG_STATUS_CONFLICT) { free(json); return queue_json( diff --git a/tui/src/web_sessions.c b/tui/src/web_sessions.c index ddc8b7e..a7e0f8f 100644 --- a/tui/src/web_sessions.c +++ b/tui/src/web_sessions.c @@ -726,7 +726,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, char **output_json, size_t *output_size) { static const char *const ALLOWED[] = { - "title", "session_type", "planned_for", "notes", "editing_state", "occurrences"}; + "title", "session_type", "planned_for", "notes", "editing_state", "occurrences", + "source_proposal_id", "source_payload_sha256"}; yyjson_doc *input = NULL; yyjson_val *root; yyjson_val *occurrences; @@ -738,6 +739,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, const char *title; const char *session_type; const char *editing_state; + const char *source_proposal = NULL; + const char *source_fingerprint = NULL; size_t index; TrainlogStatus status = TRAINLOG_STATUS_OK; yyjson_mut_doc *response; @@ -768,6 +771,21 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, yyjson_doc_free(input); return TRAINLOG_STATUS_INVALID_ARGUMENT; } + if (yyjson_obj_get(root, "source_proposal_id") != NULL && + (!bounded_json_string(yyjson_obj_get(root, "source_proposal_id"), + TRAINLOG_ID_MAX + 1U, + &source_proposal) || + !bounded_json_string(yyjson_obj_get(root, "source_payload_sha256"), + 65U, + &source_fingerprint) || + strlen(source_fingerprint) != 64U)) { + yyjson_doc_free(input); + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + if (preparation_id != NULL && preparation_id[0] != '\0' && source_proposal != NULL) { + yyjson_doc_free(input); + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } if (actual_preparation == NULL || actual_preparation[0] == '\0') { if (expected_revision[0] != '\0' || trainlog_id_generate("sp", new_preparation, sizeof(new_preparation)) != TRAINLOG_STATUS_OK) { @@ -800,6 +818,20 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, (void)sqlite3_finalize(statement); statement = NULL; } + if ((preparation_id == NULL || preparation_id[0] == '\0') && source_proposal != NULL) { + if (sqlite3_prepare_v2(database->connection, + "UPDATE session_preparations SET source_proposal_id=?1,source_payload_sha256=?2 WHERE preparation_id=?3 AND EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=?1 AND i.payload_sha256=?2)", + -1, &statement, NULL) != SQLITE_OK || + sqlite3_bind_text(statement, 1, source_proposal, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 3, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) { + status = TRAINLOG_STATUS_CONFLICT; + goto rollback; + } + (void)sqlite3_finalize(statement); + statement = NULL; + } if (preparation_id != NULL && preparation_id[0] != '\0') { const char *current = NULL; if (sqlite3_prepare_v2(database->connection, @@ -914,3 +946,121 @@ rollback: yyjson_doc_free(input); return status; } + +TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database, + const char *preparation_id, + const char *expected_revision, + const char *request_id, + char **output_json, + size_t *output_size) { + sqlite3_stmt *statement = NULL; + char delivery_id[TRAINLOG_GENERATED_ID_CAPACITY]; + char execution_id[TRAINLOG_GENERATED_ID_CAPACITY]; + char now[TRAINLOG_TIMESTAMP_MAX + 1U]; + yyjson_mut_doc *response = NULL; + yyjson_mut_val *root; + TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR; + + if (database == NULL || preparation_id == NULL || expected_revision == NULL || + request_id == NULL || request_id[0] == '\0' || output_json == NULL || output_size == NULL || + trainlog_id_generate("spd", delivery_id, sizeof(delivery_id)) != TRAINLOG_STATUS_OK || + trainlog_id_generate("se", execution_id, sizeof(execution_id)) != TRAINLOG_STATUS_OK || + !timestamp_now(now)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + *output_json = NULL; + *output_size = 0U; + if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (sqlite3_prepare_v2(database->connection, + "SELECT response_json FROM session_preparation_requests WHERE request_id=?1 AND command='deliver'", + -1, &statement, NULL) == SQLITE_OK && + sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW) { + const char *saved = (const char *)sqlite3_column_text(statement, 0); + *output_size = saved == NULL ? 0U : strlen(saved); + *output_json = saved == NULL ? NULL : strdup(saved); + (void)sqlite3_finalize(statement); + (void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL); + return *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK; + } + if (statement != NULL) { + (void)sqlite3_finalize(statement); + statement = NULL; + } + if (sqlite3_prepare_v2(database->connection, + "SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND p.current_revision_id=?2 AND p.editing_state='ready' AND EXISTS(SELECT 1 FROM session_preparation_entries e WHERE e.revision_id=p.current_revision_id)", + -1, &statement, NULL) != SQLITE_OK || + sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_step(statement) != SQLITE_ROW) { + status = TRAINLOG_STATUS_CONFLICT; + goto delivery_rollback; + } + (void)sqlite3_finalize(statement); + statement = NULL; + if (sqlite3_prepare_v2(database->connection, + "INSERT INTO session_preparation_deliveries VALUES(?1,?2,?3,?4,'pending',?5,NULL,NULL)", + -1, &statement, NULL) != SQLITE_OK || + sqlite3_bind_text(statement, 1, delivery_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 4, execution_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_step(statement) != SQLITE_DONE) { + goto delivery_rollback; + } + (void)sqlite3_finalize(statement); + statement = NULL; + response = yyjson_mut_doc_new(NULL); + root = response == NULL ? NULL : yyjson_mut_obj(response); + if (response == NULL || root == NULL) { + status = TRAINLOG_STATUS_SYSTEM_ERROR; + goto delivery_rollback; + } + yyjson_mut_doc_set_root(response, root); + (void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U); + (void)yyjson_mut_obj_add_strcpy(response, root, "delivery_id", delivery_id); + (void)yyjson_mut_obj_add_strcpy(response, root, "execution_session_id", execution_id); + (void)yyjson_mut_obj_add_strcpy(response, root, "revision_id", expected_revision); + status = write_document(response, output_json, output_size); + response = NULL; + if (status != TRAINLOG_STATUS_OK || + sqlite3_prepare_v2(database->connection, + "INSERT INTO session_preparation_requests VALUES(?1,'deliver',?2,?3,?4,?5)", + -1, &statement, NULL) != SQLITE_OK || + sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 4, *output_json, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { + statement = NULL; + goto delivery_rollback; + } + statement = NULL; + if (sqlite3_prepare_v2(database->connection, + "UPDATE session_preparations SET delivery_state='pending' WHERE preparation_id=?1 AND current_revision_id=?2", + -1, &statement, NULL) != SQLITE_OK || + sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1 || + sqlite3_finalize(statement) != SQLITE_OK || + sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { + statement = NULL; + goto delivery_rollback; + } + return TRAINLOG_STATUS_OK; + +delivery_rollback: + if (statement != NULL) { + (void)sqlite3_finalize(statement); + } + yyjson_mut_doc_free(response); + (void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL); + free(*output_json); + *output_json = NULL; + *output_size = 0U; + return status; +} diff --git a/web/src/api/sessions.ts b/web/src/api/sessions.ts index 44c4e53..2c96a00 100644 --- a/web/src/api/sessions.ts +++ b/web/src/api/sessions.ts @@ -65,6 +65,8 @@ export interface PreparationInput { notes: string | null editing_state: 'draft' | 'ready' occurrences: PreparationOccurrence[] + source_proposal_id?: string + source_payload_sha256?: string } function object(value: unknown): value is Record { @@ -173,3 +175,24 @@ export async function savePreparation( typeof value.revision_id !== 'string') throw new TypeError('réponse de sauvegarde invalide') return { preparation_id: value.preparation_id, revision_id: value.revision_id } } + +export async function prepareForAndroid(identity: string, revision: string): Promise { + const csrf = await mutationCsrfToken() + const response = await fetch( + `/api/v1/sessions/preparation/${encodeURIComponent(identity)}/deliver`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Trainlog-CSRF-Token': csrf, + 'X-Trainlog-Request-ID': requestId(), + 'If-Match': `"${revision}"`, + }, + body: '{}', + }) + const value: unknown = await response.json() + if (!response.ok) { + const reason = object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}` + throw new Error(reason) + } +} diff --git a/web/src/routes/SessionsPage.tsx b/web/src/routes/SessionsPage.tsx index 02786b6..7708fb1 100644 --- a/web/src/routes/SessionsPage.tsx +++ b/web/src/routes/SessionsPage.tsx @@ -3,6 +3,7 @@ import { fetchCatalog, fetchSessionDetail, fetchReadonlySessionDetail, + prepareForAndroid, fetchSessionPage, savePreparation, type CatalogChoice, @@ -37,7 +38,13 @@ function Editor({ initial, onSaved, onCancel }: { }) { const [input, setInput] = useState(() => initial ? { title: initial.title, session_type: initial.session_type, planned_for: initial.planned_for, - notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences, + notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences.map((value) => ({ + ...value, entry_id: initial.kind === 'proposal' ? undefined : value.entry_id, + })), + ...(initial.kind === 'proposal' ? { + source_proposal_id: initial.identity, + source_payload_sha256: initial.source_fingerprint ?? undefined, + } : {}), } : emptyInput()) const [catalog, setCatalog] = useState([]) const [catalogSearch, setCatalogSearch] = useState('') @@ -72,7 +79,9 @@ function Editor({ initial, onSaved, onCancel }: { setSaving(true); setError('') try { const result = await savePreparation({ ...input, editing_state: ready ? 'ready' : 'draft' }, - initial?.identity, initial?.revision_id) + initial?.kind === 'preparation' ? initial.identity : undefined, + initial?.kind === 'preparation' ? initial.revision_id : undefined) + if (ready) await prepareForAndroid(result.preparation_id, result.revision_id) onSaved(result.preparation_id) } catch (reason) { setError(reason instanceof Error ? reason.message : 'Sauvegarde impossible') @@ -149,7 +158,8 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro if (!detail) return

Chargement de la fiche…

return
- {kind === 'preparation' && }
+

{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}

{detail.title || 'Sans titre'}

{detail.planned_for ?? 'Aucune date planifiée'} · {detail.state}

{detail.notes &&

Note

{detail.notes}

}