feat(programs): add deletion and Android projection
This commit is contained in:
parent
4d6ee07ac5
commit
20fc427c2f
77 changed files with 1935 additions and 116 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
"execution-draft-v1",
|
||||
"generation-archive-v1",
|
||||
"session-preparations-v2",
|
||||
"programs-v1",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
BIN
docs/reviews/evidence/web-programs-v1-card-desktop.png
Normal file
BIN
docs/reviews/evidence/web-programs-v1-card-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
84
tests/test_programs_exchange.py
Normal file
84
tests/test_programs_exchange.py
Normal 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()
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
107
tools/export_programs.py
Normal 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()
|
||||
|
|
@ -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,"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue