feat(programs): add deletion and Android projection

This commit is contained in:
fy59 2026-09-18 19:17:34 +02:00
parent 4d6ee07ac5
commit 20fc427c2f
77 changed files with 1935 additions and 116 deletions

View file

@ -2,6 +2,22 @@
## Unreleased — 0.1.2
- Implemented `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1` without
deployment. Desktop schema v26 adds terminal logical Program deletion with
request/deleted revisions, durable response replay, generation linkage and
acknowledgment state; source definitions and derived preparations remain,
while deleted Programs cannot resurrect or prepare. The responsive Sessions
Programs cards/timeline now use imported/session/usage/provenance data and
protect a discrete accessible trash confirmation with focus restoration and
Escape/Cancel no-op behavior. Android schema v24 adds the read-only synced
Programs list/detail projection. The optional staged controlled-generation
`trainlog-programs` V1 (`programs-v1.json`) publishes nondeleted Programs and
unacknowledged tombstones under capability `programs-v1`; it changes neither
`TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparations. Real
Firefox Programs screenshots are recorded in
`docs/reviews/programs_presentation_android_delete_v1_evidence.md`;
deployment remains pending.
- Completed the private grouped rollout of Web Sessions deletion and Program
V1 from desktop source `a0fc48f082b76dba2ae3a56fceb492169de89f89`.
Desktop migrated from schema v24 to v25 after backup, restoration, and

View file

@ -17,7 +17,7 @@ over direct MTP; it never copies SQLite database files between devices.
|---|---|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
| Local Web (0.1.2 development) | Display the Dashboard and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and embedded Programs workflow through typed Trainlog Core/API boundaries. Analyse, the standalone Programmes route, and Exercises remain placeholders. |
| Local Web (0.1.2 development) | Display the Dashboard and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and embedded Programs workflow through typed Trainlog Core/API boundaries. Programs has responsive detail and terminal logical deletion; its latest Android projection companion is implemented but not deployed. Analyse, the standalone Programmes route, and Exercises remain placeholders. |
No interface reconstructs business truth from SQLite tables. The local Web is
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
@ -320,7 +320,7 @@ not contain cloud credentials and does not run `rclone`.
As of 2026-09-18:
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v25 and Android SQLite schema v23;
- desktop SQLite schema v26 and Android SQLite schema v24;
- Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;
@ -335,6 +335,10 @@ As of 2026-09-18:
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
Programs is implemented inside Sessions; Analyse, Exercises, and the
standalone top-level Programmes route remain placeholders;
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=IMPLEMENTED_VALIDATED`:
desktop Programs presentation/logical deletion and the Android read-only
projection are implemented and have real Firefox screenshot evidence, but
deployment remains pending;
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
services; automatic synchronization still selects V3. The trusted local

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..23
manifest.getInt("schema") in 17..24
) {
"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..23) { "Unsupported Android schema ${db.version}." }
require(db.version in 17..24) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()

View file

@ -164,6 +164,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
"execution-draft-v1",
"generation-archive-v1",
"session-preparations-v2",
"programs-v1",
)
),
)

View file

