Deliver prepared sessions through generation sync

This commit is contained in:
fy59 2026-09-17 20:47:15 +02:00
parent 47385c7a86
commit e0a0397d2a
25 changed files with 711 additions and 28 deletions

View file

@ -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()

View file

@ -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()

View file

@ -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<SessionExerciseDraft> =
buildList {
val identities = mutableSetOf<String>()
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<PreparedSession> {
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(" +

View file

@ -130,6 +130,18 @@ data class AiSessionDraft(
val entries: List<SessionExerciseDraft>,
)
/** 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<SessionExerciseDraft>,
)
data class SessionSummary(
val sessionId: String,
val startedAt: String,

View file

@ -83,9 +83,36 @@ fun AiSessionDraftsScreen(
var message by remember { mutableStateOf<String?>(null) }
var pendingDeletion by remember { mutableStateOf<Pair<String, String>?>(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(

View file

@ -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) }

View file

@ -295,6 +295,8 @@
<string name="session_proposal">Session proposal</string><string name="planned_for">Planned for %1$s</string>
<plurals name="repetition_count"><item quantity="one">%d repetition</item><item quantity="other">%d repetitions</item></plurals>
<string name="start">Start</string><string name="start_proposal_description">Copy this proposal into the current session.</string>
<string name="manual_preparation">Manual preparation</string>
<string name="start_preparation_description">Start this preparation without replacing an active session.</string>
<string name="draft_already_active">A session is already in progress. The imported draft was preserved.</string>
<string name="delete_draft_detail">Draft “%1$s” will be deleted.\nThis action cannot be undone.</string>
<string name="training">Training</string><string name="resume_current_session">Resume current session</string>

View file

@ -295,6 +295,8 @@
<string name="session_proposal">Proposition de séance</string><string name="planned_for">Prévue le %1$s</string>
<plurals name="repetition_count"><item quantity="one">%d répétition</item><item quantity="other">%d répétitions</item></plurals>
<string name="start">Démarrer</string><string name="start_proposal_description">Copier cette proposition dans la séance en cours.</string>
<string name="manual_preparation">Préparation manuelle</string>
<string name="start_preparation_description">Démarrer cette préparation sans écraser une séance en cours.</string>
<string name="draft_already_active">Une séance est déjà en cours. Le brouillon importé a été conservé.</string>
<string name="delete_draft_detail">Le brouillon « %1$s » sera supprimé.\nCette action est irréversible.</string>
<string name="training">Entraînement</string><string name="resume_current_session">Reprendre la séance en cours</string>

View file

@ -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"))

View file

@ -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") }

View file

@ -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"),

View file

@ -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))

View file

@ -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)

View file

@ -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<Context>()
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)
}
}
}

View file

@ -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())

View file

@ -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())

View file

@ -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;
"""

View file

@ -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())

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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;
}

View file

@ -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<string, unknown> {
@ -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<void> {
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)
}
}

View file

@ -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<PreparationInput>(() => 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<CatalogChoice[]>([])
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 <p aria-live="polite">Chargement de la fiche…</p>
return <article className="session-detail"><div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
{kind === 'preparation' && <button type="button" className="primary-action" onClick={() => onEdit(detail)}>Modifier</button>}</div>
<button type="button" className="primary-action" onClick={() => onEdit(detail)}>
{kind === 'preparation' ? 'Modifier' : 'Préparer à partir de cette proposition'}</button></div>
<header className="detail-summary"><p className="eyebrow">{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}</p>
<h2>{detail.title || 'Sans titre'}</h2><p>{detail.planned_for ?? 'Aucune date planifiée'} · {detail.state}</p></header>
{detail.notes && <section className="detail-tile"><h3>Note</h3><p>{detail.notes}</p></section>}