@ -70,7 +70,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
private val SUPPORTED =
(ANDROID_KINDS.values +
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false))
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) +
Kind("trainlog-programs", 1, "programs-v1.json", false))
.map { it.format to it.version }
.toSet()
@ -658,6 +659,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
is ExerciseAliasImportResult.Applied,
is ExecutionDraftImportResult.Applied,
is AiSessionDraftImportResult.Applied,
is ProgramsImportResult.Applied,
is CausalDeleteResult.Applied,
is CausalDeleteResult.Unchanged -> null
else -> result.toString()
@ -799,6 +801,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
apply("causal-deletions") {
repository.applyCausalDeletionExportV1Json(checkNotNull(a["causal-deletions"]))
}
if ("programs-v1" in a)
apply("programs-v1") {
repository.applyProgramsV1Json(checkNotNull(a["programs-v1"]))
}
apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
apply("exercise-profile-state") {
repository.applyExerciseProfileStateJson(

View file

@ -38,6 +38,10 @@ import com.labfytools.trainlog.model.SessionLoadMode
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.model.SyncedProgramDetail
import com.labfytools.trainlog.model.SyncedProgramOccurrence
import com.labfytools.trainlog.model.SyncedProgramSession
import com.labfytools.trainlog.model.SyncedProgramSummary
import org.json.JSONArray
import org.json.JSONObject
import java.text.Normalizer
@ -218,7 +222,14 @@ sealed interface MobileSessionImportResult {
data object DatabaseError : MobileSessionImportResult
}
sealed interface ProgramsImportResult {
data class Applied(val imported: Int, val deleted: Int, val skipped: Int) : ProgramsImportResult
data class Invalid(val message: String) : ProgramsImportResult
data object DatabaseError : ProgramsImportResult
}
private class SyncLifecycleConflict(val ownerId: String) : RuntimeException()
private class ProgramsImportInvalid(message: String) : RuntimeException(message)
sealed interface CreateEquipmentResult {
data class Created(val equipment: EquipmentCatalogEntry) : CreateEquipmentResult
@ -8056,6 +8067,299 @@ class TrainlogRepository(
secondaryZoneIds.forEach { insert(it, "secondary") }
}
/**
* Applies the desktop-owned read-only program projection.
*
* WHY: snapshot omission is not proof of deletion on an intermittently connected phone.
* CONTRACT: the complete document is validated before one transactional replacement pass;
* explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always
* wins over a later stale snapshot and never touches drafts, preparations, history or catalog.
*/
fun applyProgramsV1Json(json: String): ProgramsImportResult {
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
val root = try { JSONObject(json) } catch (_: Exception) {
return ProgramsImportResult.Invalid("Invalid Programs JSON.")
}
fun JSONObject.exact(vararg names: String): Boolean =
keys().asSequence().toSet() == names.toSet()
fun bounded(value: Any?, maximum: Int, nullable: Boolean = false): Boolean {
if (value == null || value == JSONObject.NULL) return nullable
val text = value as? String ?: return false
return text.isNotBlank() && text.toByteArray(StandardCharsets.UTF_8).size <= maximum
}
fun nullableBounded(value: Any?, maximum: Int): Boolean =
value == JSONObject.NULL || bounded(value, maximum)
fun date(value: Any?): Boolean = value == JSONObject.NULL ||
(value is String && try { LocalDate.parse(value); true } catch (_: Exception) { false })
fun timestamp(value: Any?): Boolean = value is String &&
try { OffsetDateTime.parse(value); true } catch (_: Exception) { false }
fun integer(value: Any?, minimum: Int, maximum: Int): Boolean =
(value is Int || value is Long) && (value as Number).toLong() in minimum.toLong()..maximum.toLong()
fun optionalInteger(value: Any?, minimum: Int, maximum: Int): Boolean =
value == JSONObject.NULL || integer(value, minimum, maximum)
fun optionalWeight(value: Any?): Boolean = value == JSONObject.NULL ||
(value is Number && value.toDouble().isFinite() && value.toDouble() >= 0.0)
try {
if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
root.optString("format") != "trainlog-programs" || root.opt("version") !is Int ||
root.getInt("version") != 1 || !timestamp(root.opt("generated_at")) ||
root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray)
throw ProgramsImportInvalid("Unsupported Programs document.")
val programs = root.getJSONArray("programs")
val deletions = root.getJSONArray("deletions")
if (programs.length() > 256 || deletions.length() > 4096)
throw ProgramsImportInvalid("Programs document exceeds its item bound.")
val programIds = mutableSetOf<String>()
val sessionIds = mutableSetOf<String>()
val deletionIds = mutableSetOf<String>()
val deletedProgramIds = mutableSetOf<String>()
for (index in 0 until deletions.length()) {
val deletion = deletions.optJSONObject(index)
?: throw ProgramsImportInvalid("Invalid program deletion.")
if (!deletion.exact("deletion_id", "program_id", "predecessor_revision_id", "revision_id", "requested_at") ||
!bounded(deletion.opt("deletion_id"), 128) ||
!bounded(deletion.opt("program_id"), 128) ||
!bounded(deletion.opt("predecessor_revision_id"), 128) ||
!bounded(deletion.opt("revision_id"), 128) ||
!timestamp(deletion.opt("requested_at")) ||
!deletionIds.add(deletion.getString("deletion_id")) ||
!deletedProgramIds.add(deletion.getString("program_id")))
throw ProgramsImportInvalid("Invalid or duplicate program deletion.")
}
for (programIndex in 0 until programs.length()) {
val program = programs.optJSONObject(programIndex)
?: throw ProgramsImportInvalid("Invalid program.")
if (!program.exact(
"program_id", "revision_id", "title", "note", "state", "start_date",
"end_date", "created_at", "updated_at", "source_format", "source_version",
"source_payload_sha256", "sessions",
) ||
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
!nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") ||
!date(program.opt("start_date")) || !date(program.opt("end_date")) ||
!timestamp(program.opt("created_at")) || !timestamp(program.opt("updated_at")) ||
!bounded(program.opt("source_format"), 64) || !integer(program.opt("source_version"), 1, Int.MAX_VALUE) ||
(program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true ||
program.opt("sessions") !is JSONArray)
throw ProgramsImportInvalid("Invalid program metadata.")
val sessions = program.getJSONArray("sessions")
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
for (sessionIndex in 0 until sessions.length()) {
val session = sessions.optJSONObject(sessionIndex)
?: throw ProgramsImportInvalid("Invalid program session.")
if (!session.exact("program_session_id", "title", "session_type", "planned_for", "note", "occurrences") ||
!bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) ||
!bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") ||
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
session.opt("occurrences") !is JSONArray)
throw ProgramsImportInvalid("Invalid program session metadata.")
val occurrences = session.getJSONArray("occurrences")
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
val entryIds = mutableSetOf<String>()
for (entryIndex in 0 until occurrences.length()) {
val entry = occurrences.optJSONObject(entryIndex)
?: throw ProgramsImportInvalid("Invalid program occurrence.")
if (!entry.exact(
"entry_id", "exercise_id", "equipment_id", "load_mode",
"rest_seconds", "target_sets", "target_reps",
"target_duration_seconds", "target_weight_kg", "notes",
) ||
!bounded(entry.opt("entry_id"), 128) || !entryIds.add(entry.getString("entry_id")) ||
!bounded(entry.opt("exercise_id"), 128) || !nullableBounded(entry.opt("equipment_id"), 128) ||
entry.optString("load_mode") !in setOf("none", "external", "assistance") ||
!integer(entry.opt("rest_seconds"), 0, 86400) ||
!optionalInteger(entry.opt("target_sets"), 1, 99) ||
!optionalInteger(entry.opt("target_reps"), 1, 10000) ||
!optionalInteger(entry.opt("target_duration_seconds"), 1, 604800) ||
!optionalWeight(entry.opt("target_weight_kg")) || !nullableBounded(entry.opt("notes"), 4000) ||
(entry.isNull("target_reps") == entry.isNull("target_duration_seconds")))
throw ProgramsImportInvalid("Invalid program occurrence values.")
}
}
}
val db = database.writableDatabase
db.beginTransaction()
try {
var deleted = 0
var imported = 0
var skipped = 0
for (index in 0 until deletions.length()) {
val deletion = deletions.getJSONObject(index)
val values = arrayOf(
deletion.getString("deletion_id"), deletion.getString("program_id"),
deletion.getString("predecessor_revision_id"), deletion.getString("revision_id"),
deletion.getString("requested_at"),
)
val existing = db.rawQuery(
"SELECT deletion_id,predecessor_revision_id,revision_id,requested_at FROM synced_program_deletions WHERE program_id=?",
arrayOf(values[1]),
).use { cursor -> if (!cursor.moveToFirst()) null else List(4) { cursor.getString(it) } }
if (existing != null && existing != listOf(values[0], values[2], values[3], values[4]))
throw ProgramsImportInvalid("Program deletion identity conflict.")
if (existing == null) {
db.execSQL("INSERT INTO synced_program_deletions VALUES(?,?,?,?,?)", values)
deleted++
} else skipped++
db.delete("synced_programs", "program_id=?", arrayOf(values[1]))
}
for (programIndex in 0 until programs.length()) {
val program = programs.getJSONObject(programIndex)
val id = program.getString("program_id")
val tombstoned = db.rawQuery("SELECT 1 FROM synced_program_deletions WHERE program_id=?", arrayOf(id))
.use { it.moveToFirst() }
if (tombstoned) { skipped++; continue }
val digest = causalDigest(program)
val existing = db.rawQuery("SELECT revision_id,snapshot_sha256 FROM synced_programs WHERE program_id=?", arrayOf(id))
.use { cursor -> if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) }
if (existing?.first == program.getString("revision_id")) {
if (existing.second != digest) throw ProgramsImportInvalid("Program revision identity conflict.")
skipped++
continue
}
db.delete("synced_programs", "program_id=?", arrayOf(id))
db.execSQL(
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
arrayOf<Any?>(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"),
program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"),
program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"),
program.getInt("source_version"), program.getString("source_payload_sha256"), digest),
)
val sessions = program.getJSONArray("sessions")
for (sessionIndex in 0 until sessions.length()) {
val session = sessions.getJSONObject(sessionIndex)
val sessionId = session.getString("program_session_id")
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?)", arrayOf<Any?>(
sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"),
session.optStringOrNull("planned_for"), session.optStringOrNull("note")))
val entries = session.getJSONArray("occurrences")
for (entryIndex in 0 until entries.length()) {
val entry = entries.getJSONObject(entryIndex)
db.execSQL("INSERT INTO synced_program_entries VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", arrayOf<Any?>(
sessionId, entry.getString("entry_id"), entryIndex, entry.getString("exercise_id"),
entry.optStringOrNull("equipment_id"), entry.getString("load_mode"), entry.getInt("rest_seconds"),
entry.optIntOrNull("target_sets"), entry.optIntOrNull("target_reps"),
entry.optIntOrNull("target_duration_seconds"), entry.optDoubleOrNull("target_weight_kg"),
entry.optStringOrNull("notes"), id))
}
}
imported++
}
db.setTransactionSuccessful()
return ProgramsImportResult.Applied(imported, deleted, skipped)
} finally { db.endTransaction() }
} catch (error: ProgramsImportInvalid) {
return ProgramsImportResult.Invalid(error.message ?: "Invalid Programs document.")
} catch (_: Exception) {
return ProgramsImportResult.DatabaseError
}
}
fun listSyncedPrograms(): List<SyncedProgramSummary> = database.readableDatabase.rawQuery(
"SELECT p.program_id,p.title,p.state,p.start_date,p.end_date,COUNT(s.program_session_id) " +
"FROM synced_programs p LEFT JOIN synced_program_sessions s ON s.program_id=p.program_id " +
"GROUP BY p.program_id ORDER BY CASE p.state WHEN 'active' THEN 0 ELSE 1 END,p.title COLLATE NOCASE,p.program_id",
null,
).use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(
SyncedProgramSummary(
cursor.getString(0),
cursor.getString(1),
cursor.getString(2),
if (cursor.isNull(3)) null else cursor.getString(3),
if (cursor.isNull(4)) null else cursor.getString(4),
cursor.getInt(5),
),
)
}
}
}
fun getSyncedProgram(programId: String): SyncedProgramDetail? {
val db = database.readableDatabase
data class Header(
val title: String,
val note: String?,
val state: String,
val startDate: String?,
val endDate: String?,
)
val header = db.rawQuery(
"SELECT title,note,state,start_date,end_date FROM synced_programs WHERE program_id=?",
arrayOf(programId),
).use { cursor ->
if (!cursor.moveToFirst()) null else Header(
cursor.getString(0),
if (cursor.isNull(1)) null else cursor.getString(1),
cursor.getString(2),
if (cursor.isNull(3)) null else cursor.getString(3),
if (cursor.isNull(4)) null else cursor.getString(4),
)
} ?: return null
val sessions = mutableListOf<SyncedProgramSession>()
db.rawQuery(
"SELECT program_session_id,title,session_type,planned_for,note " +
"FROM synced_program_sessions WHERE program_id=? " +
"ORDER BY position,program_session_id",
arrayOf(programId),
).use { sessionCursor ->
while (sessionCursor.moveToNext()) {
val sessionId = sessionCursor.getString(0)
val entries = mutableListOf<SyncedProgramOccurrence>()
db.rawQuery(
"SELECT pe.entry_id,pe.exercise_id," +
"COALESCE(e.name,canonical.name,pe.exercise_id),pe.equipment_id," +
"pe.load_mode,pe.rest_seconds,pe.target_sets,pe.target_reps," +
"pe.target_duration_seconds,pe.target_weight_kg,pe.notes " +
"FROM synced_program_entries pe " +
"LEFT JOIN exercises e ON e.exercise_id=pe.exercise_id " +
"LEFT JOIN exercise_aliases alias ON alias.source_exercise_id=pe.exercise_id " +
"LEFT JOIN exercises canonical " +
"ON canonical.exercise_id=alias.canonical_exercise_id " +
"WHERE pe.program_session_id=? ORDER BY pe.position,pe.entry_id",
arrayOf(sessionId),
).use { entry ->
while (entry.moveToNext()) {
entries += SyncedProgramOccurrence(
entry.getString(0),
entry.getString(1),
entry.getString(2),
if (entry.isNull(3)) null else entry.getString(3),
entry.getString(4),
entry.getInt(5),
if (entry.isNull(6)) null else entry.getInt(6),
if (entry.isNull(7)) null else entry.getInt(7),
if (entry.isNull(8)) null else entry.getInt(8),
if (entry.isNull(9)) null else entry.getDouble(9),
if (entry.isNull(10)) null else entry.getString(10),
)
}
}
sessions += SyncedProgramSession(
sessionId,
sessionCursor.getString(1),
sessionCursor.getString(2),
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
entries,
)
}
}
return SyncedProgramDetail(
programId,
header.title,
header.note,
header.state,
header.startDate,
header.endDate,
sessions,
)
}
private fun normalizeName(
value: String,
): String {
@ -8143,6 +8447,9 @@ private fun JSONObject.optDoubleOrNull(key: String): Double? =
private fun JSONObject.optIntOrNull(key: String): Int? =
if (has(key) && !isNull(key)) getInt(key) else null
private fun JSONObject.optStringOrNull(key: String): String? =
if (has(key) && !isNull(key)) getString(key) else null
private fun jsonHasUniqueObjectKeys(json: String): Boolean = try {
JsonReader(StringReader(json)).use { reader ->
fun consumeValue() {
@ -8231,7 +8538,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
23,
24,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -8279,6 +8586,7 @@ private class TrainlogDatabaseHelper(
createSyncGenerationArchiveTable(db)
createSessionPreparationTables(db)
createSessionPreparationWithdrawalTable(db)
createSyncedProgramTables(db)
seedEquipment(db)
}
@ -8468,6 +8776,14 @@ private class TrainlogDatabaseHelper(
createSessionPreparationWithdrawalTable(db)
version = 23
}
if (version < 24 && newVersion >= 24) {
/* WHY: reusable Programs remain desktop-owned planning definitions.
* CONTRACT: v24 adds only a read-only projection and durable deletion
* tombstones. INVARIANT: no draft, preparation, history, catalog or
* body row is rewritten, inferred or removed by this migration. */
createSyncedProgramTables(db)
version = 24
}
if (version != newVersion) {
error(
@ -8477,6 +8793,46 @@ private class TrainlogDatabaseHelper(
}
}
private fun createSyncedProgramTables(db: SQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS synced_programs(
program_id TEXT PRIMARY KEY, revision_id TEXT NOT NULL, title TEXT NOT NULL,
note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')),
start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
source_format TEXT NOT NULL, source_version INTEGER NOT NULL,
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS synced_program_sessions(
program_session_id TEXT PRIMARY KEY,
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),
planned_for TEXT, note TEXT, UNIQUE(program_id,position)
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS synced_program_entries(
program_session_id TEXT NOT NULL REFERENCES synced_program_sessions(program_session_id) ON DELETE CASCADE,
entry_id TEXT NOT NULL, position INTEGER NOT NULL CHECK(position>=0), exercise_id TEXT NOT NULL,
equipment_id TEXT, load_mode TEXT NOT NULL CHECK(load_mode IN('none','external','assistance')),
rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400),
target_sets INTEGER, target_reps INTEGER, target_duration_seconds INTEGER,
target_weight_kg REAL, notes TEXT, program_id TEXT NOT NULL,
PRIMARY KEY(program_session_id,entry_id), UNIQUE(program_session_id,position)
)""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS synced_program_entries_exercise ON synced_program_entries(exercise_id)")
db.execSQL(
"""CREATE TABLE IF NOT EXISTS synced_program_deletions(
deletion_id TEXT PRIMARY KEY, program_id TEXT NOT NULL UNIQUE,
predecessor_revision_id TEXT NOT NULL, revision_id TEXT NOT NULL,
requested_at TEXT NOT NULL
)""".trimIndent(),
)
}
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
fun addColumn(table: String, name: String, declaration: String) {
if (!tableHasColumn(db, table, name)) {

View file

@ -0,0 +1,44 @@
package com.labfytools.trainlog.model
/** Read-only desktop-owned program projection exposed to Android presentation. */
data class SyncedProgramSummary(
val programId: String,
val title: String,
val state: String,
val startDate: String?,
val endDate: String?,
val sessionCount: Int,
)
data class SyncedProgramDetail(
val programId: String,
val title: String,
val note: String?,
val state: String,
val startDate: String?,
val endDate: String?,
val sessions: List<SyncedProgramSession>,
)
data class SyncedProgramSession(
val programSessionId: String,
val title: String,
val sessionType: String,
val plannedFor: String?,
val note: String?,
val occurrences: List<SyncedProgramOccurrence>,
)
data class SyncedProgramOccurrence(
val entryId: String,
val exerciseId: String,
val exerciseName: String,
val equipmentId: String?,
val loadMode: String,
val restSeconds: Int,
val targetSets: Int?,
val targetReps: Int?,
val targetDurationSeconds: Int?,
val targetWeightKg: Double?,
val notes: String?,
)

View file

@ -66,6 +66,7 @@ fun AndroidAppShell(
AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions
AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator
AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions
AppRoute.Programs -> R.string.route_programs; is AppRoute.ProgramDetail -> R.string.route_program_detail
is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create

View file

@ -23,6 +23,8 @@ sealed interface AppRoute {
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
data object Programs : AppRoute { override val section = AppSection.SESSIONS }
data class ProgramDetail(val programId: String) : AppRoute { override val section = AppSection.SESSIONS }
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
data object Exercises : AppRoute { override val section = AppSection.EXERCISES }

View file

@ -0,0 +1,79 @@
package com.labfytools.trainlog.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.TrainlogRepository
/** Presentation-only list for the desktop-owned Programs projection. */
@Composable
fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
val strings = localizedContext()
val programs = repository.listSyncedPrograms()
TrainlogScreen(strings.getString(R.string.route_programs)) {
if (programs.isEmpty()) TrainlogInfo(strings.getString(R.string.programs_none))
programs.forEach { program ->
TrainlogFrame(program.title, active = program.state == "active") {
TrainlogInfo(
listOfNotNull(
strings.getString(if (program.state == "active") R.string.program_active else R.string.program_archived),
program.startDate?.let { strings.getString(R.string.program_start_date, formatDate(it)) },
program.endDate?.let { strings.getString(R.string.program_end_date, formatDate(it)) },
strings.resources.getQuantityString(
R.plurals.program_session_count,
program.sessionCount,
program.sessionCount,
),
).joinToString(" · "),
)
TrainlogButton(
strings.getString(R.string.program_open),
{ onOpen(program.programId) },
Modifier.testTag("program-${program.programId}"),
)
}
}
}
}
/** CONTRACT: this route exposes no start, edit, archive, or delete mutation. */
@Composable
fun ProgramDetailScreen(repository: TrainlogRepository, programId: String) {
val strings = localizedContext()
val program = repository.getSyncedProgram(programId)
TrainlogScreen(strings.getString(R.string.route_program_detail)) {
if (program == null) {
TrainlogInfo(strings.getString(R.string.program_not_found))
return@TrainlogScreen
}
TrainlogFrame(program.title, active = program.state == "active") {
TrainlogInfo(strings.getString(if (program.state == "active") R.string.program_active else R.string.program_archived))
program.startDate?.let { TrainlogInfo(strings.getString(R.string.program_start_date, formatDate(it))) }
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
program.note?.let { TrainlogInfo(it) }
}
program.sessions.forEachIndexed { index, session ->
TrainlogFrame("${index + 1}. ${session.title}") {
TrainlogInfo(
listOfNotNull(
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
).joinToString(" · "),
)
session.note?.let { TrainlogInfo(it) }
session.occurrences.forEachIndexed { occurrenceIndex, occurrence ->
val target = occurrence.targetReps?.let { strings.getString(R.string.program_target_reps, it) }
?: occurrence.targetDurationSeconds?.let { strings.getString(R.string.program_target_duration, it) }.orEmpty()
val sets = occurrence.targetSets?.let { strings.getString(R.string.program_target_sets, it) }.orEmpty()
val weight = occurrence.targetWeightKg?.let { strings.getString(R.string.program_target_weight, it) }.orEmpty()
TrainlogInfo(
"${occurrenceIndex + 1}. ${occurrence.exerciseName}" +
listOf(sets, target, weight).filter { it.isNotEmpty() }.joinToString(" · ", prefix = " · "),
)
occurrence.notes?.let { TrainlogInfo(it) }
}
}
}
}
}

View file

@ -46,6 +46,7 @@ fun SessionsHub(
onManual: () -> Unit,
onDrafts: () -> Unit,
onHistory: () -> Unit,
onPrograms: () -> Unit = {},
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
@ -67,6 +68,8 @@ fun SessionsHub(
}
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
Modifier.fillMaxWidth().testTag("sessions-history-action"))
TrainlogButton(strings.getString(R.string.route_programs), onPrograms,
Modifier.fillMaxWidth().testTag("sessions-programs-action"))
}
}

View file

@ -106,7 +106,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
)
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) })
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
draftMessage = strings.getString(R.string.existing_draft_warning)
@ -124,6 +124,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
onStarted = { open(AppRoute.SessionEditor) },
)
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId)
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) }
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
if (saved) exportSnapshot()
@ -164,6 +166,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
private fun AppRoute.stateKey(): String = when (this) {
is AppRoute.SessionDetail -> "session:${sessionId}"
is AppRoute.SessionCorrection -> "session-correction:${sessionId}"
is AppRoute.ProgramDetail -> "program:${programId}"
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}"

View file

@ -12,6 +12,20 @@
<string name="route_session_generator">Plan a session</string>
<string name="route_ai_drafts">Drafts</string>
<string name="route_completed_sessions">Completed sessions</string>
<string name="route_programs">Programs</string>
<string name="route_program_detail">Program details</string>
<string name="programs_none">No synchronized programs.</string>
<string name="program_active">ACTIVE</string>
<string name="program_archived">ARCHIVED</string>
<string name="program_start_date">Start: %1$s</string>
<string name="program_end_date">End: %1$s</string>
<string name="program_open">View program</string>
<string name="program_not_found">Program not found.</string>
<string name="program_target_sets">%1$d sets</string>
<string name="program_target_reps">%1$d repetitions</string>
<string name="program_target_duration">%1$d s</string>
<string name="program_target_weight">%1$.1f kg</string>
<plurals name="program_session_count"><item quantity="one">%d session</item><item quantity="other">%d sessions</item></plurals>
<string name="route_session_detail">Session details</string>
<string name="route_session_correction">Edit session</string>
<string name="route_exercise_detail">Exercise details</string>

View file

@ -12,6 +12,20 @@
<string name="route_session_generator">Programmer une séance</string>
<string name="route_ai_drafts">Brouillons</string>
<string name="route_completed_sessions">Séances effectuées</string>
<string name="route_programs">Programmes</string>
<string name="route_program_detail">Détail du programme</string>
<string name="programs_none">Aucun programme synchronisé.</string>
<string name="program_active">ACTIF</string>
<string name="program_archived">ARCHIVÉ</string>
<string name="program_start_date">Début : %1$s</string>
<string name="program_end_date">Fin : %1$s</string>
<string name="program_open">Afficher le programme</string>
<string name="program_not_found">Programme introuvable.</string>
<string name="program_target_sets">%1$d séries</string>
<string name="program_target_reps">%1$d répétitions</string>
<string name="program_target_duration">%1$d s</string>
<string name="program_target_weight">%1$.1f kg</string>
<plurals name="program_session_count"><item quantity="one">%d séance</item><item quantity="other">%d séances</item></plurals>
<string name="route_session_detail">Détail de la séance</string>
<string name="route_session_correction">Modifier la séance</string>
<string name="route_exercise_detail">Fiche exercice</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(23, scalar(db, "PRAGMA user_version"))
assertEquals(24, 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(23, scalar(db, "PRAGMA user_version"))
assertEquals(24, 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(23, scalar(db, "PRAGMA user_version"))
assertEquals(24, 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(23, cursor.getInt(0))
assertTrue(cursor.moveToFirst()); assertEquals(24, 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(23, it.version) }
.use { assertEquals(24, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -0,0 +1,127 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
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 ProgramsProjectionRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun program(title: String = "Cycle lisible"): JSONObject =
JSONObject()
.put("program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("revision_id", "pgr_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
.put("title", title)
.put("note", "Lecture seule")
.put("state", "active")
.put("start_date", "2026-09-21")
.put("end_date", JSONObject.NULL)
.put("created_at", "2026-09-18T10:00:00Z")
.put("updated_at", "2026-09-18T11:00:00Z")
.put("source_format", "trainlog-program")
.put("source_version", 1)
.put("source_payload_sha256", "a".repeat(64))
.put(
"sessions",
JSONArray().put(
JSONObject()
.put("program_session_id", "pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
.put("title", "Séance A")
.put("session_type", "training")
.put("planned_for", JSONObject.NULL)
.put("note", JSONObject.NULL)
.put(
"occurrences",
JSONArray().put(
JSONObject()
.put("entry_id", "pge_dddddddd-dddd-4ddd-8ddd-dddddddddddd")
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("equipment_id", JSONObject.NULL)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 3)
.put("target_reps", 8)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 40.0)
.put("notes", JSONObject.NULL),
),
),
),
)
private fun artifact(programs: JSONArray, deletions: JSONArray = JSONArray()): String =
JSONObject()
.put("format", "trainlog-programs")
.put("version", 1)
.put("generated_at", "2026-09-18T12:00:00Z")
.put("programs", programs)
.put("deletions", deletions)
.toString()
private fun deletion(): JSONObject =
JSONObject()
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
.put("program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("predecessor_revision_id", "pgr_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
.put("revision_id", "pgr_11111111-1111-4111-8111-111111111111")
.put("requested_at", "2026-09-18T12:30:00Z")
@Test
fun projectionReplayConflictDeletionAndRestartAreDurable() {
val name = "programs-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV1Json(artifact(JSONArray().put(program()))))
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV1Json(artifact(JSONArray().put(program()))))
val summary = repository.listSyncedPrograms().single()
assertEquals(1, summary.sessionCount)
val detail = repository.getSyncedProgram(summary.programId)!!
assertEquals("ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", detail.sessions.single().occurrences.single().exerciseName)
val conflict = repository.applyProgramsV1Json(artifact(JSONArray().put(program("Divergent"))))
assertTrue(conflict is ProgramsImportResult.Invalid)
assertEquals("Cycle lisible", repository.listSyncedPrograms().single().title)
assertEquals(ProgramsImportResult.Applied(0, 1, 0), repository.applyProgramsV1Json(artifact(JSONArray(), JSONArray().put(deletion()))))
assertTrue(repository.listSyncedPrograms().isEmpty())
} finally {
repository.close()
}
repository = TrainlogRepository(context, name)
try {
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV1Json(artifact(JSONArray().put(program()))))
assertTrue(repository.listSyncedPrograms().isEmpty())
assertTrue(repository.listEquipment().isNotEmpty())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun malformedNestedDocumentDoesNotPartiallyDelete() {
val name = "programs-invalid-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
val malformed = program().apply { getJSONArray("sessions").getJSONObject(0).remove("title") }
val result = repository.applyProgramsV1Json(artifact(JSONArray().put(malformed), JSONArray().put(deletion())))
assertTrue(result is ProgramsImportResult.Invalid)
assertEquals(1, repository.listSyncedPrograms().size)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -260,7 +260,7 @@ class SessionPreparationExchangeTest {
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size)
repository.close()
assertEquals("23", scalarText(name, "PRAGMA user_version"))
assertEquals("24", scalarText(name, "PRAGMA user_version"))
assertEquals(
"0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -111,7 +111,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(23, it.getInt(0))
assertEquals(24, 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(23, cursor.getInt(0))
assertEquals(24, 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(23, cursor.getInt(0))
assertEquals(24, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())

View file

@ -17,7 +17,7 @@ evidence and chronology.
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
history and Android-delivery workflow.
- [Program format V1](program_format_v1.md) owns the strict desktop planning
import contract used by the Sessions Programs subtab.
import contract and program lifecycle used by the Sessions Programs subtab.
- [Android](android.md) owns field-companion capture, local persistence,
glanceable summaries, permissions, and Android interaction rules.
@ -77,6 +77,9 @@ evidence and chronology.
- [Review records](reviews/) retain historical audits, incidents, checkpoints,
and validation evidence. Their status and paths are historical.
- [Programs presentation / Android delete evidence](reviews/programs_presentation_android_delete_v1_evidence.md)
records the implemented, not-deployed validation checkpoint and real-browser
screenshot evidence.
- [Changelog](../CHANGELOG.md) owns chronological change history.
- [Release notes](releases/) retain reusable, tag-scoped English source text
for public GitHub and Forgejo releases.

View file

@ -16,6 +16,13 @@ and their active or completed execution data is untouched. The stored result is
Withdrawal replay is idempotent, and an older preparation delivery is skipped
once the withdrawal identity for that preparation exists.
Android schema v24 adds a read-only synchronized Programs projection:
`synced_programs`, `synced_program_sessions`, `synced_program_entries`, and
`synced_program_deletions`. It presents received program and session detail in
Sessions without offering import, archive, delete, preparation, or other
program mutation actions. The projection is separate from capture data, AI
proposals, manual preparations, and the active-session singleton.
## 1. Purpose
The Android application is Trainlog's low-friction capture client.
@ -68,7 +75,15 @@ including intermediate forms such as `0.`, `0,`, `.3`, and `,3`. Commit replaces
existing positive/non-negative domain rule. Distance remains explicitly km; no
unit guessing or automatic correction occurs.
## Session exchange V3
## Session exchange V3 and optional Programs companion V1
`trainlog-programs` V1 is an optional staged controlled-generation companion,
not a replacement for mobile export V3. The PC publishes `programs-v1.json` as
a full snapshot of nondeleted programs plus unacknowledged program-deletion
tombstones. Both peers advertise the `programs-v1` capability; Android applies
the projection and deletion state durably, and desktop records the correlated
ACK before marking the tombstone acknowledged. It changes neither
`TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparation exchange.
Completed session occurrences persist an `entry_id`; it is never regenerated
for exchange. Android schema v14 added feedback roots and completion `ended_at`

View file

@ -493,9 +493,15 @@ silently rendered as unclassified.
### Desktop
Desktop SQLite schema v24 is canonical long-term history. Its additive v23 ->
v24 migration adds durable manual-preparation withdrawal identity without
deleting revisions, deliveries or workout facts. Its additive v18 ->
Desktop SQLite schema v26 is canonical long-term history. Its additive v25 ->
v26 migration adds terminal Program logical deletion and its durable replay,
generation, and acknowledgement ledger without deleting imported definitions,
derived preparations, or workout facts. Android schema v24 maintains the
separate read-only synchronized Programs projection; the optional staged
`trainlog-programs` V1 companion does not replace mobile V3 or change
`TRAINLOG_FORMAT_V1`. Its additive desktop v23 -> v24 migration adds durable
manual-preparation withdrawal identity without deleting revisions, deliveries
or workout facts. Its additive v18 ->
v19 migration stores causal notes and execution-draft lifecycle state without
activating a new transport. Its v15 -> v16
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the

View file

@ -57,8 +57,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v25 |
| Android SQLite | schema v23 |
| Desktop SQLite | schema v26 |
| Android SQLite | schema v24 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
@ -81,6 +81,7 @@ parallel implementation of its rules.
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=IMPLEMENTED_VALIDATED` (not deployed; real Firefox Programs screenshots recorded) |
| Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` |
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
@ -231,9 +232,9 @@ explicit proposal derivation and generation-backed Android delivery. Programmes
is implemented as the fourth Sessions subtab; Analyse and Exercises remain
explicit placeholders, and no standalone Programs route exists.
Desktop schema v25 owns immutable manual-preparation revisions, stable ordered
Desktop schema v26 owns immutable manual-preparation revisions, stable ordered
occurrences, persistent HTTP idempotency keys, delivery-to-execution identity,
and durable revision-bound withdrawal records. Android schema v23 stores
and durable revision-bound withdrawal records. Android schema v24 stores
received preparations and permanent withdrawal results separately from AI
proposals and the active singleton. Pending deliveries are cancelled by an
exact V2 withdrawal; started executions are preserved. An exact correlated ACK
@ -254,12 +255,22 @@ preparations and is propagated by `trainlog-ai-session-drafts` V2; inactive
execution drafts and completed sessions use revision-guarded causal deletion.
Active or concurrently changed drafts conflict instead of being erased.
Program V1 is desktop-owned planning data in schema v25. The Sessions Programs
subtab lists, searches, filters, deep-links and archives programs. Local JSON
import is strict and bounded, previews through Core before committing, and has
deterministic replay/conflict behavior. One explicit program-session action
creates a provenance-bearing manual preparation without Android delivery or
performed work.
Program V1 is desktop-owned planning data in schema v26. The responsive
Sessions Programs subtab provides cards and timeline detail backed by real
imported definitions, sessions, usage, and provenance; it keeps strict bounded
preview/commit import and deterministic replay/conflict behavior. A discrete,
accessible trash action uses confirmation, focus restoration, and Escape-safe
cancel semantics. Active and archived programs may be terminally logically
deleted; sources and derived preparations remain, while deleted programs cannot
resurrect or reprepare and archived programs cannot prepare.
Android schema v24 contains a read-only synchronized Programs projection with
program, session, entry, and deletion tables. Its Sessions list/detail has no
program mutation actions. The optional staged controlled-generation
`trainlog-programs` V1 companion publishes a PC-to-Android full nondeleted
snapshot plus unacknowledged tombstones under capability `programs-v1`; a
durable correlated ACK records acknowledgment. It changes neither
`TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparations.
The controlled deployment preserved the installed Android signing identity and
advanced its private `versionCode` from 13 to 14 without uninstalling or

View file

@ -1,5 +1,19 @@
# Desktop database
## Schema v26: Programs presentation, Android projection, and logical deletion
Schema v26 adds nullable `programs.deleted_at` and the terminal
`program_deletions` logical-deletion ledger. A ledger row binds the stable
program identity to the request revision, deleted revision, response replay,
generation identity, and nullable `acknowledged_at`. Deletion is available for
active and archived programs. It retains the imported source definition and
all derived preparations, but a deleted program cannot be resurrected or used
to create another preparation. An archived program cannot be prepared.
The related Programs presentation reads imported definitions, their sessions,
usage, and provenance as real desktop data; it does not make planning targets
performed work. The v26 migration is additive and transactional.
## Schema v25: Web deletion requests and Program V1
Schema v25 adds durable Program V1 definitions, their ordered session and
@ -51,8 +65,8 @@ is a later, separately versioned migration.
## 1. Status
```text
TRAINLOG_DATABASE_SCHEMA_VERSION=25
DATABASE_SCHEMA_V25=IMPLEMENTED
TRAINLOG_DATABASE_SCHEMA_VERSION=26
DATABASE_SCHEMA_V26=IMPLEMENTED
TRAINLOG_FORMAT_V1=FROZEN
```
@ -71,7 +85,7 @@ PRAGMA user_version;
Current value:
```text
25
26
```
The independent actual-set loads documented in the current desktop, Android

View file

@ -13,7 +13,7 @@ identities remain opaque. Display titles are not identities. A program is not a
reusable session template, AI proposal, manual preparation, execution draft or
completed session.
Desktop schema v25 owns the imported program and its exact source SHA-256.
Desktop schema v26 owns the imported program and its exact source SHA-256.
Exact same-ID content replay is idempotent. The same ID with a different byte
payload is an explicit conflict. A different ID may reuse a title because titles
have no identity semantics.
@ -60,3 +60,11 @@ session is a separate idempotent command that copies the ordered plan and stores
program/session provenance on a new `sp_<uuid-v4>` manual preparation. It does
not create a delivery, Android object, execution draft, completed session or
performed measurement.
Deletion is a separate terminal logical lifecycle transition. It is allowed
from active or archived state, records request/deleted revisions and durable
response replay in the desktop `program_deletions` ledger, and retains the
source definition and derived preparations. A deleted program cannot be
resurrected or prepared; an archived program cannot be prepared. The optional
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
projection and does not alter this import format.

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View file

@ -0,0 +1,52 @@
# Programs presentation, Android projection, and delete V1 evidence
## Scope and lifecycle
This record captures the implementation and validation evidence presently
available for `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1`.
The feature is implemented and validated in the recorded automated and real
Firefox Programs presentation checks. It is **not deployed**. This record does
not assign `PASS` or `FROZEN` status.
## Implemented contract
- Desktop schema v26 adds `programs.deleted_at` and the terminal
`program_deletions` logical-deletion ledger. The ledger retains request and
deleted revisions, response replay, `generation_id`, and `acknowledged_at`.
Active and archived programs may be deleted. Source definitions and derived
preparations remain; deleted programs cannot resurrect or reprepare, and
archived programs cannot prepare.
- The Web Sessions Programs subtab renders responsive cards and timeline detail
from real imported, session, usage, and provenance data. Its discrete trash
action has destructive confirmation, focus management/restoration, and
Escape/Cancel paths that make no mutation.
- Android schema v24 adds the read-only `synced_programs`,
`synced_program_sessions`, `synced_program_entries`, and
`synced_program_deletions` projection. Android provides Sessions list/detail
consultation only; it exposes no Program mutation actions.
- `trainlog-programs` V1 is an optional staged controlled-generation companion
named `programs-v1.json`. With the `programs-v1` capability, desktop sends a
full snapshot of nondeleted programs and unacknowledged tombstones; Android
durably acknowledges correlated consumption. The companion does not change
`TRAINLOG_FORMAT_V1`, mobile export V3, catalog exchange, or preparations.
## Validation recorded
- Full Meson suite: **88/88**.
- Focused ASan/UBSan suite: **3/3**.
- Web Vitest: **94** tests.
- Web production build completed.
- Android unit suite: **229** tests, **5 skipped**; `assembleDebug` completed.
- Python Programs and synchronization suites completed.
- Format and diff validators completed, including `git diff --check`.
- Real Firefox Programs screenshots passed and are retained as
[desktop cards, 1440x1000](evidence/web-programs-v1-card-desktop.png) and
[mobile deletion dialog, exact 390x844](evidence/web-programs-v1-delete-dialog-mobile-390x844.png).
These results are implementation and presentation-validation evidence. They do
not substitute for separate deployment validation.
## Remaining validation
- Validate deployment separately before representing the feature as deployed.

View file

@ -24,7 +24,7 @@ distinction between measured values and estimates.
## Current baseline
Desktop schema v25, Android schema v23, Notcurses, direct
Desktop schema v26, Android schema v24, Notcurses, direct
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
implemented. Session Generator V1 is hidden pending V2. AI session-draft
@ -59,7 +59,9 @@ explicit trusted opt-in. The authorized private direct-MTP rollout passed on
real hardware; automatic V3 selection remains unchanged without that opt-in.
The next separately contracted Web surface is Exercises. Sessions deletion and
its Programs subtab are complete in software and are not reopened by that
future contract.
future contract. `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1` is
implemented and validated in automated and real Firefox Programs presentation
checks, but is not deployed. It must not be reopened by the Exercises contract.
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
defines the complete-sync target and its bounded dependency order.

View file

@ -40,7 +40,7 @@ available because availability is independently guarded by causal state.
## Machine-exercise Phase 1 compatibility
Desktop schema v25 and Android schema v23 retain mobile export V3, readable V1/V2 imports,
Desktop schema v26 and Android schema v24 retain mobile export V3, readable V1/V2 imports,
equipment definitions V1, equipment associations V2, exercise aliases V1, and
the BODY ZONES companion without wire-format changes. Machine metadata is not
silently added to a frozen artifact: stable exercise IDs and canonical names
@ -144,11 +144,27 @@ does not publish to it or let it override an artifact in the new endpoint.
| PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
No SQLite file is transferred.
## Optional Programs companion V1
`trainlog-programs` V1 is a staged controlled-generation companion. It is
selected only when the peer capability set includes `programs-v1`. Its snapshot
contains the current nondeleted desktop programs, their planning definitions,
sessions and entries, and the unacknowledged terminal program-deletion
tombstones. Android schema v24 stores a read-only synchronized projection and
returns a durable correlated ACK. Desktop schema v26 records the exact
generation and only then sets `program_deletions.acknowledged_at`.
This companion is independent of `trainlog-program` V1 import, mobile export
V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
semantics.
`trainlog-exercise-aliases` v1 is the separate EXERCISE_MERGE_V1 identity
companion. Its root contains exactly `format`, `version`, and `aliases`; every
entry contains exactly `source_exercise_id` and `canonical_exercise_id` using

View file

@ -51,13 +51,23 @@ withdrawal without the proposal and that Android's tombstone rejects later V1
replay.
`web_programs` exercises validation preview without writes, transactional
import, exact replay, detail serialization, archive replay, preparation
creation, provenance and absence of delivery. Schema migration coverage checks
all v25 tables and columns. Programs Vitest covers preview before the only
importing mutation and explicit preparation creation/navigation. The full HTTP,
import, exact replay, responsive detail serialization, archive replay,
revision-guarded terminal deletion, response replay, preparation creation,
usage/provenance and absence of delivery. Schema migration coverage checks all
v26 tables and columns. Programs Vitest covers preview before the only importing
mutation, explicit preparation creation/navigation, discrete trash confirmation,
focus restoration, and Escape/Cancel zero-mutation behavior. The full HTTP,
pagination, strict-parser and Core suites retain malformed, duplicate-key,
bounds, catalog/profile/equipment, conflict and rollback coverage.
Android Programs projection tests cover schema-v23 to v24 migration, full
nondeleted `trainlog-programs` V1 snapshot import, durable deletion tombstones,
read-only Sessions list/detail data, exact replay, capability gating, and
correlated ACK behavior. Python Programs/synchronization suites cover the
staged companion's full snapshot, unacknowledged tombstone publication, and
ACK-driven acknowledgment without changing mobile V3, catalog, or preparation
artifacts.
`test_web_sessions_browser.py` runs headless Firefox against the embedded
production assets and C HTTP server with private temporary XDG roots. It creates
only a synthetic ready preparation and delivery, verifies French then persisted

View file

@ -69,13 +69,23 @@ Inactive execution drafts and completed sessions use the existing causal
operation/state boundary. Active or stale drafts conflict, and completed-session
deletion records a finalization so older snapshots cannot recreate the session.
Programs are planning data owned by desktop schema v25. Import first runs the
Programs are planning data owned by desktop schema v26. The Programs subtab
uses responsive cards and a timeline detail view backed by real imported
definitions, sessions, usage, and provenance data. Import first runs the
same strict Core validator in preview mode, then commits only after explicit
confirmation. Exact content replay is idempotent; same-ID divergent content is
a conflict. Archiving preserves definitions and derived preparations. Creating
a preparation from one program session copies targets and provenance into a
new manual preparation but creates no delivery, execution or performed data.
See [Program format V1](program_format_v1.md).
a preparation from one active program session copies targets and provenance
into a new manual preparation but creates no delivery, execution or performed
data. Archived or deleted programs cannot be prepared.
The discrete right-edge Program trash action is accessible and opens a
confirmation dialog that names the program and its consequence. Focus moves
into that dialog, Escape and Cancel perform no mutation, and focus returns to
the initiating action. Deletion is terminal logical deletion: it is allowed for
active and archived programs, retains source and derived-preparation evidence,
and prevents resurrection and re-preparation. See
[Program format V1](program_format_v1.md).
This V1 does not provide live Web capture, historical correction, automatic
training generation, Analysis or the standalone Exercises route.

View file

@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Programs V1 desktop projection export regressions."""
import sqlite3
import sys
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import export_programs
SCHEMA = """
CREATE TABLE programs(program_id TEXT PRIMARY KEY,title TEXT,note TEXT,state TEXT,
start_date TEXT,end_date TEXT,created_at TEXT,updated_at TEXT,revision_id TEXT,
source_format TEXT,source_version INTEGER,source_payload_sha256 TEXT,deleted_at TEXT);
CREATE TABLE program_sessions(program_session_id TEXT PRIMARY KEY,program_id TEXT,
position INTEGER,title TEXT,session_type TEXT,planned_for TEXT,note TEXT);
CREATE TABLE program_session_entries(program_session_id TEXT,entry_id TEXT,position INTEGER,
exercise_id TEXT,equipment_id TEXT,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER,
load_mode TEXT,rest_seconds INTEGER,target_sets INTEGER,target_reps INTEGER,
target_duration_seconds INTEGER,target_weight_kg REAL,notes TEXT);
CREATE TABLE program_deletions(program_id TEXT PRIMARY KEY,request_id TEXT,
expected_revision TEXT,deleted_revision TEXT,deleted_at TEXT,response_json TEXT,
generation_id TEXT,acknowledged_at TEXT);
PRAGMA user_version=26;
"""
class ProgramsExchangeTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.database = Path(self.temporary.name) / "trainlog.sqlite"
with closing(sqlite3.connect(self.database)) as db:
db.executescript(SCHEMA)
db.execute("INSERT INTO programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,NULL)", (
"pg_live", "Cycle", None, "active", "2026-09-21", None,
"2026-09-18T10:00:00Z", "2026-09-18T11:00:00Z", "pgr_live",
"trainlog-program", 1, "a" * 64,
))
db.execute("INSERT INTO program_sessions VALUES(?,?,?,?,?,?,?)",
("pgs_live", "pg_live", 0, "A", "training", None, None))
db.execute("INSERT INTO program_session_entries VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("pgs_live", "pge_live", 0, "ex_unknown", None, "sets", "reps", 0,
"none", 60, 3, 8, None, None, None))
db.execute("INSERT INTO programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", (
"pg_deleted", "Deleted", None, "active", None, None,
"2026-09-18T10:00:00Z", "2026-09-18T11:00:00Z", "pgr_old",
"trainlog-program", 1, "b" * 64, "2026-09-18T12:00:00Z",
))
db.execute("INSERT INTO program_deletions VALUES(?,?,?,?,?,?,NULL,NULL)",
("pg_deleted", "pgd_delete", "pgr_old", "pgr_deleted",
"2026-09-18T12:00:00Z", "{}"))
db.execute("INSERT INTO program_deletions VALUES(?,?,?,?,?,?,?,?)",
("pg_ack", "pgd_ack", "pgr_ack_old", "pgr_ack_deleted",
"2026-09-18T12:00:00Z", "{}", "gen_old", "2026-09-18T13:00:00Z"))
db.commit()
def tearDown(self):
self.temporary.cleanup()
def test_full_live_snapshot_and_only_unacknowledged_deletions(self):
payload = export_programs.export_programs(self.database)
self.assertEqual("trainlog-programs", payload["format"])
self.assertEqual(["pg_live"], [program["program_id"] for program in payload["programs"]])
occurrence = payload["programs"][0]["sessions"][0]["occurrences"][0]
self.assertEqual((3, 8, None), (
occurrence["target_sets"], occurrence["target_reps"],
occurrence["target_duration_seconds"],
))
self.assertEqual([{
"deletion_id": "pgd_delete",
"program_id": "pg_deleted",
"predecessor_revision_id": "pgr_old",
"revision_id": "pgr_deleted",
"requested_at": "2026-09-18T12:00:00Z",
}], payload["deletions"])
if __name__ == "__main__":
unittest.main()

View file

@ -1,11 +1,15 @@
import json
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import sync_generation_exchange as generation
class DeploymentToolsTest(unittest.TestCase):
@ -62,7 +66,9 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.2")
self.assertEqual(inventory["desktop_schema"], 26)
self.assertIn("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", inventory["protocols"])
self.assertTrue((output / "bin/trainlog").is_file())
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
self.assertTrue((output / "bin/trainlog-syncd").is_file())
@ -91,6 +97,16 @@ class DeploymentToolsTest(unittest.TestCase):
text=True,
)
self.assertEqual(imported.returncode, 0, imported.stderr)
packaged_tools = {
row["path"].removeprefix("tools/")
for row in inventory["files"]
if row["path"].startswith("tools/")
}
exporters = {
row[5]
for row in generation.ARTIFACTS
}
self.assertTrue(exporters <= packaged_tools)
self.assertTrue(
any(
row["path"] == "tools/trainlog_syncd.py"

View file

@ -25,6 +25,252 @@ EVIDENCE = ROOT / "docs" / "reviews" / "evidence"
class BrowserSessionsTest(unittest.TestCase):
def test_programs_v1_import_detail_and_discrete_delete_in_real_firefox(self):
if not GECKO.is_file():
self.skipTest("pinned geckodriver unavailable")
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.firefox.service import Service
from selenium.webdriver.support.ui import WebDriverWait
import websocket
with tempfile.TemporaryDirectory(prefix="trainlog-web-programs-") as directory:
root = Path(directory)
xdg = root / "data"
config = root / "config"
xdg.mkdir()
config.mkdir()
with socket.socket() as reserved:
reserved.bind(("127.0.0.1", 0))
port = reserved.getsockname()[1]
environment = os.environ.copy()
environment.update(
{
"XDG_DATA_HOME": str(xdg),
"XDG_CONFIG_HOME": str(config),
"XDG_CACHE_HOME": str(root / "cache"),
}
)
server = subprocess.Popen(
[str(BUILD / "trainlog"), "--web", "--port", str(port)],
env=environment,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
driver = None
bidi = None
try:
for _ in range(200):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", port)) == 0:
break
time.sleep(0.025)
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
options.set_capability("webSocketUrl", True)
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
wait = WebDriverWait(driver, 15)
bidi = websocket.create_connection(
driver.capabilities["webSocketUrl"], suppress_origin=True
)
command_id = 0
def bidi_command(method: str, parameters: dict):
nonlocal command_id
command_id += 1
bidi.send(json.dumps({
"id": command_id,
"method": method,
"params": parameters,
}))
while True:
response = json.loads(bidi.recv())
if response.get("id") != command_id:
continue
if response.get("type") != "success":
raise AssertionError(response)
return response["result"]
context = bidi_command("browsingContext.getTree", {})["contexts"][0]["context"]
def set_viewport(width: int, height: int):
bidi_command(
"browsingContext.setViewport",
{
"context": context,
"viewport": {"width": width, "height": height},
"devicePixelRatio": 1,
},
)
wait.until(
lambda current: current.execute_script(
"return innerWidth===arguments[0]&&innerHeight===arguments[1]",
width,
height,
)
)
set_viewport(1440, 1000)
driver.get(f"http://127.0.0.1:{port}/seances")
wait.until(lambda current: "Aucun résultat" in current.page_source)
catalog = driver.execute_async_script(
"const done=arguments[0];"
"fetch('/api/v1/sessions/catalog?offset=0&limit=1')"
".then((response)=>response.json()).then(done).catch(String);"
)
self.assertIsInstance(catalog, dict)
self.assertTrue(catalog["items"])
exercise = catalog["items"][0]
occurrence = {
"entry_id": "pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
"exercise_id": exercise["exercise_id"],
"equipment_id": None,
"load_mode": "none",
"rest_seconds": 90,
"target_sets": 3 if exercise["recording_mode"] == "sets" else None,
"target_reps": 8 if exercise["tracking_mode"] == "reps" else None,
"target_duration_seconds": (
None if exercise["tracking_mode"] == "reps" else 600
),
"target_weight_kg": None,
"notes": None,
}
program = {
"format": "trainlog-program",
"version": 1,
"program": {
"program_id": "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"title": "Cycle navigateur V1",
"note": "Programme synthétique lisible.",
"state": "active",
"start_date": "2026-09-21",
"end_date": None,
"sessions": [
{
"program_session_id": "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"title": "Séance A ordonnée",
"session_type": "training",
"planned_for": "2026-09-21",
"note": None,
"occurrences": [occurrence],
},
{
"program_session_id": "pgs_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
"title": "Séance B ordonnée",
"session_type": "max_test",
"planned_for": "2026-09-23",
"note": "Contrôle.",
"occurrences": [{**occurrence,
"entry_id": "pge_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"}],
},
],
},
}
import_path = root / "programme-navigateur-v1.json"
import_path.write_text(json.dumps(program), encoding="utf-8")
driver.find_element(By.XPATH, "//button[normalize-space()='Programmes']").click()
wait.until(lambda current: "Importer un programme" in current.page_source)
driver.find_element(By.XPATH, "//button[normalize-space()='Importer un programme']").click()
driver.find_element(By.CSS_SELECTOR, "input[type='file']").send_keys(str(import_path))
wait.until(lambda current: "Aperçu avant import" in current.page_source)
self.assertIn("2 séances", driver.page_source)
driver.find_element(By.XPATH, "//button[normalize-space()='Importer']").click()
wait.until(lambda current: "Cycle navigateur V1" in current.page_source)
wait.until(lambda current: "Séance A ordonnée" in current.page_source)
detail = driver.find_element(By.CSS_SELECTOR, "main").text
self.assertIn("PROGRAMME · ACTIF", detail)
self.assertIn("Import trainlog-program v1", detail)
self.assertLess(detail.index("1. Séance A ordonnée"), detail.index("2. Séance B ordonnée"))
driver.find_element(
By.XPATH, "//button[normalize-space()='← Retour aux programmes']"
).click()
wait.until(lambda current: "Programme actif" in current.page_source)
card = driver.find_element(By.CSS_SELECTOR, ".program-row").text
self.assertIn("Cycle navigateur V1", card)
self.assertIn("2 séances · jamais utilisé", card)
self.assertIn("trainlog-program · importé", card)
EVIDENCE.mkdir(parents=True, exist_ok=True)
self.assertTrue(driver.save_screenshot(
str(EVIDENCE / "web-programs-v1-card-desktop.png")
))
trash = driver.find_element(
By.CSS_SELECTOR, "button[aria-label='Supprimer le programme Cycle navigateur V1']"
)
trash.click()
confirmation = wait.until(
lambda current: current.find_element(By.CSS_SELECTOR, "[role='alertdialog']")
)
self.assertIn("Supprimer le programme « Cycle navigateur V1 » ?", confirmation.text)
self.assertIn("séances sources", confirmation.text)
self.assertNotIn("Séance A ordonnée", confirmation.text)
set_viewport(390, 844)
self.assertTrue(driver.save_screenshot(
str(EVIDENCE / "web-programs-v1-delete-dialog-mobile-390x844.png")
))
confirmation.find_element(By.XPATH, ".//button[normalize-space()='Conserver']").click()
wait.until(lambda current: not current.find_elements(By.CSS_SELECTOR, "[role='alertdialog']"))
self.assertEqual(driver.switch_to.active_element.get_attribute("aria-label"), trash.get_attribute("aria-label"))
trash = driver.find_element(
By.CSS_SELECTOR, "button[aria-label='Supprimer le programme Cycle navigateur V1']"
)
trash.click()
wait.until(lambda current: current.find_element(By.CSS_SELECTOR, "[role='alertdialog']"))
driver.switch_to.active_element.send_keys(Keys.ESCAPE)
wait.until(lambda current: not current.find_elements(By.CSS_SELECTOR, "[role='alertdialog']"))
database_path = xdg / "trainlog" / "trainlog.db"
with sqlite3.connect(database_path) as database:
self.assertIsNone(database.execute(
"SELECT deleted_at FROM programs WHERE program_id=?",
(program["program"]["program_id"],),
).fetchone()[0])
driver.find_element(
By.CSS_SELECTOR, "button[aria-label='Supprimer le programme Cycle navigateur V1']"
).click()
confirmation = wait.until(
lambda current: current.find_element(By.CSS_SELECTOR, "[role='alertdialog']")
)
confirmation.find_element(
By.XPATH, ".//button[normalize-space()='Supprimer le programme']"
).click()
wait.until(
lambda current: "Aucun programme." in current.page_source
or current.find_elements(By.CSS_SELECTOR, "[role='alert']")
)
self.assertNotIn("Cycle navigateur V1", driver.find_element(By.CSS_SELECTOR, "main").text)
driver.refresh()
wait.until(lambda current: "Cycle navigateur V1" not in current.page_source)
with sqlite3.connect(database_path) as database:
deleted_at = database.execute(
"SELECT deleted_at FROM programs WHERE program_id=?",
(program["program"]["program_id"],),
).fetchone()[0]
source_sessions = database.execute(
"SELECT COUNT(*) FROM program_sessions WHERE program_id=?",
(program["program"]["program_id"],),
).fetchone()[0]
source_entries = database.execute(
"SELECT COUNT(*) FROM program_session_entries"
).fetchone()[0]
self.assertIsNotNone(deleted_at)
self.assertEqual(source_sessions, 2)
self.assertEqual(source_entries, 2)
finally:
if bidi is not None:
bidi.close()
if driver is not None:
driver.quit()
os.killpg(server.pid, signal.SIGTERM)
server.wait(timeout=10)
def test_create_preferences_reload_and_withdraw_by_real_click(self):
if not GECKO.is_file():
self.skipTest("pinned geckodriver unavailable")

View file

@ -38,7 +38,7 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23, 24, 25):
if version not in (20, 21, 22, 23, 24, 25, 26):
raise CausalError(f"desktop schema v20-v25 required, found v{version}")

View file

@ -182,7 +182,7 @@ def causal_payload(draft):
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23, 24, 25):
if version not in (19, 20, 21, 22, 23, 24, 25, 26):
raise LifecycleError(f"desktop schema v19-v25 required, found v{version}")

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
"""Build the raw export using only explicitly ordered SELECT statements."""
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError("schema desktop v15-v18 requis")
raise ValueError("schema desktop v15-v26 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",

View file

@ -20,7 +20,7 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25)
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v25 requis")
columns = {row[1] for row in connection.execute(

View file

@ -28,7 +28,7 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
supported_versions = tuple(range(8, 26))
supported_versions = tuple(range(8, 27))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v16 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)

View file

@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(8, 26))
supported_versions = tuple(range(8, 27))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():

View file

@ -17,7 +17,7 @@ def main():
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25):
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26):
raise ValueError("schema desktop v12-v25 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "

View file

@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(11, 26))
supported_versions = tuple(range(11, 27))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25):
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in range(8, 26):
if version not in range(8, 27):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 26)
supported_versions = range(11, 27)
legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema:
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")

107
tools/export_programs.py Normal file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Export the desktop-owned Programs V1 Android projection."""
import argparse
import datetime as dt
import json
from contextlib import closing
from pathlib import Path
from trainlog_sqlite import connect_database
def nullable(row, index):
return None if row[index] is None else row[index]
def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] != 26:
raise ValueError("desktop schema v26 required")
programs = []
for row in db.execute(
"SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at,"
"updated_at,source_format,source_version,source_payload_sha256 FROM programs "
"WHERE deleted_at IS NULL ORDER BY program_id"
):
sessions = []
for session in db.execute(
"SELECT program_session_id,title,session_type,planned_for,note FROM "
"program_sessions WHERE program_id=? ORDER BY position,program_session_id",
(row[0],),
):
occurrences = []
for entry in db.execute(
"SELECT entry_id,exercise_id,equipment_id,load_mode,rest_seconds,target_sets,"
"target_reps,target_duration_seconds,target_weight_kg,notes FROM "
"program_session_entries WHERE program_session_id=? ORDER BY position,entry_id",
(session[0],),
):
occurrences.append({
"entry_id": entry[0],
"exercise_id": entry[1],
"equipment_id": nullable(entry, 2),
"load_mode": entry[3],
"rest_seconds": entry[4],
"target_sets": nullable(entry, 5),
"target_reps": nullable(entry, 6),
"target_duration_seconds": nullable(entry, 7),
"target_weight_kg": nullable(entry, 8),
"notes": nullable(entry, 9),
})
sessions.append({
"program_session_id": session[0],
"title": session[1],
"session_type": session[2],
"planned_for": nullable(session, 3),
"note": nullable(session, 4),
"occurrences": occurrences,
})
programs.append({
"program_id": row[0],
"revision_id": row[1],
"title": row[2],
"note": nullable(row, 3),
"state": row[4],
"start_date": nullable(row, 5),
"end_date": nullable(row, 6),
"created_at": row[7],
"updated_at": row[8],
"source_format": row[9],
"source_version": row[10],
"source_payload_sha256": row[11],
"sessions": sessions,
})
deletions = [{
"deletion_id": row[0],
"program_id": row[1],
"predecessor_revision_id": row[2],
"revision_id": row[3],
"requested_at": row[4],
} for row in db.execute(
"SELECT request_id,program_id,expected_revision,deleted_revision,deleted_at "
"FROM program_deletions WHERE acknowledged_at IS NULL ORDER BY request_id"
)]
return {
"format": "trainlog-programs",
"version": 1,
"generated_at": dt.datetime.now().astimezone().isoformat(),
"programs": programs,
"deletions": deletions,
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
args.output.write_text(
json.dumps(export_programs(args.database), ensure_ascii=False, sort_keys=True,
separators=(",", ":")),
encoding="utf-8",
)
if __name__ == "__main__":
main()

View file

@ -17,9 +17,9 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25)
supported_versions = (24, 25, 26)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24 or v25 required")
raise ValueError("desktop schema v24, v25 or v26 required")
deliveries = []
rows = connection.execute(
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"

View file

@ -10,7 +10,7 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
supported_versions = range(15, 26)
supported_versions = range(15, 27)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v25 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")

View file

@ -193,7 +193,7 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
supported_versions = range(18, 26)
supported_versions = range(18, 27)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v25 requis")
draft, entries = validate(payload, connection)

View file

@ -187,7 +187,7 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
supported_versions = range(8, 26)
supported_versions = range(8, 27)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export

View file

@ -53,7 +53,7 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 26)
supported_versions = range(8, 27)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():

View file

@ -56,7 +56,7 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 26)
supported_versions = range(12, 27)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v21 requis")
for source, canonical in aliases:

View file

@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 26)
supported_versions = range(11, 27)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v25 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")

View file

@ -50,7 +50,7 @@ def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25):
17, 18, 19, 20, 21, 22, 23, 24, 25, 26):
fail("schema desktop v17-v25 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:

View file

@ -846,7 +846,7 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 26):
if version not in range(5, 27):
raise ImportFailure(
f"base desktop schema v5 à v25 attendue, version trouvée: {version}"
)
@ -1978,7 +1978,7 @@ def apply_payload(connection, payload, trace_exercises=False):
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25):
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v25")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(

View file

@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 26)
supported_versions = range(15, 27)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v25 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")

View file

@ -38,6 +38,7 @@ RUNTIME_TOOLS = [
"export_training_feedback.py",
"export_ai_session_drafts.py",
"export_session_preparations.py",
"export_programs.py",
]
@ -96,8 +97,8 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.2",
"desktop_schema": 25,
"android_schema": 23,
"desktop_schema": 26,
"android_schema": 24,
"protocols": [
"mobile-export-v3",
"mobile-history-v4",
@ -108,6 +109,7 @@ def main() -> int:
"generation-archive-v1",
"session-preparations-v2",
"ai-session-drafts-v2",
"programs-v1",
],
"entry_points": [
"bin/trainlog",

View file

@ -72,6 +72,8 @@ ARTIFACTS = (
"export_ai_session_drafts.py", ()),
("session-preparations", "trainlog-session-preparations", 2,
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
"export_programs.py", ()),
)
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS}
@ -201,9 +203,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
supported_versions = (24, 25)
supported_versions = (24, 25, 26)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24 or v25 required")
raise GenerationError("desktop schema v24, v25 or v26 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -436,6 +438,17 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
(operation["operation_id"], generation, 0 if existed else 1))
programs_path = stage / "programs-v1.json"
if programs_path.is_file():
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
for deletion in captured_programs["deletions"]:
cursor = db.execute(
"UPDATE program_deletions SET generation_id=? WHERE request_id=? "
"AND deleted_revision=? AND generation_id IS NULL",
(generation, deletion["deletion_id"], deletion["revision_id"]),
)
if cursor.rowcount != 1:
raise GenerationError("program deletion changed during capture")
db.commit()
return stage, manifest, checksum
except Exception:
@ -699,6 +712,12 @@ def accept_ack(database: Path, path: Path) -> str:
"WHERE generation_id=? AND acknowledged_at IS NULL",
(ack["consumed_at"], ack["generation_id"]),
)
if db.execute("PRAGMA user_version").fetchone()[0] >= 26:
db.execute(
"UPDATE program_deletions SET acknowledged_at=? WHERE generation_id=? "
"AND acknowledged_at IS NULL",
(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 "

View file

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

View file

@ -44,6 +44,16 @@ TrainlogStatus trainlog_web_programs_archive_json(TrainlogDatabase *database,
char **output_json,
size_t *output_size);
/* CONTRACT: deletion is a terminal logical transition. Exact retries replay
* the durable response; reusing a request ID for another semantic command is
* a conflict. Source definitions and derived preparations remain untouched. */
TrainlogStatus trainlog_web_programs_delete_json(TrainlogDatabase *database,
const char *program_id,
const char *expected_revision,
const char *request_id,
char **output_json,
size_t *output_size);
/* Creates a separate draft preparation and never changes or delivers the
* source program. request_id is forwarded to the preparation service. */
TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,

View file

@ -1214,6 +1214,22 @@ static const char *const MIGRATE_V24_TO_V25_SQL =
"UNIQUE(resource_kind,target_id));"
"PRAGMA user_version=25;COMMIT;";
/* WHY: deleting a reusable plan must not erase imported definitions, source
* sessions, or preparations already derived from them. CONTRACT: v26 adds a
* terminal logical marker and a durable command ledger. INVARIANT: migration
* deletes nothing and marks no existing program as deleted. */
static const char *const MIGRATE_V25_TO_V26_SQL =
"BEGIN IMMEDIATE;"
"ALTER TABLE programs ADD COLUMN deleted_at TEXT;"
"CREATE TABLE program_deletions("
"program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT,"
"request_id TEXT NOT NULL UNIQUE,expected_revision TEXT NOT NULL,"
"deleted_revision TEXT NOT NULL,deleted_at TEXT NOT NULL,response_json TEXT NOT NULL,"
"generation_id TEXT,acknowledged_at TEXT);"
"CREATE INDEX program_deletions_pending ON program_deletions("
"generation_id,deleted_at,request_id) WHERE acknowledged_at IS NULL;"
"PRAGMA user_version=26;COMMIT;";
/* CONTRACT: migration fixtures may retain additive v25 columns while
* deliberately lowering user_version. Add each provenance column only when
* absent; a genuine v24 database still receives both before opening returns. */
@ -2002,7 +2018,8 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
status = TRAINLOG_STATUS_OK;
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25) {
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -2135,6 +2152,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v25_web_deletion_requests(database);
}
if (status == TRAINLOG_STATUS_OK && version < 26) {
status = execute_sql(database, MIGRATE_V25_TO_V26_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -2154,7 +2174,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v25" : "migrate database to schema v25",
version == 0 ? "create schema v26" : "migrate database to schema v26",
database->connection,
SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -197,6 +197,14 @@ add_program_list_item(yyjson_mut_doc *document, yyjson_mut_val *items, sqlite3_s
document, item, "session_count", sqlite3_column_int64(statement, 5)) &&
add_column_text(document, item, "provenance", statement, 6) &&
add_column_text(document, item, "updated_at", statement, 7) &&
add_column_text(document, item, "imported_at", statement, 8) &&
add_column_text(document, item, "revision_id", statement, 9) &&
yyjson_mut_obj_add_sint(
document, item, "preparation_count", sqlite3_column_int64(statement, 10)) &&
yyjson_mut_obj_add_strcpy(document,
item,
"usage",
sqlite3_column_int64(statement, 10) > 0 ? "used" : "unused") &&
yyjson_mut_arr_add_val(items, item);
}
@ -208,9 +216,14 @@ TrainlogStatus trainlog_web_programs_list_json(TrainlogDatabase *database,
char **output_json,
size_t *output_size) {
static const char SQL[] =
"SELECT p.program_id,p.title,p.state,p.start_date,p.end_date,COUNT(s.program_session_id),"
"p.source_format,p.updated_at FROM programs p LEFT JOIN program_sessions s ON "
"s.program_id=p.program_id WHERE (?1='' OR p.title LIKE '%'||?1||'%' COLLATE NOCASE) "
"SELECT p.program_id,p.title,p.state,p.start_date,p.end_date,"
"COUNT(DISTINCT s.program_session_id),p.source_format,p.updated_at,p.created_at,"
"p.revision_id,COUNT(DISTINCT sp.preparation_id) "
"FROM programs p "
"LEFT JOIN program_sessions s ON s.program_id=p.program_id "
"LEFT JOIN session_preparations sp ON sp.source_program_id=p.program_id "
"WHERE p.deleted_at IS NULL AND "
"(?1='' OR p.title LIKE '%'||?1||'%' COLLATE NOCASE) "
"AND (?2='' OR p.state=?2) GROUP BY p.program_id ORDER BY CASE p.state WHEN 'active' "
"THEN 0 ELSE 1 END,p.title COLLATE NOCASE,p.program_id LIMIT ?3 OFFSET ?4";
sqlite3_stmt *statement = NULL;
@ -419,7 +432,7 @@ TrainlogStatus trainlog_web_programs_detail_json(TrainlogDatabase *database,
if (sqlite3_prepare_v2(
database->connection,
"SELECT program_id,title,note,state,start_date,end_date,created_at,updated_at,"
"revision_id,source_format,source_version,source_payload_sha256 "
"revision_id,source_format,source_version,source_payload_sha256,deleted_at "
"FROM programs WHERE program_id=?1",
-1,
&statement,
@ -437,6 +450,11 @@ TrainlogStatus trainlog_web_programs_detail_json(TrainlogDatabase *database,
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_NOT_FOUND;
}
if (sqlite3_column_type(statement, 12) != SQLITE_NULL) {
(void)sqlite3_finalize(statement);
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_CONFLICT;
}
yyjson_mut_doc_set_root(document, root);
(void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U);
for (int column = 0; column < 12; ++column) {
@ -682,8 +700,10 @@ static TrainlogStatus program_insert_header(TrainlogDatabase *database,
const char *digest,
const char *now,
const char *revision) {
static const char SQL[] = "INSERT INTO programs "
"VALUES(?1,?2,?3,?4,?5,?6,?7,?7,?8,'trainlog-program',1,?9)";
static const char SQL[] =
"INSERT INTO programs(program_id,title,note,state,start_date,end_date,created_at,"
"updated_at,revision_id,source_format,source_version,source_payload_sha256) "
"VALUES(?1,?2,?3,?4,?5,?6,?7,?7,?8,'trainlog-program',1,?9)";
sqlite3_stmt *statement = NULL;
int result;
@ -1138,7 +1158,8 @@ static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
result = sqlite3_prepare_v2(database->connection,
"UPDATE programs SET state='archived',updated_at=?1,revision_id=?2 "
"WHERE program_id=?3 AND revision_id=?4 AND state='active'",
"WHERE program_id=?3 AND revision_id=?4 AND state='active' AND "
"deleted_at IS NULL",
-1,
&statement,
NULL);
@ -1189,7 +1210,9 @@ static TrainlogStatus program_store_archive_replay(TrainlogDatabase *database,
const char *now) {
sqlite3_stmt *statement = NULL;
int result = sqlite3_prepare_v2(database->connection,
"INSERT INTO program_requests VALUES(?1,'archive',?2,?3,?4)",
"INSERT INTO program_requests("
"request_id,command,program_id,response_json,created_at) "
"VALUES(?1,'archive',?2,?3,?4)",
-1,
&statement,
NULL);
@ -1260,6 +1283,213 @@ TrainlogStatus trainlog_web_programs_archive_json(TrainlogDatabase *database,
return TRAINLOG_STATUS_OK;
}
typedef enum ProgramDeleteReplay {
PROGRAM_DELETE_REPLAY_NONE = 0,
PROGRAM_DELETE_REPLAY_EXACT,
PROGRAM_DELETE_REPLAY_CONFLICT,
PROGRAM_DELETE_REPLAY_ERROR
} ProgramDeleteReplay;
static ProgramDeleteReplay program_replay_delete(TrainlogDatabase *database,
const char *program_id,
const char *expected_revision,
const char *request_id,
char **output_json,
size_t *output_size) {
sqlite3_stmt *statement = NULL;
ProgramDeleteReplay replay = PROGRAM_DELETE_REPLAY_ERROR;
int result;
result = sqlite3_prepare_v2(database->connection,
"SELECT program_id,expected_revision,response_json FROM "
"program_deletions WHERE request_id=?1",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
if (result == SQLITE_ROW) {
const char *known_program = (const char *)sqlite3_column_text(statement, 0);
const char *known_revision = (const char *)sqlite3_column_text(statement, 1);
const char *saved = (const char *)sqlite3_column_text(statement, 2);
if (known_program == NULL || known_revision == NULL || saved == NULL) {
replay = PROGRAM_DELETE_REPLAY_ERROR;
} else if (strcmp(known_program, program_id) != 0 ||
strcmp(known_revision, expected_revision) != 0) {
replay = PROGRAM_DELETE_REPLAY_CONFLICT;
} else {
*output_size = strlen(saved);
*output_json = strdup(saved);
replay =
*output_json == NULL ? PROGRAM_DELETE_REPLAY_ERROR : PROGRAM_DELETE_REPLAY_EXACT;
}
} else if (result == SQLITE_DONE) {
replay = PROGRAM_DELETE_REPLAY_NONE;
}
(void)sqlite3_finalize(statement);
if (replay != PROGRAM_DELETE_REPLAY_NONE) {
return replay;
}
result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_requests WHERE request_id=?1",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
(void)sqlite3_finalize(statement);
if (result == SQLITE_ROW) {
return PROGRAM_DELETE_REPLAY_CONFLICT;
}
return result == SQLITE_DONE ? PROGRAM_DELETE_REPLAY_NONE : PROGRAM_DELETE_REPLAY_ERROR;
}
static TrainlogStatus program_build_delete_response(const char *program_id,
const char *revision,
const char *deleted_at,
char **output_json,
size_t *output_size) {
yyjson_mut_doc *response = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = response == NULL ? NULL : yyjson_mut_obj(response);
if (response == NULL || root == NULL) {
yyjson_mut_doc_free(response);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
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, "program_id", program_id);
(void)yyjson_mut_obj_add_strcpy(response, root, "deleted_at", deleted_at);
(void)yyjson_mut_obj_add_strcpy(response, root, "revision_id", revision);
return write_document(response, output_json, output_size);
}
TrainlogStatus trainlog_web_programs_delete_json(TrainlogDatabase *database,
const char *program_id,
const char *expected_revision,
const char *request_id,
char **output_json,
size_t *output_size) {
sqlite3_stmt *statement = NULL;
ProgramDeleteReplay replay;
char revision[80];
char now[32];
TrainlogStatus status = TRAINLOG_STATUS_OK;
int result;
if (database == NULL || program_id == NULL || program_id[0] == '\0' ||
expected_revision == NULL || expected_revision[0] == '\0' || request_id == NULL ||
request_id[0] == '\0' || strlen(request_id) > 128U || output_json == NULL ||
output_size == NULL || !timestamp_now(now) ||
trainlog_id_generate("pgr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK) {
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;
}
replay = program_replay_delete(
database, program_id, expected_revision, request_id, output_json, output_size);
if (replay == PROGRAM_DELETE_REPLAY_EXACT) {
(void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL);
return TRAINLOG_STATUS_OK;
}
if (replay == PROGRAM_DELETE_REPLAY_CONFLICT) {
status = TRAINLOG_STATUS_CONFLICT;
} else if (replay == PROGRAM_DELETE_REPLAY_ERROR) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
if (status == TRAINLOG_STATUS_OK) {
result =
sqlite3_prepare_v2(database->connection,
"UPDATE programs SET deleted_at=?1,updated_at=?1,revision_id=?2 "
"WHERE program_id=?3 AND revision_id=?4 AND deleted_at IS NULL",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, now, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, program_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK : SQLITE_ERROR;
}
if (result == SQLITE_OK && sqlite3_changes(database->connection) != 1) {
status = TRAINLOG_STATUS_CONFLICT;
} else if (result != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)sqlite3_finalize(statement);
statement = NULL;
}
if (status == TRAINLOG_STATUS_OK) {
status = program_build_delete_response(program_id, revision, now, output_json, output_size);
}
if (status == TRAINLOG_STATUS_OK) {
result = sqlite3_prepare_v2(
database->connection,
"INSERT INTO program_deletions("
"program_id,request_id,expected_revision,deleted_revision,deleted_at,response_json) "
"VALUES(?1,?2,?3,?4,?5,?6)",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, program_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, request_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 6, *output_json, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK : SQLITE_ERROR;
}
if (result != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)sqlite3_finalize(statement);
}
if (status != TRAINLOG_STATUS_OK ||
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
free(*output_json);
*output_json = NULL;
*output_size = 0U;
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
return TRAINLOG_STATUS_OK;
}
static yyjson_val *program_find_session(yyjson_val *root, const char *program_session_id) {
yyjson_val *sessions = yyjson_obj_get(root, "sessions");
size_t index;
@ -1375,6 +1605,10 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
document = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
free(detail);
root = document == NULL ? NULL : yyjson_doc_get_root(document);
if (root == NULL || !yyjson_equals_str(yyjson_obj_get(root, "state"), "active")) {
yyjson_doc_free(document);
return TRAINLOG_STATUS_CONFLICT;
}
session = root == NULL ? NULL : program_find_session(root, program_session_id);
if (session == NULL) {
yyjson_doc_free(document);

View file

@ -712,6 +712,46 @@ static enum MHD_Result handle_program_archive(TrainlogWebContext *context,
return queue_owned_programs_json(connection, status, json, json_size);
}
static enum MHD_Result handle_program_delete(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *identity,
size_t identity_length,
const char *method) {
static const char SUFFIX[] = "/delete";
const char *request_id =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
const char *if_match =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
char program_id[TRAINLOG_ID_MAX + 1U];
char revision[TRAINLOG_ID_MAX + 1U];
char *json = NULL;
size_t json_size = 0U;
size_t program_length = identity_length - strlen(SUFFIX);
TrainlogStatus status;
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST");
}
if (!program_mutation_allowed(context, connection)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
if (request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U ||
!copy_path_component(program_id, sizeof(program_id), identity, program_length) ||
!parse_quoted_revision(if_match, revision, sizeof(revision))) {
return queue_json(connection,
MHD_HTTP_PRECONDITION_REQUIRED,
"{\"error\":\"precondition_required\"}\n",
NULL);
}
status = trainlog_web_programs_delete_json(
context->database, program_id, revision, request_id, &json, &json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
static enum MHD_Result handle_program_prepare(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *identity,
@ -759,6 +799,7 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
TrainlogHttpRequestState *request) {
static const char PREFIX[] = "/api/v1/sessions/program/";
static const char ARCHIVE_SUFFIX[] = "/archive";
static const char DELETE_SUFFIX[] = "/delete";
const char *identity;
const char *sessions_marker;
char *json = NULL;
@ -783,6 +824,10 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
return handle_program_archive(context, connection, identity, identity_length, method);
}
if (identity_length > strlen(DELETE_SUFFIX) &&
strcmp(identity + identity_length - strlen(DELETE_SUFFIX), DELETE_SUFFIX) == 0) {
return handle_program_delete(context, connection, identity, identity_length, method);
}
if (sessions_marker != NULL) {
return handle_program_prepare(context, connection, identity, sessions_marker, method);
}
@ -791,6 +836,10 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
connection, MHD_HTTP_METHOD_NOT_ALLOWED, "{\"error\":\"method_not_allowed\"}\n", "GET");
}
status = trainlog_web_programs_detail_json(context->database, identity, &json, &json_size);
if (status == TRAINLOG_STATUS_CONFLICT) {
free(json);
return queue_json(connection, MHD_HTTP_GONE, "{\"error\":\"program_deleted\"}\n", NULL);
}
return queue_owned_programs_json(connection, status, json, json_size);
}

View file

@ -732,7 +732,8 @@ static bool program_session_exists(TrainlogDatabase *database,
result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_sessions s JOIN programs p ON "
"p.program_id=s.program_id WHERE p.program_id=?1 AND "
"s.program_session_id=?2",
"s.program_session_id=?2 AND p.state='active' AND "
"p.deleted_at IS NULL",
-1,
&statement,
NULL);

View file

@ -30,6 +30,7 @@ static int scalar(sqlite3 *database, const char *sql) {
int main(void) {
static const char DOWNGRADE_TO_V20[] = "PRAGMA foreign_keys=OFF;"
"DROP TABLE web_session_deletion_requests;"
"DROP TABLE program_deletions;"
"DROP TABLE program_requests;"
"DROP TABLE program_session_entries;"
"DROP TABLE program_sessions;"
@ -53,6 +54,7 @@ int main(void) {
TrainlogDatabase *database = NULL;
sqlite3 *raw = NULL;
size_t exercises = 0U;
char diagnostic[256];
CHECK(file_descriptor >= 0);
CHECK(close(file_descriptor) == 0);
@ -70,7 +72,11 @@ int main(void) {
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, DOWNGRADE_TO_V20, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
if (trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) !=
TRAINLOG_STATUS_OK) {
(void)fprintf(stderr, "migration diagnostic: %s\n", diagnostic);
return 1;
}
CHECK(trainlog_database_exercise_count(database, &exercises) == TRAINLOG_STATUS_OK);
CHECK(exercises > 0U);
trainlog_database_close(database);

View file

@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic,
"migrate database to schema v25: SQLite rc=",
strlen("migrate database to schema v25: SQLite rc=")) == 0);
"migrate database to schema v26: SQLite rc=",
strlen("migrate database to schema v26: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=26;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=27;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 26 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 27 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

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

View file

@ -193,6 +193,13 @@ static bool import_archive_and_prepare(void) {
char *second_entry;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(scalar(database, "PRAGMA user_version") == 26);
CHECK(scalar(database,
"SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE "
"name IN('generation_id','acknowledged_at')") == 2);
CHECK(scalar(database,
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND "
"name='program_deletions_pending'") == 1);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
"Exercise fixture",
@ -284,8 +291,15 @@ static bool import_archive_and_prepare(void) {
&response,
&response_size) == TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"state\":\"archived\"") != NULL);
CHECK(extract_string(response, "revision_id", revision, sizeof(revision)));
free(response);
response = NULL;
CHECK(trainlog_web_programs_prepare_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"request-prepare-after-archive",
&response,
&response_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_web_programs_archive_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
@ -295,6 +309,59 @@ static bool import_archive_and_prepare(void) {
&response_size) == TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"state\":\"archived\"") != NULL);
free(response);
response = NULL;
CHECK(trainlog_web_programs_delete_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
revision,
"request-delete-readable",
&response,
&response_size) == TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"deleted_at\":") != NULL);
free(response);
response = NULL;
CHECK(trainlog_web_programs_delete_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
revision,
"request-delete-readable",
&response,
&response_size) == TRAINLOG_STATUS_OK);
free(response);
response = NULL;
CHECK(trainlog_web_programs_delete_json(database,
"pg_11111111-1111-4111-8111-111111111111",
revision,
"request-delete-readable",
&response,
&response_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 2);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_sessions") == 2);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_entries") == 2);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1);
CHECK(trainlog_web_programs_list_json(database, "", "", 0U, 24U, &response, &response_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(response, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") == NULL);
CHECK(strstr(response, "\"preparation_count\":0") != NULL);
free(response);
response = NULL;
CHECK(trainlog_web_programs_detail_json(
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) ==
TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_web_programs_prepare_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"request-prepare-after-delete",
&response,
&response_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_web_programs_import_json(
database, PROGRAM_JSON, strlen(PROGRAM_JSON), true, &response, &response_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"imported\":false") != NULL);
CHECK(scalar(database, "SELECT COUNT(*) FROM programs WHERE deleted_at IS NOT NULL") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_deletions WHERE request_id LIKE "
"'request-delete-%' AND generation_id IS NULL AND acknowledged_at IS NULL") == 1);
free(response);
trainlog_database_close(database);
return true;
}

View file

@ -9,6 +9,10 @@ export interface ProgramListItem {
session_count: number
provenance: string
updated_at: string
imported_at: string
revision_id: string
preparation_count: number
usage: 'used' | 'unused'
}
export interface ProgramOccurrence {
@ -93,10 +97,10 @@ async function errorReason(response: Response): Promise<string> {
return object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}`
}
function requestId(): string {
function requestId(prefix = 'web'): string {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return `web-${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
return `${prefix}_${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
}
export async function fetchAllPrograms(
@ -127,6 +131,10 @@ export async function fetchAllPrograms(
if (!object(candidate) || typeof candidate.program_id !== 'string' ||
typeof candidate.title !== 'string' ||
(candidate.state !== 'active' && candidate.state !== 'archived') ||
typeof candidate.imported_at !== 'string' ||
typeof candidate.revision_id !== 'string' ||
!Number.isSafeInteger(candidate.preparation_count) ||
(candidate.usage !== 'used' && candidate.usage !== 'unused') ||
identities.has(candidate.program_id)) {
throw new TypeError('pagination de programmes incohérente')
}
@ -156,13 +164,20 @@ export async function fetchProgram(programId: string, signal?: AbortSignal): Pro
return value as unknown as ProgramDetail
}
async function programMutation(path: string, body: string, extraHeaders = {}): Promise<Response> {
async function programMutation(
path: string,
body: string,
extraHeaders = {},
requestPrefix = 'web',
): Promise<Response> {
const csrf = await mutationCsrfToken()
return fetch(path, {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf, 'X-Trainlog-Request-ID': requestId(), ...extraHeaders,
'X-Trainlog-CSRF-Token': csrf,
'X-Trainlog-Request-ID': requestId(requestPrefix),
...extraHeaders,
},
body,
})
@ -194,6 +209,17 @@ export async function archiveProgram(programId: string, revision: string): Promi
}
}
export async function deleteProgram(programId: string, revision: string): Promise<void> {
const response = await programMutation(
`/api/v1/sessions/program/${encodeURIComponent(programId)}/delete`, '{}',
{ 'If-Match': `"${revision}"` },
'pd',
)
if (!response.ok) {
throw new Error(await errorReason(response))
}
}
export async function createPreparationFromProgram(
programId: string,
programSessionId: string,

View file

@ -37,6 +37,62 @@ function preview(imported: boolean) {
}
describe('Programs tab', () => {
it('deletes a program through a separate guarded action and restores focus on cancel', async () => {
const item = {
program_id: 'pg_test',
title: 'Cycle à retirer',
state: 'archived',
start_date: null,
end_date: null,
session_count: 4,
provenance: 'trainlog-program',
updated_at: '2026-09-18T12:00:00Z',
imported_at: '2026-09-17T12:00:00Z',
revision_id: 'pgr_revision',
preparation_count: 2,
usage: 'used',
}
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.startsWith('/api/v1/sessions/programs?')) {
return jsonResponse({ ...programPage(), items: [item] })
}
if (url === '/api/v1/sync/status') {
return jsonResponse(
{ phase: 'idle', result: 'ok' },
{ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) },
)
}
if (url === '/api/v1/sessions/program/pg_test/delete') {
expect(init?.method).toBe('POST')
expect((init?.headers as Record<string, string>)['If-Match']).toBe('"pgr_revision"')
expect((init?.headers as Record<string, string>)['X-Trainlog-Request-ID'])
.toMatch(/^pd_[0-9a-f]{32}$/)
return jsonResponse({ api_version: 1, program_id: 'pg_test' })
}
throw new Error(`unexpected request ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
render(<ProgramsTab detailProgramId={null} onOpen={vi.fn()} onBack={vi.fn()} />)
const trash = await screen.findByRole('button', {
name: 'Supprimer le programme Cycle à retirer',
})
fireEvent.click(trash)
let dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveTextContent(/toutes les préparations.*conserv/)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(document.activeElement).toBe(trash)
fireEvent.click(trash)
dialog = screen.getByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Supprimer le programme' }))
await waitFor(() => expect(screen.queryByText('Cycle à retirer')).not.toBeInTheDocument())
expect(fetchMock.mock.calls.filter(([url]) =>
String(url) === '/api/v1/sessions/program/pg_test/delete')).toHaveLength(1)
})
it('shows a server preview before the only importing mutation', async () => {
const onOpen = vi.fn()
const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => {

View file

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import {
archiveProgram,
createPreparationFromProgram,
deleteProgram,
fetchAllPrograms,
fetchProgram,
importProgram,
@ -10,6 +11,7 @@ import {
type ProgramImportPreview,
type ProgramListItem,
} from '../api/programs'
import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog'
import { useDatePreferences } from '../presentation/DatePreferences'
import { formatCivilDate, formatDateTime } from '../presentation/dateFormat'
@ -98,6 +100,8 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
<p>
{session.session_type === 'max_test'
? 'Test MAX planifié' : 'Entraînement planifié'}
{session.planned_for
? ` · ${formatCivilDate(session.planned_for, dateFormat)}` : ''}
{' · '}{session.occurrences.length} exercice
{session.occurrences.length > 1 ? 's' : ''}
</p>
@ -131,6 +135,7 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
}) {
const { dateFormat } = useDatePreferences()
const fileInput = useRef<HTMLInputElement>(null)
const deleteReturnFocus = useRef<HTMLElement>(null)
const [items, setItems] = useState<ProgramListItem[]>([])
const [search, setSearch] = useState('')
const [state, setState] = useState('')
@ -139,6 +144,9 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
const [rawImport, setRawImport] = useState('')
const [preview, setPreview] = useState<ProgramImportPreview | null>(null)
const [fileName, setFileName] = useState('')
const [deleteTarget, setDeleteTarget] = useState<ProgramListItem | null>(null)
const [deleteBusy, setDeleteBusy] = useState(false)
const [deleteError, setDeleteError] = useState('')
const reload = () => {
setPending(true)
setError('')
@ -199,6 +207,22 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
setPreview(null)
setRawImport('')
}
const confirmDelete = async () => {
if (!deleteTarget) return
setDeleteBusy(true)
setDeleteError('')
try {
await deleteProgram(deleteTarget.program_id, deleteTarget.revision_id)
setItems((current) => current.filter(
(candidate) => candidate.program_id !== deleteTarget.program_id,
))
setDeleteTarget(null)
} catch (reason) {
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
} finally {
setDeleteBusy(false)
}
}
return <section className="programs-tab">
<div className="sessions-toolbar">
<label className="session-search">
@ -264,27 +288,57 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
{!pending && !error && items.length === 0 &&
<p className="empty-inline">Aucun programme.</p>}
<div className="program-list">
{items.map((program) => <button
type="button"
{items.map((program) => <article
key={program.program_id}
className="session-row program-row"
onClick={() => onOpen(program.program_id)}
>
<span>
<small>{program.state === 'active' ? 'Programme actif' : 'Programme archivé'}</small>
<strong>{program.title}</strong>
</span>
<span>
{program.start_date
? formatCivilDate(program.start_date, dateFormat) : 'Dates libres'}
</span>
<span>
{program.session_count} séance{program.session_count > 1 ? 's' : ''}
</span>
<span>
{program.provenance} · {formatDateTime(program.updated_at, dateFormat)}
</span>
</button>)}
<button
type="button"
className="session-row-main"
onClick={() => onOpen(program.program_id)}
>
<span>
<small>{program.state === 'active' ? 'Programme actif' : 'Programme archivé'}</small>
<strong>{program.title}</strong>
</span>
<span>
{program.start_date
? formatCivilDate(program.start_date, dateFormat) : 'Dates libres'}
</span>
<span>
{program.session_count} séance{program.session_count > 1 ? 's' : ''}
{program.preparation_count > 0
? ` · ${program.preparation_count} préparation${program.preparation_count > 1 ? 's' : ''}`
: ' · jamais utilisé'}
</span>
<span>
{program.provenance} · importé {formatDateTime(program.imported_at, dateFormat)}
</span>
</button>
<button
type="button"
className="trash-action"
aria-label={`Supprimer le programme ${program.title}`}
title="Supprimer le programme"
onClick={(event) => {
deleteReturnFocus.current = event.currentTarget
setDeleteError('')
setDeleteTarget(program)
}}
>🗑</button>
</article>)}
</div>
{deleteTarget && <DeleteConfirmationDialog
title={deleteTarget.title}
itemType={deleteTarget.state === 'active' ? 'Programme actif' : 'Programme archivé'}
consequence="Le programme disparaîtra définitivement de cette liste. Ses séances sources, ses exercices et toutes les préparations déjà créées seront conservés."
cancelLabel="Conserver"
confirmLabel="Supprimer le programme"
busy={deleteBusy}
error={deleteError}
returnFocus={deleteReturnFocus}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
/>}
</section>
}

View file

@ -167,12 +167,17 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
.trash-action:hover,
.trash-action:focus-visible { background: rgb(243 139 168 / 12%); }
.program-list, .program-session-list { display: grid; gap: .75rem; }
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); }
.program-row .session-row-main { grid-template-columns: minmax(13rem, 2fr) repeat(3, minmax(8rem, 1fr)); }
.program-session-list { padding: 0; list-style: none; }
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; }
.program-session-list p { margin: .35rem 0 0; color: var(--subtext-0); }
.import-preview { margin: 1rem 0; padding: 1rem; border: 1px solid var(--blue); border-radius: .75rem; background: var(--mantle); }
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 600px) { .program-session-list li { align-items: stretch; flex-direction: column; } }
@media (max-width: 600px) {
.program-session-list li { align-items: stretch; flex-direction: column; }
.program-row .session-row-main { grid-template-columns: 1fr; }
}
.detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); }
.detail-occurrences li { display: grid; gap: .35rem; }
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }