Compare commits
3 commits
4d6ee07ac5
...
db722588f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db722588f8 | ||
|
|
f431b6ec9c | ||
|
|
20fc427c2f |
81 changed files with 2958 additions and 147 deletions
|
|
@ -170,7 +170,7 @@ It is not the canonical analytics store.
|
||||||
The Android UI is driven by exercise metadata, never by exercise-name
|
The Android UI is driven by exercise metadata, never by exercise-name
|
||||||
heuristics.
|
heuristics.
|
||||||
|
|
||||||
Android local SQLite schema v23 retains the exactly-one durable active-session
|
Android local SQLite schema v24 retains the exactly-one durable active-session
|
||||||
draft introduced in v4.
|
draft introduced in v4.
|
||||||
Every meaningful draft/form mutation is persisted by the repository. Back,
|
Every meaningful draft/form mutation is persisted by the repository. Back,
|
||||||
backgrounding and process death never delete the draft. Home offers explicit
|
backgrounding and process death never delete the draft. Home offers explicit
|
||||||
|
|
@ -184,7 +184,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
|
||||||
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
|
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
|
||||||
durable flattened exercise-alias mapping used to resolve retired creator IDs.
|
durable flattened exercise-alias mapping used to resolve retired creator IDs.
|
||||||
Desktop schema v12 owns the same alias compatibility boundary. Later additive
|
Desktop schema v12 owns the same alias compatibility boundary. Later additive
|
||||||
desktop migrations reach v24 and Android migrations reach v23 without changing
|
desktop migrations reach v26 and Android migrations reach v24 without changing
|
||||||
that identity contract. The active
|
that identity contract. The active
|
||||||
completed-session exchange is the separate strict
|
completed-session exchange is the separate strict
|
||||||
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
|
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
|
||||||
|
|
@ -241,13 +241,14 @@ Desktop SQLite schema is versioned with:
|
||||||
PRAGMA user_version;
|
PRAGMA user_version;
|
||||||
```
|
```
|
||||||
|
|
||||||
The current desktop schema is v24. Schema v9 added an occurrence-owned explicit
|
The current desktop schema is v26. Schema v9 added an occurrence-owned explicit
|
||||||
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
|
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
|
||||||
flattened exercise aliases; v13-v18 add machine metadata, feedback and
|
flattened exercise aliases; v13-v18 add machine metadata, feedback and
|
||||||
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
|
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
|
||||||
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
|
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
|
||||||
generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
|
generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
|
||||||
and durable preparation withdrawal. None changes `TRAINLOG_FORMAT_V1`.
|
and durable preparation withdrawal; v25 adds Programs; and v26 additively adds
|
||||||
|
logical Program deletion. None changes `TRAINLOG_FORMAT_V1`.
|
||||||
|
|
||||||
Every incompatible schema evolution requires an explicit migration and
|
Every incompatible schema evolution requires an explicit migration and
|
||||||
regression coverage.
|
regression coverage.
|
||||||
|
|
|
||||||
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -2,6 +2,26 @@
|
||||||
|
|
||||||
## Unreleased — 0.1.2
|
## Unreleased — 0.1.2
|
||||||
|
|
||||||
|
- Completed the private coordinated deployment of
|
||||||
|
`TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1`. 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 and real rollout evidence are recorded in
|
||||||
|
`docs/reviews/programs_presentation_android_delete_v1_evidence.md`. The
|
||||||
|
signed private Android update advanced versionCode 15 to 16 without
|
||||||
|
uninstall or data clearing. A real disposable Program tombstone was consumed
|
||||||
|
and acknowledged, and a final correlated exchange after component restart
|
||||||
|
proved persistence, idempotence, and non-resurrection.
|
||||||
|
|
||||||
- Completed the private grouped rollout of Web Sessions deletion and Program
|
- Completed the private grouped rollout of Web Sessions deletion and Program
|
||||||
V1 from desktop source `a0fc48f082b76dba2ae3a56fceb492169de89f89`.
|
V1 from desktop source `a0fc48f082b76dba2ae3a56fceb492169de89f89`.
|
||||||
Desktop migrated from schema v24 to v25 after backup, restoration, and
|
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. |
|
| 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. |
|
| 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, terminal logical deletion, and a privately deployed read-only Android projection. Analyse, the standalone Programmes route, and Exercises remain placeholders. |
|
||||||
|
|
||||||
No interface reconstructs business truth from SQLite tables. The local Web is
|
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
|
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:
|
As of 2026-09-18:
|
||||||
|
|
||||||
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
- `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;
|
- Notcurses is the only active desktop terminal backend;
|
||||||
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||||
all-files access setting;
|
all-files access setting;
|
||||||
|
|
@ -335,6 +335,9 @@ As of 2026-09-18:
|
||||||
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
|
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
|
||||||
Programs is implemented inside Sessions; Analyse, Exercises, and the
|
Programs is implemented inside Sessions; Analyse, Exercises, and the
|
||||||
standalone top-level Programmes route remain placeholders;
|
standalone top-level Programmes route remain placeholders;
|
||||||
|
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
|
||||||
|
private desktop/Android deployment, real Firefox presentation, correlated
|
||||||
|
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
|
||||||
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
|
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
|
||||||
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
|
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
|
||||||
services; automatic synchronization still selects V3. The trusted local
|
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.getInt("version") == VERSION &&
|
||||||
manifest.getString("package") == context.packageName &&
|
manifest.getString("package") == context.packageName &&
|
||||||
manifest.getBoolean("plaintext") &&
|
manifest.getBoolean("plaintext") &&
|
||||||
manifest.getInt("schema") in 17..23
|
manifest.getInt("schema") in 17..24
|
||||||
) {
|
) {
|
||||||
"Unsupported backup."
|
"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.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
|
return db.version
|
||||||
} finally {
|
} finally {
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
||||||
"execution-draft-v1",
|
"execution-draft-v1",
|
||||||
"generation-archive-v1",
|
"generation-archive-v1",
|
||||||
"session-preparations-v2",
|
"session-preparations-v2",
|
||||||
|
"programs-v1",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
private val SUPPORTED =
|
private val SUPPORTED =
|
||||||
(ANDROID_KINDS.values +
|
(ANDROID_KINDS.values +
|
||||||
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
|
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 }
|
.map { it.format to it.version }
|
||||||
.toSet()
|
.toSet()
|
||||||
|
|
||||||
|
|
@ -658,6 +659,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
is ExerciseAliasImportResult.Applied,
|
is ExerciseAliasImportResult.Applied,
|
||||||
is ExecutionDraftImportResult.Applied,
|
is ExecutionDraftImportResult.Applied,
|
||||||
is AiSessionDraftImportResult.Applied,
|
is AiSessionDraftImportResult.Applied,
|
||||||
|
is ProgramsImportResult.Applied,
|
||||||
is CausalDeleteResult.Applied,
|
is CausalDeleteResult.Applied,
|
||||||
is CausalDeleteResult.Unchanged -> null
|
is CausalDeleteResult.Unchanged -> null
|
||||||
else -> result.toString()
|
else -> result.toString()
|
||||||
|
|
@ -799,6 +801,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
apply("causal-deletions") {
|
apply("causal-deletions") {
|
||||||
repository.applyCausalDeletionExportV1Json(checkNotNull(a["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("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
|
||||||
apply("exercise-profile-state") {
|
apply("exercise-profile-state") {
|
||||||
repository.applyExerciseProfileStateJson(
|
repository.applyExerciseProfileStateJson(
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ import com.labfytools.trainlog.model.SessionLoadMode
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
import com.labfytools.trainlog.model.SessionType
|
import com.labfytools.trainlog.model.SessionType
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
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.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.text.Normalizer
|
import java.text.Normalizer
|
||||||
|
|
@ -218,7 +222,14 @@ sealed interface MobileSessionImportResult {
|
||||||
data object DatabaseError : 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 SyncLifecycleConflict(val ownerId: String) : RuntimeException()
|
||||||
|
private class ProgramsImportInvalid(message: String) : RuntimeException(message)
|
||||||
|
|
||||||
sealed interface CreateEquipmentResult {
|
sealed interface CreateEquipmentResult {
|
||||||
data class Created(val equipment: EquipmentCatalogEntry) : CreateEquipmentResult
|
data class Created(val equipment: EquipmentCatalogEntry) : CreateEquipmentResult
|
||||||
|
|
@ -8056,6 +8067,299 @@ class TrainlogRepository(
|
||||||
secondaryZoneIds.forEach { insert(it, "secondary") }
|
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(
|
private fun normalizeName(
|
||||||
value: String,
|
value: String,
|
||||||
): String {
|
): String {
|
||||||
|
|
@ -8143,6 +8447,9 @@ private fun JSONObject.optDoubleOrNull(key: String): Double? =
|
||||||
private fun JSONObject.optIntOrNull(key: String): Int? =
|
private fun JSONObject.optIntOrNull(key: String): Int? =
|
||||||
if (has(key) && !isNull(key)) getInt(key) else null
|
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 {
|
private fun jsonHasUniqueObjectKeys(json: String): Boolean = try {
|
||||||
JsonReader(StringReader(json)).use { reader ->
|
JsonReader(StringReader(json)).use { reader ->
|
||||||
fun consumeValue() {
|
fun consumeValue() {
|
||||||
|
|
@ -8231,7 +8538,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
23,
|
24,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -8279,6 +8586,7 @@ private class TrainlogDatabaseHelper(
|
||||||
createSyncGenerationArchiveTable(db)
|
createSyncGenerationArchiveTable(db)
|
||||||
createSessionPreparationTables(db)
|
createSessionPreparationTables(db)
|
||||||
createSessionPreparationWithdrawalTable(db)
|
createSessionPreparationWithdrawalTable(db)
|
||||||
|
createSyncedProgramTables(db)
|
||||||
seedEquipment(db)
|
seedEquipment(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -8468,6 +8776,14 @@ private class TrainlogDatabaseHelper(
|
||||||
createSessionPreparationWithdrawalTable(db)
|
createSessionPreparationWithdrawalTable(db)
|
||||||
version = 23
|
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) {
|
if (version != newVersion) {
|
||||||
error(
|
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) {
|
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
|
||||||
fun addColumn(table: String, name: String, declaration: String) {
|
fun addColumn(table: String, name: String, declaration: String) {
|
||||||
if (!tableHasColumn(db, table, name)) {
|
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.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.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.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
|
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
|
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
|
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 SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data object AiSessionDrafts : 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 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 SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data class SessionCorrection(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 }
|
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,
|
onManual: () -> Unit,
|
||||||
onDrafts: () -> Unit,
|
onDrafts: () -> Unit,
|
||||||
onHistory: () -> Unit,
|
onHistory: () -> Unit,
|
||||||
|
onPrograms: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
|
|
@ -67,6 +68,8 @@ fun SessionsHub(
|
||||||
}
|
}
|
||||||
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
|
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
|
||||||
Modifier.fillMaxWidth().testTag("sessions-history-action"))
|
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) },
|
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
||||||
)
|
)
|
||||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
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.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) }, {
|
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||||
draftMessage = strings.getString(R.string.existing_draft_warning)
|
draftMessage = strings.getString(R.string.existing_draft_warning)
|
||||||
|
|
@ -124,6 +124,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
onStarted = { open(AppRoute.SessionEditor) },
|
onStarted = { open(AppRoute.SessionEditor) },
|
||||||
)
|
)
|
||||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
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.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) }
|
||||||
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
|
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
|
||||||
if (saved) exportSnapshot()
|
if (saved) exportSnapshot()
|
||||||
|
|
@ -164,6 +166,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
private fun AppRoute.stateKey(): String = when (this) {
|
private fun AppRoute.stateKey(): String = when (this) {
|
||||||
is AppRoute.SessionDetail -> "session:${sessionId}"
|
is AppRoute.SessionDetail -> "session:${sessionId}"
|
||||||
is AppRoute.SessionCorrection -> "session-correction:${sessionId}"
|
is AppRoute.SessionCorrection -> "session-correction:${sessionId}"
|
||||||
|
is AppRoute.ProgramDetail -> "program:${programId}"
|
||||||
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
|
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
|
||||||
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
|
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
|
||||||
is AppRoute.ExerciseCreate -> "exercise-create:${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_session_generator">Plan a session</string>
|
||||||
<string name="route_ai_drafts">Drafts</string>
|
<string name="route_ai_drafts">Drafts</string>
|
||||||
<string name="route_completed_sessions">Completed sessions</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_detail">Session details</string>
|
||||||
<string name="route_session_correction">Edit session</string>
|
<string name="route_session_correction">Edit session</string>
|
||||||
<string name="route_exercise_detail">Exercise details</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_session_generator">Programmer une séance</string>
|
||||||
<string name="route_ai_drafts">Brouillons</string>
|
<string name="route_ai_drafts">Brouillons</string>
|
||||||
<string name="route_completed_sessions">Séances effectuées</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_detail">Détail de la séance</string>
|
||||||
<string name="route_session_correction">Modifier la séance</string>
|
<string name="route_session_correction">Modifier la séance</string>
|
||||||
<string name="route_exercise_detail">Fiche exercice</string>
|
<string name="route_exercise_detail">Fiche exercice</string>
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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")) {
|
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(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"))
|
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()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* 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") }
|
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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(
|
assertEquals(before, listOf(
|
||||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
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 ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
||||||
null,
|
null,
|
||||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||||
)
|
)
|
||||||
.use { assertEquals(23, it.version) }
|
.use { assertEquals(24, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
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)
|
repository = TrainlogRepository(context, name)
|
||||||
assertEquals(1, repository.listPreparedSessions().size)
|
assertEquals(1, repository.listPreparedSessions().size)
|
||||||
repository.close()
|
repository.close()
|
||||||
assertEquals("23", scalarText(name, "PRAGMA user_version"))
|
assertEquals("24", scalarText(name, "PRAGMA user_version"))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"0",
|
"0",
|
||||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,224 @@ import org.robolectric.annotation.Config
|
||||||
class SyncGenerationServiceTest {
|
class SyncGenerationServiceTest {
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
|
||||||
|
val repositoryRoot =
|
||||||
|
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||||
|
it.parentFile
|
||||||
|
}
|
||||||
|
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||||
|
val fixture = java.io.File(repositoryRoot, "build/tui/sync-generation-fixture")
|
||||||
|
val generationTool =
|
||||||
|
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
|
||||||
|
val root = Files.createTempDirectory("trainlog-program-deletion-generation-").toFile()
|
||||||
|
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||||
|
val desktopOwned = java.io.File(root, "desktop-owned")
|
||||||
|
val desktopObjects = java.io.File(root, "desktop-objects")
|
||||||
|
val androidName = "program-deletion-generation-${UUID.randomUUID()}.db"
|
||||||
|
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
|
val deletionId = "program-delete-generation-regression"
|
||||||
|
var android = TrainlogRepository(context, androidName)
|
||||||
|
|
||||||
|
fun run(vararg command: String): String {
|
||||||
|
val process =
|
||||||
|
ProcessBuilder(*command)
|
||||||
|
.directory(repositoryRoot)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
val output = process.inputStream.bufferedReader().readText()
|
||||||
|
assertEquals(
|
||||||
|
"command failed: ${command.joinToString(" ")}\n$output",
|
||||||
|
0,
|
||||||
|
process.waitFor(),
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
fun captureAndPublish(service: SyncGenerationService): Pair<String, java.io.File> {
|
||||||
|
val captured =
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
generationTool,
|
||||||
|
"capture-desktop",
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"--owned-root",
|
||||||
|
desktopOwned.absolutePath,
|
||||||
|
"--consumer-peer",
|
||||||
|
service.peerId(),
|
||||||
|
)
|
||||||
|
val generationId =
|
||||||
|
checkNotNull(Regex("generation_id=(gen_[^ ]+)").find(captured)).groupValues[1]
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
generationTool,
|
||||||
|
"publish",
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"--generation-id",
|
||||||
|
generationId,
|
||||||
|
"--object-root",
|
||||||
|
desktopObjects.absolutePath,
|
||||||
|
)
|
||||||
|
return generationId to java.io.File(desktopObjects, "generations/$generationId")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptAndroidAck(generationId: String, acknowledgement: String): String {
|
||||||
|
val acknowledgementFile =
|
||||||
|
java.io.File(root, "$generationId-ack.json").apply {
|
||||||
|
writeText(acknowledgement)
|
||||||
|
}
|
||||||
|
return run(
|
||||||
|
"python3",
|
||||||
|
generationTool,
|
||||||
|
"accept-ack",
|
||||||
|
acknowledgementFile.absolutePath,
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun androidDeletionCount(): Int =
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(androidName).absolutePath,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
)
|
||||||
|
.use { db ->
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT COUNT(*) FROM synced_program_deletions WHERE program_id=?",
|
||||||
|
arrayOf(programId),
|
||||||
|
)
|
||||||
|
.use {
|
||||||
|
assertTrue(it.moveToFirst())
|
||||||
|
it.getInt(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val created = run(fixture.absolutePath, desktopDb.absolutePath, "program-create")
|
||||||
|
val createdProgram = JSONObject(created.substringAfter("PROGRAM_CREATE=").trim())
|
||||||
|
val initialRevision = createdProgram.getString("revision_id")
|
||||||
|
assertEquals(programId, createdProgram.getString("program_id"))
|
||||||
|
|
||||||
|
var service = SyncGenerationService(android)
|
||||||
|
val (liveGenerationId, livePublished) = captureAndPublish(service)
|
||||||
|
val desktopPeer =
|
||||||
|
JSONObject(java.io.File(livePublished, "manifest.json").readText())
|
||||||
|
.getJSONObject("producer")
|
||||||
|
.getString("peer_id")
|
||||||
|
val livePrograms =
|
||||||
|
JSONObject(java.io.File(livePublished, "programs-v1.json").readText())
|
||||||
|
assertEquals(
|
||||||
|
programId,
|
||||||
|
livePrograms
|
||||||
|
.getJSONArray("programs")
|
||||||
|
.getJSONObject(0)
|
||||||
|
.getString("program_id"),
|
||||||
|
)
|
||||||
|
assertEquals(0, livePrograms.getJSONArray("deletions").length())
|
||||||
|
val liveAck = service.consume(livePublished)
|
||||||
|
assertEquals(programId, android.listSyncedPrograms().single().programId)
|
||||||
|
assertTrue(
|
||||||
|
acceptAndroidAck(liveGenerationId, liveAck).contains("ACK_ACCEPT=acknowledged")
|
||||||
|
)
|
||||||
|
|
||||||
|
val deleted =
|
||||||
|
run(
|
||||||
|
fixture.absolutePath,
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"program-delete",
|
||||||
|
initialRevision,
|
||||||
|
deletionId,
|
||||||
|
)
|
||||||
|
val replayedDelete =
|
||||||
|
run(
|
||||||
|
fixture.absolutePath,
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"program-delete",
|
||||||
|
initialRevision,
|
||||||
|
deletionId,
|
||||||
|
)
|
||||||
|
assertEquals(deleted, replayedDelete)
|
||||||
|
val deletionRevision =
|
||||||
|
JSONObject(deleted.substringAfter("PROGRAM_DELETE=").trim())
|
||||||
|
.getString("revision_id")
|
||||||
|
|
||||||
|
val (deletionGenerationId, deletionPublished) = captureAndPublish(service)
|
||||||
|
val deletedPrograms =
|
||||||
|
JSONObject(java.io.File(deletionPublished, "programs-v1.json").readText())
|
||||||
|
assertEquals(0, deletedPrograms.getJSONArray("programs").length())
|
||||||
|
assertEquals(1, deletedPrograms.getJSONArray("deletions").length())
|
||||||
|
val exportedDeletion = deletedPrograms.getJSONArray("deletions").getJSONObject(0)
|
||||||
|
assertEquals(deletionId, exportedDeletion.getString("deletion_id"))
|
||||||
|
assertEquals(initialRevision, exportedDeletion.getString("predecessor_revision_id"))
|
||||||
|
assertEquals(deletionRevision, exportedDeletion.getString("revision_id"))
|
||||||
|
val deletionAck = service.consume(deletionPublished)
|
||||||
|
assertTrue(android.listSyncedPrograms().isEmpty())
|
||||||
|
assertTrue(
|
||||||
|
acceptAndroidAck(deletionGenerationId, deletionAck)
|
||||||
|
.contains("ACK_ACCEPT=acknowledged")
|
||||||
|
)
|
||||||
|
val acknowledgedDeletion =
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT generation_id || '|' || (acknowledged_at IS NOT NULL) " +
|
||||||
|
"FROM program_deletions WHERE request_id='$deletionId';",
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
assertEquals("$deletionGenerationId|1", acknowledgedDeletion)
|
||||||
|
|
||||||
|
android.close()
|
||||||
|
android = TrainlogRepository(context, androidName)
|
||||||
|
service = SyncGenerationService(android)
|
||||||
|
val reopenedDesktopPeer =
|
||||||
|
checkNotNull(
|
||||||
|
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||||
|
.find(
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
generationTool,
|
||||||
|
"peer-id",
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupValues[1]
|
||||||
|
assertEquals(desktopPeer, reopenedDesktopPeer)
|
||||||
|
assertEquals(liveAck, service.consume(livePublished))
|
||||||
|
assertTrue(android.listSyncedPrograms().isEmpty())
|
||||||
|
assertEquals(1, androidDeletionCount())
|
||||||
|
|
||||||
|
val (cleanGenerationId, cleanPublished) = captureAndPublish(service)
|
||||||
|
val cleanPrograms =
|
||||||
|
JSONObject(java.io.File(cleanPublished, "programs-v1.json").readText())
|
||||||
|
assertEquals(0, cleanPrograms.getJSONArray("programs").length())
|
||||||
|
assertEquals(0, cleanPrograms.getJSONArray("deletions").length())
|
||||||
|
val cleanAck = service.consume(cleanPublished)
|
||||||
|
assertTrue(android.listSyncedPrograms().isEmpty())
|
||||||
|
assertEquals(1, androidDeletionCount())
|
||||||
|
assertTrue(
|
||||||
|
acceptAndroidAck(cleanGenerationId, cleanAck).contains("ACK_ACCEPT=acknowledged")
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"1",
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT COUNT(*) FROM program_deletions WHERE program_id='$programId';",
|
||||||
|
)
|
||||||
|
.trim(),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
android.close()
|
||||||
|
context.deleteDatabase(androidName)
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun acknowledgedOutboundRemainsResumableUntilInboundIsTerminal() {
|
fun acknowledgedOutboundRemainsResumableUntilInboundIsTerminal() {
|
||||||
val sourceName = "generation-resume-source-${UUID.randomUUID()}.db"
|
val sourceName = "generation-resume-source-${UUID.randomUUID()}.db"
|
||||||
|
|
@ -111,7 +329,7 @@ class SyncGenerationServiceTest {
|
||||||
.use { db ->
|
.use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use {
|
db.rawQuery("PRAGMA user_version", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
assertEquals(23, it.getInt(0))
|
assertEquals(24, it.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(23, cursor.getInt(0))
|
assertEquals(24, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(23, cursor.getInt(0))
|
assertEquals(24, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ evidence and chronology.
|
||||||
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
|
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
|
||||||
history and Android-delivery workflow.
|
history and Android-delivery workflow.
|
||||||
- [Program format V1](program_format_v1.md) owns the strict desktop planning
|
- [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,
|
- [Android](android.md) owns field-companion capture, local persistence,
|
||||||
glanceable summaries, permissions, and Android interaction rules.
|
glanceable summaries, permissions, and Android interaction rules.
|
||||||
|
|
@ -77,6 +77,9 @@ evidence and chronology.
|
||||||
|
|
||||||
- [Review records](reviews/) retain historical audits, incidents, checkpoints,
|
- [Review records](reviews/) retain historical audits, incidents, checkpoints,
|
||||||
and validation evidence. Their status and paths are historical.
|
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.
|
- [Changelog](../CHANGELOG.md) owns chronological change history.
|
||||||
- [Release notes](releases/) retain reusable, tag-scoped English source text
|
- [Release notes](releases/) retain reusable, tag-scoped English source text
|
||||||
for public GitHub and Forgejo releases.
|
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
|
Withdrawal replay is idempotent, and an older preparation delivery is skipped
|
||||||
once the withdrawal identity for that preparation exists.
|
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
|
## 1. Purpose
|
||||||
|
|
||||||
The Android application is Trainlog's low-friction capture client.
|
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
|
existing positive/non-negative domain rule. Distance remains explicitly km; no
|
||||||
unit guessing or automatic correction occurs.
|
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
|
Completed session occurrences persist an `entry_id`; it is never regenerated
|
||||||
for exchange. Android schema v14 added feedback roots and completion `ended_at`
|
for exchange. Android schema v14 added feedback roots and completion `ended_at`
|
||||||
|
|
|
||||||
|
|
@ -493,9 +493,15 @@ silently rendered as unclassified.
|
||||||
|
|
||||||
### Desktop
|
### Desktop
|
||||||
|
|
||||||
Desktop SQLite schema v24 is canonical long-term history. Its additive v23 ->
|
Desktop SQLite schema v26 is canonical long-term history. Its additive v25 ->
|
||||||
v24 migration adds durable manual-preparation withdrawal identity without
|
v26 migration adds terminal Program logical deletion and its durable replay,
|
||||||
deleting revisions, deliveries or workout facts. Its additive v18 ->
|
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
|
v19 migration stores causal notes and execution-draft lifecycle state without
|
||||||
activating a new transport. Its v15 -> v16
|
activating a new transport. Its v15 -> v16
|
||||||
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
|
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ The opt-in full-generation path is operational on the private daily
|
||||||
installation. The loopback Web control and foreground Android coordinator use
|
installation. The loopback Web control and foreground Android coordinator use
|
||||||
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
|
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
|
||||||
persistent peer identities. The latest controlled rollout verified real
|
persistent peer identities. The latest controlled rollout verified real
|
||||||
desktop and Android backups, restoration and migration on copies, desktop
|
desktop and Android backups, populated-copy migration, desktop schema v25 to
|
||||||
schema v24 to v25, and an in-place private Android update from versionCode 14
|
v26, and an in-place private Android update from versionCode 15 to 16. The
|
||||||
to 15. The installed APK remains `com.labfytools.trainlog` 0.1.2, is
|
installed APK remains `com.labfytools.trainlog` 0.1.2, is
|
||||||
non-debuggable, and retains certificate SHA-256
|
non-debuggable, and retains certificate SHA-256
|
||||||
`aa56c97f2781a0d01f007f4444c3970deb58ad8327ca3da7b0b90f37dbe2ad25`.
|
`aa56c97f2781a0d01f007f4444c3970deb58ad8327ca3da7b0b90f37dbe2ad25`.
|
||||||
One preparation created explicitly as disposable was withdrawn through the Web
|
One preparation created explicitly as disposable was withdrawn through the Web
|
||||||
|
|
@ -57,8 +57,8 @@ parallel implementation of its rules.
|
||||||
| Boundary | Current state |
|
| Boundary | Current state |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||||
| Desktop SQLite | schema v25 |
|
| Desktop SQLite | schema v26 |
|
||||||
| Android SQLite | schema v23 |
|
| Android SQLite | schema v24 |
|
||||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||||
| Desktop terminal backend | Notcurses only |
|
| Desktop terminal backend | Notcurses only |
|
||||||
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
|
| 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 Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
||||||
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
| 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) |
|
| 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=PASS` (private coordinated deployment and restart/replay validated) |
|
||||||
| Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` |
|
| Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` |
|
||||||
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_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` |
|
| 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
|
is implemented as the fourth Sessions subtab; Analyse and Exercises remain
|
||||||
explicit placeholders, and no standalone Programs route exists.
|
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,
|
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
|
received preparations and permanent withdrawal results separately from AI
|
||||||
proposals and the active singleton. Pending deliveries are cancelled by an
|
proposals and the active singleton. Pending deliveries are cancelled by an
|
||||||
exact V2 withdrawal; started executions are preserved. An exact correlated ACK
|
exact V2 withdrawal; started executions are preserved. An exact correlated ACK
|
||||||
|
|
@ -254,12 +255,36 @@ preparations and is propagated by `trainlog-ai-session-drafts` V2; inactive
|
||||||
execution drafts and completed sessions use revision-guarded causal deletion.
|
execution drafts and completed sessions use revision-guarded causal deletion.
|
||||||
Active or concurrently changed drafts conflict instead of being erased.
|
Active or concurrently changed drafts conflict instead of being erased.
|
||||||
|
|
||||||
Program V1 is desktop-owned planning data in schema v25. The Sessions Programs
|
Program V1 is desktop-owned planning data in schema v26. The responsive
|
||||||
subtab lists, searches, filters, deep-links and archives programs. Local JSON
|
Sessions Programs subtab provides cards and timeline detail backed by real
|
||||||
import is strict and bounded, previews through Core before committing, and has
|
imported definitions, sessions, usage, and provenance; it keeps strict bounded
|
||||||
deterministic replay/conflict behavior. One explicit program-session action
|
preview/commit import and deterministic replay/conflict behavior. A discrete,
|
||||||
creates a provenance-bearing manual preparation without Android delivery or
|
accessible trash action uses confirmation, focus restoration, and Escape-safe
|
||||||
performed work.
|
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 private Programs rollout migrated desktop schema v25 to v26 and Android
|
||||||
|
schema v23 to v24 after verified backups, then installed the signed
|
||||||
|
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
|
||||||
|
real 24-session Program remained unique and readable on Web and Android. A
|
||||||
|
clearly disposable Program was imported, synchronized, cancelled once without
|
||||||
|
mutation, then logically deleted through the Web confirmation. Android consumed
|
||||||
|
the real tombstone and desktop recorded its correlated generation ACK. A first
|
||||||
|
post-restart attempt failed before generation with `device_unavailable` and
|
||||||
|
`PTP_ERROR_IO`; no success state was recorded. Releasing the stale bounded MTP
|
||||||
|
worker and restarting the local ADB server restored the same physical peer. The
|
||||||
|
subsequent correlated exchange completed in both directions, and store reopen
|
||||||
|
confirmed one durable Android tombstone, one acknowledged desktop deletion,
|
||||||
|
no duplicate Program, and no resurrection.
|
||||||
|
|
||||||
The controlled deployment preserved the installed Android signing identity and
|
The controlled deployment preserved the installed Android signing identity and
|
||||||
advanced its private `versionCode` from 13 to 14 without uninstalling or
|
advanced its private `versionCode` from 13 to 14 without uninstalling or
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
# Desktop database
|
# 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: Web deletion requests and Program V1
|
||||||
|
|
||||||
Schema v25 adds durable Program V1 definitions, their ordered session and
|
Schema v25 adds durable Program V1 definitions, their ordered session and
|
||||||
|
|
@ -51,8 +65,8 @@ is a later, separately versioned migration.
|
||||||
## 1. Status
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION=25
|
TRAINLOG_DATABASE_SCHEMA_VERSION=26
|
||||||
DATABASE_SCHEMA_V25=IMPLEMENTED
|
DATABASE_SCHEMA_V26=IMPLEMENTED
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -71,7 +85,7 @@ PRAGMA user_version;
|
||||||
Current value:
|
Current value:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
25
|
26
|
||||||
```
|
```
|
||||||
|
|
||||||
The independent actual-set loads documented in the current desktop, Android
|
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
|
reusable session template, AI proposal, manual preparation, execution draft or
|
||||||
completed session.
|
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
|
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
|
payload is an explicit conflict. A different ID may reuse a title because titles
|
||||||
have no identity semantics.
|
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
|
program/session provenance on a new `sp_<uuid-v4>` manual preparation. It does
|
||||||
not create a delivery, Android object, execution draft, completed session or
|
not create a delivery, Android object, execution draft, completed session or
|
||||||
performed measurement.
|
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 |
128
docs/reviews/programs_presentation_android_delete_v1_evidence.md
Normal file
128
docs/reviews/programs_presentation_android_delete_v1_evidence.md
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
# 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, validated, and deployed on the authorized private
|
||||||
|
desktop/Android pair. `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`.
|
||||||
|
This is not a public release and does not change a FROZEN exchange contract.
|
||||||
|
|
||||||
|
## 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: **89/89**.
|
||||||
|
- Focused ASan/UBSan suite: **3/3**.
|
||||||
|
- Web Vitest: **94** tests.
|
||||||
|
- Web production build completed.
|
||||||
|
- Android unit suite: **230** 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).
|
||||||
|
|
||||||
|
### Pre-switch migration and exchange gates
|
||||||
|
|
||||||
|
`schema_v26_migration` deterministically migrates a populated v25 fixture with
|
||||||
|
24 sessions, 48 entries, preparation provenance, and historical evidence. It
|
||||||
|
asserts the exact typed post-migration projections rather than only table or
|
||||||
|
column presence.
|
||||||
|
|
||||||
|
The integrated Android `SyncGenerationServiceTest` executes the real production
|
||||||
|
chain from a live Program through capture and publication, Android consumption,
|
||||||
|
Program deletion and tombstone publication, the real correlated ACK, desktop
|
||||||
|
acceptance and reopen. An exact old generation is idempotently recognized and
|
||||||
|
ACKed while the durable tombstone prevents Program resurrection; a second
|
||||||
|
exchange adds no duplicate data.
|
||||||
|
|
||||||
|
### Private real-copy v25 to v26 migration evidence
|
||||||
|
|
||||||
|
A private real-copy migration gate preserved user-data confidentiality while
|
||||||
|
validating the source backup at v25 and its post-migration v26 result. The
|
||||||
|
post-migration integrity check passed, foreign-key violations numbered **0**,
|
||||||
|
and the business-data SHA-256 was identical before and after migration:
|
||||||
|
`b264dd1b5fd7cdc599b2f6e2a20c01af794949e7871b8e3107ff3b29a06c6e11`.
|
||||||
|
|
||||||
|
The Program hash was
|
||||||
|
`1b14ba7d8dbfbd3d3b19b6584e399fce3f161e1aa20591ca21676530dc0ebd43`;
|
||||||
|
the 24 ordered sessions hash was
|
||||||
|
`593f91572cccb109f76175be249afad5b0e311c429b56ef0cee141a5deb7f3a4`;
|
||||||
|
the 164 entries hash was
|
||||||
|
`9ca6742dd168777b7653a857bc695dbf1f59f10abf489bfaae24660b69fdb1cc`;
|
||||||
|
and the two preparations hash was
|
||||||
|
`2e2733b2624248210c390e5d592fe03e4130775ddb24e7fdb93280b4f778ec25`.
|
||||||
|
The copy contained no `deleted_at` values and no
|
||||||
|
`program_deletions` rows. All 45 unchanged business tables matched their
|
||||||
|
canonical field-level hashes.
|
||||||
|
|
||||||
|
## Private deployment validation
|
||||||
|
|
||||||
|
The authorized rollout used source commit
|
||||||
|
`f431b6ec9cab68e0d41d04815ba41bac55787485`. The coordinated desktop archive
|
||||||
|
SHA-256 was
|
||||||
|
`072fb61c8ddc86ebd9fed650c3928ddfd07010823358e0cd6cfefb3de11d4597`;
|
||||||
|
the private release APK SHA-256 was
|
||||||
|
`bbf3fa5b3ddce3d83b13d22170c23ae0196991b31b70466f85fa124ed72fb297`.
|
||||||
|
The installed `com.labfytools.trainlog` remained non-debuggable and retained
|
||||||
|
certificate SHA-256
|
||||||
|
`aa56c97f2781a0d01f007f4444c3970deb58ad8327ca3da7b0b90f37dbe2ad25`.
|
||||||
|
|
||||||
|
The real desktop database migrated from schema v25 to v26 with
|
||||||
|
`integrity_check=ok`, no foreign-key violations, and unchanged field-level
|
||||||
|
Program data. The real Program retained its stable identity, title, active
|
||||||
|
state, 24 ordered sessions, 164 entries, occurrences, targets, equipment,
|
||||||
|
rests, notes, and preparation provenance. Android migrated from schema v23 to
|
||||||
|
v24 without changing any of its 46 pre-existing tables.
|
||||||
|
|
||||||
|
The real Web import created only the disposable Program
|
||||||
|
`pg_dd90fbc1-8131-41f1-9d49-f516e34a17da`. Its first confirmation cancellation
|
||||||
|
made no mutation. Confirmed deletion produced revision
|
||||||
|
`pgr_5c34b25a-678e-49e5-89a2-eecebd4e361f`; Android consumed its tombstone and
|
||||||
|
desktop correlated acknowledgment with generation
|
||||||
|
`gen_5a260b7c-6377-4805-82bb-d793fc41e35f`.
|
||||||
|
|
||||||
|
After both components restarted, the first attempted control run failed before
|
||||||
|
either generation was created: `device_unavailable` followed a real
|
||||||
|
`PTP_ERROR_IO`. The failure was retained and was not rewritten as success. A
|
||||||
|
stale `trainlog-sync-once --request-only` worker owned the MTP descriptor;
|
||||||
|
stopping only `trainlog-syncd` released it. A bounded libmtp probe found the
|
||||||
|
same Samsung SM-G990B, and restarting the local ADB server restored the same
|
||||||
|
serial and peer advertisement without reconnecting, reinstalling, clearing, or
|
||||||
|
restoring either store.
|
||||||
|
|
||||||
|
The final post-restart run
|
||||||
|
`sy_2b2320a8-e182-41a5-b238-ac04f0d4e5f2` completed with desktop generation
|
||||||
|
`gen_9afcc6f9-f7a5-4eec-a24e-696182addfe8`, Android generation
|
||||||
|
`gen_0b101149-5ba8-4ba2-9912-eca8cc9d9953`, two durable `consumed` ACKs, no
|
||||||
|
missing capability, and `sessions_reconciled=0`. A final Android backup has
|
||||||
|
schema v24, `integrity_check=ok`, no foreign-key violations, one synchronized
|
||||||
|
Program with 24 sessions, and exactly one tombstone for the disposable Program.
|
||||||
|
Its archive SHA-256 is
|
||||||
|
`cad5830062578984e77f7900246701b5e1a6bc7629054d012ef34c7cd5f36669`.
|
||||||
|
Desktop has schema v26, one acknowledged deletion ledger row, no visible
|
||||||
|
disposable Program, and one unchanged real Program. Closing and reopening the
|
||||||
|
services and Android application preserved those results. Both Trainlog user
|
||||||
|
services were active at closeout.
|
||||||
|
|
@ -24,7 +24,7 @@ distinction between measured values and estimates.
|
||||||
|
|
||||||
## Current baseline
|
## 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
|
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
|
||||||
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
|
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
|
||||||
implemented. Session Generator V1 is hidden pending V2. AI session-draft
|
implemented. Session Generator V1 is hidden pending V2. AI session-draft
|
||||||
|
|
@ -59,7 +59,10 @@ explicit trusted opt-in. The authorized private direct-MTP rollout passed on
|
||||||
real hardware; automatic V3 selection remains unchanged without that opt-in.
|
real hardware; automatic V3 selection remains unchanged without that opt-in.
|
||||||
The next separately contracted Web surface is Exercises. Sessions deletion and
|
The next separately contracted Web surface is Exercises. Sessions deletion and
|
||||||
its Programs subtab are complete in software and are not reopened by that
|
its Programs subtab are complete in software and are not reopened by that
|
||||||
future contract.
|
future contract. `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` after
|
||||||
|
its coordinated private desktop/Android deployment, correlated deletion ACK,
|
||||||
|
and post-restart non-resurrection validation. It must not be reopened by the
|
||||||
|
Exercises contract.
|
||||||
|
|
||||||
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
|
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
|
||||||
defines the complete-sync target and its bounded dependency order.
|
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
|
## 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
|
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
||||||
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
||||||
silently added to a frozen artifact: stable exercise IDs and canonical names
|
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-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
|
||||||
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` 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 | `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 |
|
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||||
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
|
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
|
||||||
|
|
||||||
No SQLite file is transferred.
|
No SQLite file is transferred.
|
||||||
|
|
||||||
|
## 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
|
`trainlog-exercise-aliases` v1 is the separate EXERCISE_MERGE_V1 identity
|
||||||
companion. Its root contains exactly `format`, `version`, and `aliases`; every
|
companion. Its root contains exactly `format`, `version`, and `aliases`; every
|
||||||
entry contains exactly `source_exercise_id` and `canonical_exercise_id` using
|
entry contains exactly `source_exercise_id` and `canonical_exercise_id` using
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,30 @@ withdrawal without the proposal and that Android's tombstone rejects later V1
|
||||||
replay.
|
replay.
|
||||||
|
|
||||||
`web_programs` exercises validation preview without writes, transactional
|
`web_programs` exercises validation preview without writes, transactional
|
||||||
import, exact replay, detail serialization, archive replay, preparation
|
import, exact replay, responsive detail serialization, archive replay,
|
||||||
creation, provenance and absence of delivery. Schema migration coverage checks
|
revision-guarded terminal deletion, response replay, preparation creation,
|
||||||
all v25 tables and columns. Programs Vitest covers preview before the only
|
usage/provenance and absence of delivery. Schema migration coverage checks all
|
||||||
importing mutation and explicit preparation creation/navigation. The full HTTP,
|
v26 tables and columns. `schema_v26_migration` additionally migrates a
|
||||||
|
deterministically populated v25 fixture containing 24 sessions and 48 entries,
|
||||||
|
with preparation provenance and history, and verifies exact typed projections
|
||||||
|
after migration. 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,
|
pagination, strict-parser and Core suites retain malformed, duplicate-key,
|
||||||
bounds, catalog/profile/equipment, conflict and rollback coverage.
|
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. The integrated `SyncGenerationServiceTest` invokes the
|
||||||
|
real production chain from a live Program through capture, publication, Android
|
||||||
|
consumption, deletion/tombstone publication, real ACK, desktop acceptance and
|
||||||
|
reopen; an exact old generation is idempotently recognized and ACKed while the
|
||||||
|
durable tombstone prevents Program resurrection, and a second exchange creates
|
||||||
|
no duplication. 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
|
`test_web_sessions_browser.py` runs headless Firefox against the embedded
|
||||||
production assets and C HTTP server with private temporary XDG roots. It creates
|
production assets and C HTTP server with private temporary XDG roots. It creates
|
||||||
only a synthetic ready preparation and delivery, verifies French then persisted
|
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
|
operation/state boundary. Active or stale drafts conflict, and completed-session
|
||||||
deletion records a finalization so older snapshots cannot recreate the 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
|
same strict Core validator in preview mode, then commits only after explicit
|
||||||
confirmation. Exact content replay is idempotent; same-ID divergent content is
|
confirmation. Exact content replay is idempotent; same-ID divergent content is
|
||||||
a conflict. Archiving preserves definitions and derived preparations. Creating
|
a conflict. Archiving preserves definitions and derived preparations. Creating
|
||||||
a preparation from one program session copies targets and provenance into a
|
a preparation from one active program session copies targets and provenance
|
||||||
new manual preparation but creates no delivery, execution or performed data.
|
into a new manual preparation but creates no delivery, execution or performed
|
||||||
See [Program format V1](program_format_v1.md).
|
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
|
This V1 does not provide live Web capture, historical correction, automatic
|
||||||
training generation, Analysis or the standalone Exercises route.
|
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 json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
|
||||||
|
import sync_generation_exchange as generation
|
||||||
|
|
||||||
|
|
||||||
class DeploymentToolsTest(unittest.TestCase):
|
class DeploymentToolsTest(unittest.TestCase):
|
||||||
|
|
@ -62,7 +66,9 @@ class DeploymentToolsTest(unittest.TestCase):
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||||
self.assertEqual(inventory["product_version"], "0.1.2")
|
self.assertEqual(inventory["product_version"], "0.1.2")
|
||||||
|
self.assertEqual(inventory["desktop_schema"], 26)
|
||||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
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").is_file())
|
||||||
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
||||||
self.assertTrue((output / "bin/trainlog-syncd").is_file())
|
self.assertTrue((output / "bin/trainlog-syncd").is_file())
|
||||||
|
|
@ -91,6 +97,16 @@ class DeploymentToolsTest(unittest.TestCase):
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
self.assertEqual(imported.returncode, 0, imported.stderr)
|
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(
|
self.assertTrue(
|
||||||
any(
|
any(
|
||||||
row["path"] == "tools/trainlog_syncd.py"
|
row["path"] == "tools/trainlog_syncd.py"
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,252 @@ EVIDENCE = ROOT / "docs" / "reviews" / "evidence"
|
||||||
|
|
||||||
|
|
||||||
class BrowserSessionsTest(unittest.TestCase):
|
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):
|
def test_create_preferences_reload_and_withdraw_by_real_click(self):
|
||||||
if not GECKO.is_file():
|
if not GECKO.is_file():
|
||||||
self.skipTest("pinned geckodriver unavailable")
|
self.skipTest("pinned geckodriver unavailable")
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def digest(value) -> str:
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
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}")
|
raise CausalError(f"desktop schema v20-v25 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ def causal_payload(draft):
|
||||||
|
|
||||||
def require_schema(connection):
|
def require_schema(connection):
|
||||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
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}")
|
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
|
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 = (
|
MEASUREMENT_FIELDS = (
|
||||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_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."""
|
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||||
raise ValueError("schema desktop v15-v18 requis")
|
raise ValueError("schema desktop v15-v26 requis")
|
||||||
|
|
||||||
root = {
|
root = {
|
||||||
"format": "TRAINLOG_AI_EXPORT",
|
"format": "TRAINLOG_AI_EXPORT",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ ID_PREFIX = "aid_"
|
||||||
def ensure_publication_state(connection):
|
def ensure_publication_state(connection):
|
||||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||||
owned_transaction = not connection.in_transaction
|
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:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v18-v25 requis")
|
raise ValueError("schema desktop v18-v25 requis")
|
||||||
columns = {row[1] for row in connection.execute(
|
columns = {row[1] for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
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:
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v8 à v16 requis")
|
raise ValueError("schema desktop v8 à v16 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
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:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v8 à v16 requis")
|
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():
|
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)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
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")
|
raise ValueError("schema desktop v12-v25 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"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 = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
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:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v11-v16 requis")
|
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():
|
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
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
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}")
|
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||||
rows = con.execute("""
|
rows = con.execute("""
|
||||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
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
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# 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(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
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")
|
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
|
legacy_v2_schema = args.version == 2 and schema_version == 10
|
||||||
if schema_version not in supported_versions and not legacy_v2_schema:
|
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)")
|
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:
|
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:
|
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 = []
|
deliveries = []
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
"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()
|
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)
|
db=connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = range(15, 26)
|
supported_versions = range(15, 27)
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v15-v25 requis")
|
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")
|
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;
|
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||||
any failure leaves no draft, child, or import-identity row behind.
|
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:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ImportFailure("schema desktop v18-v25 requis")
|
raise ImportFailure("schema desktop v18-v25 requis")
|
||||||
draft, entries = validate(payload, connection)
|
draft, entries = validate(payload, connection)
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ def main():
|
||||||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = range(8, 26)
|
supported_versions = range(8, 27)
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v8 à v21 requis")
|
fail("schema desktop v8 à v21 requis")
|
||||||
proof_path = args.mobile_export
|
proof_path = args.mobile_export
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ def validate(payload, reserved):
|
||||||
|
|
||||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||||
"""Apply validated definitions without owning the surrounding transaction."""
|
"""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:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v8 à v21 requis")
|
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():
|
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):
|
def apply_aliases(con, aliases):
|
||||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
"""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:
|
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v12-v21 requis")
|
fail("schema desktop v12-v21 requis")
|
||||||
for source, canonical in aliases:
|
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):
|
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||||
"""Apply validated zone rows without owning transaction boundaries."""
|
"""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:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ImportFailure("schema desktop v11-v25 requis")
|
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")
|
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")
|
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
|
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
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")
|
fail("schema desktop v17-v25 requis")
|
||||||
applied=equal=ancestor=pending=0;seen=set()
|
applied=equal=ancestor=pending=0;seen=set()
|
||||||
for item in root["exercises"]:
|
for item in root["exercises"]:
|
||||||
|
|
|
||||||
|
|
@ -846,7 +846,7 @@ def require_supported_schema(connection):
|
||||||
|
|
||||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# 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(
|
raise ImportFailure(
|
||||||
f"base desktop schema v5 à v25 attendue, version trouvée: {version}"
|
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)
|
require_supported_schema(connection)
|
||||||
|
|
||||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
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")
|
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v25")
|
||||||
if schema_version >= 20:
|
if schema_version >= 20:
|
||||||
protected = {(row[0], row[1]) for row in connection.execute(
|
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):
|
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
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:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v15-v25 requis")
|
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")
|
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_training_feedback.py",
|
||||||
"export_ai_session_drafts.py",
|
"export_ai_session_drafts.py",
|
||||||
"export_session_preparations.py",
|
"export_session_preparations.py",
|
||||||
|
"export_programs.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -96,8 +97,8 @@ def main() -> int:
|
||||||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||||
).strip(),
|
).strip(),
|
||||||
"product_version": "0.1.2",
|
"product_version": "0.1.2",
|
||||||
"desktop_schema": 25,
|
"desktop_schema": 26,
|
||||||
"android_schema": 23,
|
"android_schema": 24,
|
||||||
"protocols": [
|
"protocols": [
|
||||||
"mobile-export-v3",
|
"mobile-export-v3",
|
||||||
"mobile-history-v4",
|
"mobile-history-v4",
|
||||||
|
|
@ -108,6 +109,7 @@ def main() -> int:
|
||||||
"generation-archive-v1",
|
"generation-archive-v1",
|
||||||
"session-preparations-v2",
|
"session-preparations-v2",
|
||||||
"ai-session-drafts-v2",
|
"ai-session-drafts-v2",
|
||||||
|
"programs-v1",
|
||||||
],
|
],
|
||||||
"entry_points": [
|
"entry_points": [
|
||||||
"bin/trainlog",
|
"bin/trainlog",
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ ARTIFACTS = (
|
||||||
"export_ai_session_drafts.py", ()),
|
"export_ai_session_drafts.py", ()),
|
||||||
("session-preparations", "trainlog-session-preparations", 2,
|
("session-preparations", "trainlog-session-preparations", 2,
|
||||||
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
"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}
|
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:
|
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:
|
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:
|
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()
|
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
|
||||||
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
|
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
|
||||||
(operation["operation_id"], generation, 0 if existed else 1))
|
(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()
|
db.commit()
|
||||||
return stage, manifest, checksum
|
return stage, manifest, checksum
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -699,6 +712,12 @@ def accept_ack(database: Path, path: Path) -> str:
|
||||||
"WHERE generation_id=? AND acknowledged_at IS NULL",
|
"WHERE generation_id=? AND acknowledged_at IS NULL",
|
||||||
(ack["consumed_at"], ack["generation_id"]),
|
(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(
|
db.execute(
|
||||||
"UPDATE session_preparations SET delivery_state='acknowledged' WHERE EXISTS("
|
"UPDATE session_preparations SET delivery_state='acknowledged' WHERE EXISTS("
|
||||||
"SELECT 1 FROM session_preparation_deliveries d WHERE "
|
"SELECT 1 FROM session_preparation_deliveries d WHERE "
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 25
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 26
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,16 @@ TrainlogStatus trainlog_web_programs_archive_json(TrainlogDatabase *database,
|
||||||
char **output_json,
|
char **output_json,
|
||||||
size_t *output_size);
|
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
|
/* Creates a separate draft preparation and never changes or delivers the
|
||||||
* source program. request_id is forwarded to the preparation service. */
|
* source program. request_id is forwarded to the preparation service. */
|
||||||
TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
||||||
|
|
|
||||||
|
|
@ -747,6 +747,15 @@ test_schema_v9_migration = executable(
|
||||||
|
|
||||||
test('schema_v9_migration', test_schema_v9_migration)
|
test('schema_v9_migration', test_schema_v9_migration)
|
||||||
|
|
||||||
|
test_schema_v26_migration = executable(
|
||||||
|
'test_schema_v26_migration',
|
||||||
|
'tests/test_schema_v26_migration.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
test('schema_v26_migration', test_schema_v26_migration)
|
||||||
|
|
||||||
test_schema_v7_migration = executable(
|
test_schema_v7_migration = executable(
|
||||||
'test_schema_v7_migration',
|
'test_schema_v7_migration',
|
||||||
'tests/test_schema_v7_migration.c',
|
'tests/test_schema_v7_migration.c',
|
||||||
|
|
|
||||||
|
|
@ -1214,6 +1214,22 @@ static const char *const MIGRATE_V24_TO_V25_SQL =
|
||||||
"UNIQUE(resource_kind,target_id));"
|
"UNIQUE(resource_kind,target_id));"
|
||||||
"PRAGMA user_version=25;COMMIT;";
|
"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
|
/* CONTRACT: migration fixtures may retain additive v25 columns while
|
||||||
* deliberately lowering user_version. Add each provenance column only when
|
* deliberately lowering user_version. Add each provenance column only when
|
||||||
* absent; a genuine v24 database still receives both before opening returns. */
|
* 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;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
|
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
|
||||||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
|
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;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
|
|
@ -2135,6 +2152,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = ensure_v25_web_deletion_requests(database);
|
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) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = ensure_v18_ai_draft_publication_state(database);
|
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) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
set_open_diagnostic(output_diagnostic,
|
set_open_diagnostic(output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
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,
|
database->connection,
|
||||||
SQLITE_ERROR);
|
SQLITE_ERROR);
|
||||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
(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)) &&
|
document, item, "session_count", sqlite3_column_int64(statement, 5)) &&
|
||||||
add_column_text(document, item, "provenance", statement, 6) &&
|
add_column_text(document, item, "provenance", statement, 6) &&
|
||||||
add_column_text(document, item, "updated_at", statement, 7) &&
|
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);
|
yyjson_mut_arr_add_val(items, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,9 +216,14 @@ TrainlogStatus trainlog_web_programs_list_json(TrainlogDatabase *database,
|
||||||
char **output_json,
|
char **output_json,
|
||||||
size_t *output_size) {
|
size_t *output_size) {
|
||||||
static const char SQL[] =
|
static const char SQL[] =
|
||||||
"SELECT p.program_id,p.title,p.state,p.start_date,p.end_date,COUNT(s.program_session_id),"
|
"SELECT p.program_id,p.title,p.state,p.start_date,p.end_date,"
|
||||||
"p.source_format,p.updated_at FROM programs p LEFT JOIN program_sessions s ON "
|
"COUNT(DISTINCT s.program_session_id),p.source_format,p.updated_at,p.created_at,"
|
||||||
"s.program_id=p.program_id WHERE (?1='' OR p.title LIKE '%'||?1||'%' COLLATE NOCASE) "
|
"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' "
|
"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";
|
"THEN 0 ELSE 1 END,p.title COLLATE NOCASE,p.program_id LIMIT ?3 OFFSET ?4";
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
|
|
@ -419,7 +432,7 @@ TrainlogStatus trainlog_web_programs_detail_json(TrainlogDatabase *database,
|
||||||
if (sqlite3_prepare_v2(
|
if (sqlite3_prepare_v2(
|
||||||
database->connection,
|
database->connection,
|
||||||
"SELECT program_id,title,note,state,start_date,end_date,created_at,updated_at,"
|
"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",
|
"FROM programs WHERE program_id=?1",
|
||||||
-1,
|
-1,
|
||||||
&statement,
|
&statement,
|
||||||
|
|
@ -437,6 +450,11 @@ TrainlogStatus trainlog_web_programs_detail_json(TrainlogDatabase *database,
|
||||||
yyjson_mut_doc_free(document);
|
yyjson_mut_doc_free(document);
|
||||||
return TRAINLOG_STATUS_NOT_FOUND;
|
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);
|
yyjson_mut_doc_set_root(document, root);
|
||||||
(void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U);
|
(void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U);
|
||||||
for (int column = 0; column < 12; ++column) {
|
for (int column = 0; column < 12; ++column) {
|
||||||
|
|
@ -682,8 +700,10 @@ static TrainlogStatus program_insert_header(TrainlogDatabase *database,
|
||||||
const char *digest,
|
const char *digest,
|
||||||
const char *now,
|
const char *now,
|
||||||
const char *revision) {
|
const char *revision) {
|
||||||
static const char SQL[] = "INSERT INTO programs "
|
static const char SQL[] =
|
||||||
"VALUES(?1,?2,?3,?4,?5,?6,?7,?7,?8,'trainlog-program',1,?9)";
|
"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;
|
sqlite3_stmt *statement = NULL;
|
||||||
int result;
|
int result;
|
||||||
|
|
||||||
|
|
@ -1138,7 +1158,8 @@ static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
|
||||||
|
|
||||||
result = sqlite3_prepare_v2(database->connection,
|
result = sqlite3_prepare_v2(database->connection,
|
||||||
"UPDATE programs SET state='archived',updated_at=?1,revision_id=?2 "
|
"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,
|
-1,
|
||||||
&statement,
|
&statement,
|
||||||
NULL);
|
NULL);
|
||||||
|
|
@ -1189,7 +1210,9 @@ static TrainlogStatus program_store_archive_replay(TrainlogDatabase *database,
|
||||||
const char *now) {
|
const char *now) {
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
int result = sqlite3_prepare_v2(database->connection,
|
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,
|
-1,
|
||||||
&statement,
|
&statement,
|
||||||
NULL);
|
NULL);
|
||||||
|
|
@ -1260,6 +1283,213 @@ TrainlogStatus trainlog_web_programs_archive_json(TrainlogDatabase *database,
|
||||||
return TRAINLOG_STATUS_OK;
|
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) {
|
static yyjson_val *program_find_session(yyjson_val *root, const char *program_session_id) {
|
||||||
yyjson_val *sessions = yyjson_obj_get(root, "sessions");
|
yyjson_val *sessions = yyjson_obj_get(root, "sessions");
|
||||||
size_t index;
|
size_t index;
|
||||||
|
|
@ -1375,6 +1605,10 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
||||||
document = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
|
document = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
|
||||||
free(detail);
|
free(detail);
|
||||||
root = document == NULL ? NULL : yyjson_doc_get_root(document);
|
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);
|
session = root == NULL ? NULL : program_find_session(root, program_session_id);
|
||||||
if (session == NULL) {
|
if (session == NULL) {
|
||||||
yyjson_doc_free(document);
|
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);
|
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,
|
static enum MHD_Result handle_program_prepare(TrainlogWebContext *context,
|
||||||
struct MHD_Connection *connection,
|
struct MHD_Connection *connection,
|
||||||
const char *identity,
|
const char *identity,
|
||||||
|
|
@ -759,6 +799,7 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
||||||
TrainlogHttpRequestState *request) {
|
TrainlogHttpRequestState *request) {
|
||||||
static const char PREFIX[] = "/api/v1/sessions/program/";
|
static const char PREFIX[] = "/api/v1/sessions/program/";
|
||||||
static const char ARCHIVE_SUFFIX[] = "/archive";
|
static const char ARCHIVE_SUFFIX[] = "/archive";
|
||||||
|
static const char DELETE_SUFFIX[] = "/delete";
|
||||||
const char *identity;
|
const char *identity;
|
||||||
const char *sessions_marker;
|
const char *sessions_marker;
|
||||||
char *json = NULL;
|
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) {
|
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
|
||||||
return handle_program_archive(context, connection, identity, identity_length, method);
|
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) {
|
if (sessions_marker != NULL) {
|
||||||
return handle_program_prepare(context, connection, identity, sessions_marker, method);
|
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");
|
connection, MHD_HTTP_METHOD_NOT_ALLOWED, "{\"error\":\"method_not_allowed\"}\n", "GET");
|
||||||
}
|
}
|
||||||
status = trainlog_web_programs_detail_json(context->database, identity, &json, &json_size);
|
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);
|
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,
|
result = sqlite3_prepare_v2(database->connection,
|
||||||
"SELECT 1 FROM program_sessions s JOIN programs p ON "
|
"SELECT 1 FROM program_sessions s JOIN programs p ON "
|
||||||
"p.program_id=s.program_id WHERE p.program_id=?1 AND "
|
"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,
|
-1,
|
||||||
&statement,
|
&statement,
|
||||||
NULL);
|
NULL);
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,222 @@
|
||||||
/* Test-only producer fixture through the public desktop persistence API. */
|
/* Test-only producer fixture through the public desktop persistence API. */
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#include "trainlog/database.h"
|
#include "trainlog/database.h"
|
||||||
|
#include "trainlog/web_programs.h"
|
||||||
|
|
||||||
int main(int argc, char **argv)
|
static const char PROGRAM_JSON[] =
|
||||||
{
|
"{\"format\":\"trainlog-program\",\"version\":1,\"program\":{"
|
||||||
TrainlogDatabase *database = NULL;
|
"\"program_id\":\"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
|
||||||
TrainlogSetInput set = { .reps = 9, .has_weight = true, .weight_kg = 32.5 };
|
"\"title\":\"Generation program\",\"note\":null,\"state\":\"active\","
|
||||||
|
"\"start_date\":\"2026-09-21\",\"end_date\":null,\"sessions\":[{"
|
||||||
|
"\"program_session_id\":\"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\","
|
||||||
|
"\"title\":\"Session A\",\"session_type\":\"training\","
|
||||||
|
"\"planned_for\":null,\"note\":null,\"occurrences\":[{"
|
||||||
|
"\"entry_id\":\"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc\","
|
||||||
|
"\"exercise_id\":\"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd\","
|
||||||
|
"\"equipment_id\":null,\"load_mode\":\"external\",\"rest_seconds\":90,"
|
||||||
|
"\"target_sets\":3,\"target_reps\":8,\"target_duration_seconds\":null,"
|
||||||
|
"\"target_weight_kg\":40.0,\"notes\":null}]}]}}";
|
||||||
|
|
||||||
|
static int create_program(TrainlogDatabase *database) {
|
||||||
|
char *response = NULL;
|
||||||
|
size_t response_size = 0U;
|
||||||
|
TrainlogStatus status;
|
||||||
|
|
||||||
|
status = trainlog_database_insert_exercise_profiled(database,
|
||||||
|
"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||||
|
"Program exercise fixture",
|
||||||
|
"program exercise fixture",
|
||||||
|
TRAINLOG_TRACKING_REPS,
|
||||||
|
TRAINLOG_RECORDING_SETS,
|
||||||
|
0U);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
status = trainlog_web_programs_import_json(
|
||||||
|
database, PROGRAM_JSON, strlen(PROGRAM_JSON), true, &response, &response_size);
|
||||||
|
free(response);
|
||||||
|
response = NULL;
|
||||||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
trainlog_web_programs_detail_json(
|
||||||
|
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) !=
|
||||||
|
TRAINLOG_STATUS_OK) {
|
||||||
|
free(response);
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
(void)printf("PROGRAM_CREATE=%s\n", response);
|
||||||
|
free(response);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
delete_program(TrainlogDatabase *database, const char *expected_revision, const char *request_id) {
|
||||||
|
char *response = NULL;
|
||||||
|
size_t response_size = 0U;
|
||||||
|
TrainlogStatus status =
|
||||||
|
trainlog_web_programs_delete_json(database,
|
||||||
|
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
expected_revision,
|
||||||
|
request_id,
|
||||||
|
&response,
|
||||||
|
&response_size);
|
||||||
|
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
free(response);
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
(void)printf("PROGRAM_DELETE=%s\n", response);
|
||||||
|
free(response);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int create_session_fixture(TrainlogDatabase *database) {
|
||||||
|
TrainlogSetInput set = {.reps = 9, .has_weight = true, .weight_kg = 32.5};
|
||||||
TrainlogSessionExerciseInput exercise;
|
TrainlogSessionExerciseInput exercise;
|
||||||
TrainlogSessionInput session;
|
TrainlogSessionInput session;
|
||||||
|
|
||||||
if (argc != 2 || trainlog_database_open(argv[1], &database) != TRAINLOG_STATUS_OK) {
|
if (trainlog_database_insert_exercise_profiled(database,
|
||||||
return 2;
|
"ex_77777777-7777-4777-8777-777777777777",
|
||||||
}
|
"Desktop generation fixture",
|
||||||
if (trainlog_database_insert_exercise_profiled(
|
"desktop generation fixture",
|
||||||
database, "ex_77777777-7777-4777-8777-777777777777",
|
TRAINLOG_TRACKING_REPS,
|
||||||
"Desktop generation fixture", "desktop generation fixture",
|
TRAINLOG_RECORDING_SETS,
|
||||||
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) != TRAINLOG_STATUS_OK) {
|
0U) != TRAINLOG_STATUS_OK) {
|
||||||
trainlog_database_close(database); return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
(void)memset(&exercise, 0, sizeof(exercise));
|
(void)memset(&exercise, 0, sizeof(exercise));
|
||||||
(void)snprintf(exercise.entry_id, sizeof(exercise.entry_id), "%s",
|
(void)snprintf(exercise.entry_id,
|
||||||
"sxe_88888888-8888-4888-8888-888888888888");
|
sizeof(exercise.entry_id),
|
||||||
(void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s",
|
"%s",
|
||||||
"ex_77777777-7777-4777-8777-777777777777");
|
"sxe_88888888-8888-4888-8888-888888888888");
|
||||||
|
(void)snprintf(exercise.exercise_id,
|
||||||
|
sizeof(exercise.exercise_id),
|
||||||
|
"%s",
|
||||||
|
"ex_77777777-7777-4777-8777-777777777777");
|
||||||
exercise.recording_mode = TRAINLOG_RECORDING_SETS;
|
exercise.recording_mode = TRAINLOG_RECORDING_SETS;
|
||||||
exercise.tracking_mode = TRAINLOG_TRACKING_REPS;
|
exercise.tracking_mode = TRAINLOG_TRACKING_REPS;
|
||||||
exercise.sets = &set;
|
exercise.sets = &set;
|
||||||
exercise.set_count = 1U;
|
exercise.set_count = 1U;
|
||||||
(void)memset(&session, 0, sizeof(session));
|
(void)memset(&session, 0, sizeof(session));
|
||||||
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
|
(void)snprintf(session.session_id,
|
||||||
"se_99999999-9999-4999-8999-999999999999");
|
sizeof(session.session_id),
|
||||||
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
|
"%s",
|
||||||
"2026-09-17T10:00:00+02:00");
|
"se_99999999-9999-4999-8999-999999999999");
|
||||||
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s",
|
(void)snprintf(
|
||||||
"2026-09-17T10:30:00+02:00");
|
session.started_at, sizeof(session.started_at), "%s", "2026-09-17T10:00:00+02:00");
|
||||||
|
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-17T10:30:00+02:00");
|
||||||
session.exercises = &exercise;
|
session.exercises = &exercise;
|
||||||
session.exercise_count = 1U;
|
session.exercise_count = 1U;
|
||||||
if (trainlog_database_insert_session(database, &session) != TRAINLOG_STATUS_OK) {
|
if (trainlog_database_insert_session(database, &session) != TRAINLOG_STATUS_OK) {
|
||||||
trainlog_database_close(database); return 4;
|
return 4;
|
||||||
}
|
}
|
||||||
trainlog_database_close(database);
|
|
||||||
(void)puts("SYNC_GENERATION_FIXTURE=PASS");
|
(void)puts("SYNC_GENERATION_FIXTURE=PASS");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
check_integrity(sqlite3 *connection, bool *output_integrity_ok, bool *output_foreign_keys_ok) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
const unsigned char *integrity_result;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
*output_integrity_ok = false;
|
||||||
|
*output_foreign_keys_ok = false;
|
||||||
|
rc = sqlite3_prepare_v2(connection, "PRAGMA integrity_check;", -1, &statement, NULL);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
rc = sqlite3_step(statement);
|
||||||
|
integrity_result = rc == SQLITE_ROW ? sqlite3_column_text(statement, 0) : NULL;
|
||||||
|
*output_integrity_ok =
|
||||||
|
integrity_result != NULL && strcmp((const char *)integrity_result, "ok") == 0;
|
||||||
|
if (sqlite3_finalize(statement) != SQLITE_OK || !*output_integrity_ok) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = sqlite3_prepare_v2(connection, "PRAGMA foreign_key_check;", -1, &statement, NULL);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
rc = sqlite3_step(statement);
|
||||||
|
*output_foreign_keys_ok = rc == SQLITE_DONE;
|
||||||
|
if (sqlite3_finalize(statement) != SQLITE_OK || !*output_foreign_keys_ok) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int migrate_only(const char *path) {
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
char diagnostic[256];
|
||||||
|
sqlite3 *inspection = NULL;
|
||||||
|
bool integrity_ok;
|
||||||
|
bool foreign_key_check_ok;
|
||||||
|
int foreign_keys_enabled;
|
||||||
|
int schema_version;
|
||||||
|
int result = 2;
|
||||||
|
|
||||||
|
if (trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) !=
|
||||||
|
TRAINLOG_STATUS_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (trainlog_database_schema_version(database, &schema_version) != TRAINLOG_STATUS_OK ||
|
||||||
|
trainlog_database_foreign_keys_enabled(database, &foreign_keys_enabled) !=
|
||||||
|
TRAINLOG_STATUS_OK) {
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
trainlog_database_close(database);
|
||||||
|
database = NULL;
|
||||||
|
|
||||||
|
if (sqlite3_open_v2(path, &inspection, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, NULL) !=
|
||||||
|
SQLITE_OK) {
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
if (check_integrity(inspection, &integrity_ok, &foreign_key_check_ok) != 0) {
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
(void)printf(
|
||||||
|
"MIGRATE_ONLY schema_version=%d integrity=%s foreign_keys=%s foreign_key_check=%s\n",
|
||||||
|
schema_version,
|
||||||
|
integrity_ok ? "ok" : "failed",
|
||||||
|
foreign_keys_enabled != 0 ? "enabled" : "disabled",
|
||||||
|
foreign_key_check_ok ? "ok" : "failed");
|
||||||
|
result = 0;
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
if (inspection != NULL) {
|
||||||
|
(void)sqlite3_close(inspection);
|
||||||
|
}
|
||||||
|
if (database != NULL) {
|
||||||
|
trainlog_database_close(database);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
int result;
|
||||||
|
|
||||||
|
if (argc == 3 && strcmp(argv[2], "migrate-only") == 0) {
|
||||||
|
return migrate_only(argv[1]);
|
||||||
|
}
|
||||||
|
if ((argc != 2 && argc != 3 && argc != 5) ||
|
||||||
|
trainlog_database_open(argv[1], &database) != TRAINLOG_STATUS_OK) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (argc == 2) {
|
||||||
|
result = create_session_fixture(database);
|
||||||
|
} else if (argc == 3 && strcmp(argv[2], "program-create") == 0) {
|
||||||
|
result = create_program(database);
|
||||||
|
} else if (argc == 5 && strcmp(argv[2], "program-delete") == 0) {
|
||||||
|
result = delete_program(database, argv[3], argv[4]);
|
||||||
|
} else {
|
||||||
|
result = 2;
|
||||||
|
}
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ static int scalar(sqlite3 *database, const char *sql) {
|
||||||
int main(void) {
|
int main(void) {
|
||||||
static const char DOWNGRADE_TO_V20[] = "PRAGMA foreign_keys=OFF;"
|
static const char DOWNGRADE_TO_V20[] = "PRAGMA foreign_keys=OFF;"
|
||||||
"DROP TABLE web_session_deletion_requests;"
|
"DROP TABLE web_session_deletion_requests;"
|
||||||
|
"DROP TABLE program_deletions;"
|
||||||
"DROP TABLE program_requests;"
|
"DROP TABLE program_requests;"
|
||||||
"DROP TABLE program_session_entries;"
|
"DROP TABLE program_session_entries;"
|
||||||
"DROP TABLE program_sessions;"
|
"DROP TABLE program_sessions;"
|
||||||
|
|
@ -53,6 +54,7 @@ int main(void) {
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
sqlite3 *raw = NULL;
|
sqlite3 *raw = NULL;
|
||||||
size_t exercises = 0U;
|
size_t exercises = 0U;
|
||||||
|
char diagnostic[256];
|
||||||
|
|
||||||
CHECK(file_descriptor >= 0);
|
CHECK(file_descriptor >= 0);
|
||||||
CHECK(close(file_descriptor) == 0);
|
CHECK(close(file_descriptor) == 0);
|
||||||
|
|
@ -70,7 +72,11 @@ int main(void) {
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw, DOWNGRADE_TO_V20, NULL, NULL, NULL) == SQLITE_OK);
|
CHECK(sqlite3_exec(raw, DOWNGRADE_TO_V20, NULL, NULL, NULL) == SQLITE_OK);
|
||||||
CHECK(sqlite3_close(raw) == 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(trainlog_database_exercise_count(database, &exercises) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(exercises > 0U);
|
CHECK(exercises > 0U);
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
|
|
|
||||||
488
tui/tests/test_schema_v26_migration.c
Normal file
488
tui/tests/test_schema_v26_migration.c
Normal file
|
|
@ -0,0 +1,488 @@
|
||||||
|
/**
|
||||||
|
* @file test_schema_v26_migration.c
|
||||||
|
* @brief Lossless populated v25 to v26 migration evidence.
|
||||||
|
*/
|
||||||
|
#define _POSIX_C_SOURCE 200809L
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include "trainlog/database.h"
|
||||||
|
|
||||||
|
#define CHECK(expression) \
|
||||||
|
do { \
|
||||||
|
if (!(expression)) { \
|
||||||
|
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
|
||||||
|
goto cleanup; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
typedef struct Projection {
|
||||||
|
char *text;
|
||||||
|
size_t length;
|
||||||
|
size_t capacity;
|
||||||
|
} Projection;
|
||||||
|
|
||||||
|
typedef struct ProjectionSpec {
|
||||||
|
const char *label;
|
||||||
|
const char *sql;
|
||||||
|
} ProjectionSpec;
|
||||||
|
|
||||||
|
static bool projection_append(Projection *projection, const char *value, size_t length) {
|
||||||
|
size_t required;
|
||||||
|
size_t capacity;
|
||||||
|
char *resized;
|
||||||
|
|
||||||
|
if (length > SIZE_MAX - projection->length - 1U) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
required = projection->length + length + 1U;
|
||||||
|
if (required > projection->capacity) {
|
||||||
|
capacity = projection->capacity == 0U ? 4096U : projection->capacity;
|
||||||
|
while (capacity < required) {
|
||||||
|
if (capacity > SIZE_MAX / 2U) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
capacity *= 2U;
|
||||||
|
}
|
||||||
|
resized = realloc(projection->text, capacity);
|
||||||
|
if (resized == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
projection->text = resized;
|
||||||
|
projection->capacity = capacity;
|
||||||
|
}
|
||||||
|
(void)memcpy(projection->text + projection->length, value, length);
|
||||||
|
projection->length += length;
|
||||||
|
projection->text[projection->length] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool projection_append_value(Projection *projection, sqlite3_stmt *statement, int column) {
|
||||||
|
char number[128];
|
||||||
|
const unsigned char *text;
|
||||||
|
int length;
|
||||||
|
int written;
|
||||||
|
|
||||||
|
switch (sqlite3_column_type(statement, column)) {
|
||||||
|
case SQLITE_NULL:
|
||||||
|
return projection_append(projection, "N;", 2U);
|
||||||
|
case SQLITE_INTEGER:
|
||||||
|
written = snprintf(
|
||||||
|
number, sizeof(number), "I%lld;", (long long)sqlite3_column_int64(statement, column));
|
||||||
|
return written > 0 && (size_t)written < sizeof(number) &&
|
||||||
|
projection_append(projection, number, (size_t)written);
|
||||||
|
case SQLITE_FLOAT:
|
||||||
|
written =
|
||||||
|
snprintf(number, sizeof(number), "F%.17g;", sqlite3_column_double(statement, column));
|
||||||
|
return written > 0 && (size_t)written < sizeof(number) &&
|
||||||
|
projection_append(projection, number, (size_t)written);
|
||||||
|
case SQLITE_TEXT:
|
||||||
|
text = sqlite3_column_text(statement, column);
|
||||||
|
length = sqlite3_column_bytes(statement, column);
|
||||||
|
written = snprintf(number, sizeof(number), "T%d:", length);
|
||||||
|
return text != NULL && length >= 0 && written > 0 && (size_t)written < sizeof(number) &&
|
||||||
|
projection_append(projection, number, (size_t)written) &&
|
||||||
|
projection_append(projection, (const char *)text, (size_t)length) &&
|
||||||
|
projection_append(projection, ";", 1U);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WHY: row counts cannot prove that nullable targets, floating-point loads, or
|
||||||
|
* ordered identities survived. CONTRACT: this helper records SQLite types,
|
||||||
|
* byte lengths, values, columns, and row boundaries for an explicitly ordered
|
||||||
|
* business query. INVARIANT: equal projections imply field-for-field equality. */
|
||||||
|
static bool capture_projection(sqlite3 *database,
|
||||||
|
const ProjectionSpec *specifications,
|
||||||
|
size_t specification_count,
|
||||||
|
Projection *projection) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
size_t index;
|
||||||
|
int column;
|
||||||
|
int result;
|
||||||
|
|
||||||
|
for (index = 0U; index < specification_count; ++index) {
|
||||||
|
if (!projection_append(
|
||||||
|
projection, specifications[index].label, strlen(specifications[index].label)) ||
|
||||||
|
!projection_append(projection, "\n", 1U) ||
|
||||||
|
sqlite3_prepare_v2(database, specifications[index].sql, -1, &statement, NULL) !=
|
||||||
|
SQLITE_OK) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
while ((result = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||||
|
for (column = 0; column < sqlite3_column_count(statement); ++column) {
|
||||||
|
if (!projection_append_value(projection, statement, column)) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!projection_append(projection, "\n", 1U)) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
statement = NULL;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
statement = NULL;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool execute(sqlite3 *database, const char *sql) {
|
||||||
|
char *error = NULL;
|
||||||
|
int result = sqlite3_exec(database, sql, NULL, NULL, &error);
|
||||||
|
|
||||||
|
if (result != SQLITE_OK) {
|
||||||
|
(void)fprintf(stderr, "fixture SQL failed: %s\n", error == NULL ? "no diagnostic" : error);
|
||||||
|
}
|
||||||
|
sqlite3_free(error);
|
||||||
|
return result == SQLITE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int scalar(sqlite3 *database, const char *sql) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int value = -1;
|
||||||
|
|
||||||
|
if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK &&
|
||||||
|
sqlite3_step(statement) == SQLITE_ROW) {
|
||||||
|
value = sqlite3_column_int(statement, 0);
|
||||||
|
}
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool insert_program_sessions(sqlite3 *database) {
|
||||||
|
static const char INSERT_SESSION[] =
|
||||||
|
"INSERT INTO program_sessions(program_session_id,program_id,position,title,session_type,"
|
||||||
|
"planned_for,note) VALUES(?1,'pg_fixture',?2,?3,?4,?5,?6)";
|
||||||
|
static const char INSERT_ENTRY[] =
|
||||||
|
"INSERT INTO program_session_entries(program_session_id,entry_id,position,exercise_id,"
|
||||||
|
"equipment_id,recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,target_sets,"
|
||||||
|
"target_reps,target_duration_seconds,target_weight_kg,notes)"
|
||||||
|
" VALUES(?1,?2,?3,?4,?5,'sets',?6,?7,?8,?9,?10,?11,?12,?13,?14)";
|
||||||
|
sqlite3_stmt *session = NULL;
|
||||||
|
sqlite3_stmt *entry = NULL;
|
||||||
|
char session_id[32];
|
||||||
|
char entry_id[32];
|
||||||
|
char title[64];
|
||||||
|
char planned_for[32];
|
||||||
|
char note[96];
|
||||||
|
int position;
|
||||||
|
int occurrence;
|
||||||
|
bool ok = false;
|
||||||
|
|
||||||
|
if (sqlite3_prepare_v2(database, INSERT_SESSION, -1, &session, NULL) != SQLITE_OK ||
|
||||||
|
sqlite3_prepare_v2(database, INSERT_ENTRY, -1, &entry, NULL) != SQLITE_OK) {
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
for (position = 0; position < 24; ++position) {
|
||||||
|
(void)snprintf(session_id, sizeof(session_id), "pgs_fixture_%02d", position + 1);
|
||||||
|
(void)snprintf(
|
||||||
|
title, sizeof(title), "Week %d · Session %d", position / 3 + 1, position + 1);
|
||||||
|
(void)snprintf(planned_for, sizeof(planned_for), "2026-10-%02dT18:00:00Z", position + 1);
|
||||||
|
(void)snprintf(
|
||||||
|
note, sizeof(note), "Progression block %d, session %d", position / 6 + 1, position + 1);
|
||||||
|
(void)sqlite3_bind_text(session, 1, session_id, -1, SQLITE_TRANSIENT);
|
||||||
|
(void)sqlite3_bind_int(session, 2, position);
|
||||||
|
(void)sqlite3_bind_text(session, 3, title, -1, SQLITE_TRANSIENT);
|
||||||
|
(void)sqlite3_bind_text(
|
||||||
|
session, 4, position == 23 ? "max_test" : "training", -1, SQLITE_STATIC);
|
||||||
|
(void)sqlite3_bind_text(session, 5, planned_for, -1, SQLITE_TRANSIENT);
|
||||||
|
(void)sqlite3_bind_text(session, 6, note, -1, SQLITE_TRANSIENT);
|
||||||
|
if (sqlite3_step(session) != SQLITE_DONE || sqlite3_reset(session) != SQLITE_OK ||
|
||||||
|
sqlite3_clear_bindings(session) != SQLITE_OK) {
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
for (occurrence = 0; occurrence < 2; ++occurrence) {
|
||||||
|
(void)snprintf(entry_id, sizeof(entry_id), "pge_%02d_%d", position + 1, occurrence + 1);
|
||||||
|
(void)snprintf(note,
|
||||||
|
sizeof(note),
|
||||||
|
occurrence == 0 ? "Controlled eccentric, RPE %d" : "Timed brace, RPE %d",
|
||||||
|
7 + position % 3);
|
||||||
|
(void)sqlite3_bind_text(entry, 1, session_id, -1, SQLITE_TRANSIENT);
|
||||||
|
(void)sqlite3_bind_text(entry, 2, entry_id, -1, SQLITE_TRANSIENT);
|
||||||
|
(void)sqlite3_bind_int(entry, 3, occurrence);
|
||||||
|
(void)sqlite3_bind_text(entry,
|
||||||
|
4,
|
||||||
|
occurrence == 0 ? "ex_fixture_press" : "ex_fixture_hold",
|
||||||
|
-1,
|
||||||
|
SQLITE_STATIC);
|
||||||
|
(void)sqlite3_bind_text(entry,
|
||||||
|
5,
|
||||||
|
occurrence == 0 ? "eq_fixture_stack" : "eq_fixture_mat",
|
||||||
|
-1,
|
||||||
|
SQLITE_STATIC);
|
||||||
|
(void)sqlite3_bind_text(
|
||||||
|
entry, 6, occurrence == 0 ? "reps" : "duration", -1, SQLITE_STATIC);
|
||||||
|
(void)sqlite3_bind_int(entry, 7, occurrence == 0 ? 1 : 2);
|
||||||
|
(void)sqlite3_bind_text(
|
||||||
|
entry, 8, occurrence == 0 ? "external" : "none", -1, SQLITE_STATIC);
|
||||||
|
(void)sqlite3_bind_int(entry, 9, occurrence == 0 ? 90 + position : 45 + position);
|
||||||
|
(void)sqlite3_bind_int(entry, 10, occurrence == 0 ? 4 : 3);
|
||||||
|
if (occurrence == 0) {
|
||||||
|
(void)sqlite3_bind_int(entry, 11, 8 + position % 5);
|
||||||
|
(void)sqlite3_bind_null(entry, 12);
|
||||||
|
(void)sqlite3_bind_double(entry, 13, 42.5 + (double)position * 1.25);
|
||||||
|
} else {
|
||||||
|
(void)sqlite3_bind_null(entry, 11);
|
||||||
|
(void)sqlite3_bind_int(entry, 12, 30 + position);
|
||||||
|
(void)sqlite3_bind_null(entry, 13);
|
||||||
|
}
|
||||||
|
(void)sqlite3_bind_text(entry, 14, note, -1, SQLITE_TRANSIENT);
|
||||||
|
if (sqlite3_step(entry) != SQLITE_DONE || sqlite3_reset(entry) != SQLITE_OK ||
|
||||||
|
sqlite3_clear_bindings(entry) != SQLITE_OK) {
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok = true;
|
||||||
|
cleanup:
|
||||||
|
(void)sqlite3_finalize(entry);
|
||||||
|
(void)sqlite3_finalize(session);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CONTRACT: construct a populated v25 database without consulting any user
|
||||||
|
* path. The current initializer supplies all older schema objects; removing
|
||||||
|
* exactly the v26 additions recreates the immediate predecessor boundary. */
|
||||||
|
static bool build_v25_fixture(const char *path) {
|
||||||
|
TrainlogDatabase *production = NULL;
|
||||||
|
sqlite3 *database = NULL;
|
||||||
|
bool ok = false;
|
||||||
|
|
||||||
|
if (trainlog_database_open(path, &production) != TRAINLOG_STATUS_OK) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (trainlog_database_insert_exercise_profiled(production,
|
||||||
|
"ex_fixture_press",
|
||||||
|
"Cable press",
|
||||||
|
"cable press",
|
||||||
|
TRAINLOG_TRACKING_REPS,
|
||||||
|
TRAINLOG_RECORDING_SETS,
|
||||||
|
1U) != TRAINLOG_STATUS_OK ||
|
||||||
|
trainlog_database_insert_exercise_profiled(production,
|
||||||
|
"ex_fixture_hold",
|
||||||
|
"Pallof hold",
|
||||||
|
"pallof hold",
|
||||||
|
TRAINLOG_TRACKING_DURATION,
|
||||||
|
TRAINLOG_RECORDING_SETS,
|
||||||
|
2U) != TRAINLOG_STATUS_OK) {
|
||||||
|
trainlog_database_close(production);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
trainlog_database_close(production);
|
||||||
|
if (sqlite3_open(path, &database) != SQLITE_OK ||
|
||||||
|
!execute(database,
|
||||||
|
"PRAGMA foreign_keys=OFF;"
|
||||||
|
"DROP INDEX program_deletions_pending;"
|
||||||
|
"DROP TABLE program_deletions;"
|
||||||
|
"ALTER TABLE programs DROP COLUMN deleted_at;"
|
||||||
|
"PRAGMA user_version=25;"
|
||||||
|
"PRAGMA foreign_keys=ON;") ||
|
||||||
|
!execute(
|
||||||
|
database,
|
||||||
|
"BEGIN IMMEDIATE;"
|
||||||
|
"INSERT INTO custom_equipment VALUES"
|
||||||
|
"('eq_fixture_stack','Cable stack','Stack','machine','external'),"
|
||||||
|
"('eq_fixture_mat','Exercise mat','Mat','surface','none');"
|
||||||
|
"INSERT INTO sessions(id,session_id,started_at,ended_at,session_type,notes) VALUES"
|
||||||
|
"(201,'se_fixture_history','2026-09-12T17:00:00Z','2026-09-12T17:52:00Z',"
|
||||||
|
"'training','Completed history sentinel');"
|
||||||
|
"INSERT INTO session_exercises(id,entry_id,session_row_id,exercise_row_id,"
|
||||||
|
"recording_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps,"
|
||||||
|
"target_duration_seconds,target_weight_kg,equipment_id,notes,tracking_mode) VALUES"
|
||||||
|
"(301,'sxe_fixture_press',201,(SELECT id FROM exercises WHERE "
|
||||||
|
"exercise_id='ex_fixture_press'),'sets',1,0,'external',105,4,10,NULL,47.5,"
|
||||||
|
"'eq_fixture_stack','Historic occurrence sentinel','reps'),"
|
||||||
|
"(302,'sxe_fixture_hold',201,(SELECT id FROM exercises WHERE "
|
||||||
|
"exercise_id='ex_fixture_hold'),'sets',2,1,'none',45,3,NULL,35,NULL,"
|
||||||
|
"'eq_fixture_mat','Historic duration sentinel','duration');"
|
||||||
|
"INSERT INTO performed_sets(id,session_exercise_row_id,position,reps,duration_seconds,"
|
||||||
|
"weight_kg) VALUES(401,301,0,10,NULL,47.5),(402,301,1,9,NULL,47.5),"
|
||||||
|
"(403,302,0,NULL,35,NULL);"
|
||||||
|
"INSERT INTO body_observations(id,observation_id,observed_at,session_row_id,"
|
||||||
|
"body_weight_kg,waist_cm,left_arm_cm,right_arm_cm,notes) VALUES"
|
||||||
|
"(501,'bo_fixture','2026-09-12T16:55:00Z',201,78.4,82.1,35.2,35.4,"
|
||||||
|
"'Historical body sentinel');"
|
||||||
|
"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"
|
||||||
|
"('pg_fixture','Eight-week strength progression','Imported coach plan','active',"
|
||||||
|
"'2026-10-01','2026-11-30','2026-09-15T08:00:00Z','2026-09-16T09:30:00Z',"
|
||||||
|
"'pgr_fixture_2','trainlog-program',1,"
|
||||||
|
"'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef');") ||
|
||||||
|
!insert_program_sessions(database) ||
|
||||||
|
!execute(
|
||||||
|
database,
|
||||||
|
"INSERT INTO session_preparations(preparation_id,current_revision_id,created_at,"
|
||||||
|
"updated_at,editing_state,delivery_state,source_proposal_id,source_payload_sha256,"
|
||||||
|
"withdrawn_at,source_program_id,source_program_session_id) VALUES"
|
||||||
|
"('prep_fixture','prepr_fixture_2','2026-09-17T08:00:00Z',"
|
||||||
|
"'2026-09-17T08:15:00Z','ready','local',NULL,NULL,NULL,'pg_fixture','pgs_fixture_07');"
|
||||||
|
"INSERT INTO session_preparation_revisions(revision_id,preparation_id,"
|
||||||
|
"parent_revision_id,title,session_type,planned_for,notes,created_at) VALUES"
|
||||||
|
"('prepr_fixture_1','prep_fixture',NULL,'Week 3 · Session 7','training',"
|
||||||
|
"'2026-10-07T18:00:00Z','Initial Program-derived revision','2026-09-17T08:00:00Z'),"
|
||||||
|
"('prepr_fixture_2','prep_fixture','prepr_fixture_1','Week 3 · Session 7 adjusted',"
|
||||||
|
"'training','2026-10-07T18:30:00Z','Adjusted after review','2026-09-17T08:15:00Z');"
|
||||||
|
"INSERT INTO session_preparation_entries(revision_id,entry_id,position,exercise_id,"
|
||||||
|
"equipment_id,recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,"
|
||||||
|
"target_sets,target_reps,target_duration_seconds,target_weight_kg,notes) VALUES"
|
||||||
|
"('prepr_fixture_1','prepe_fixture_1a',0,'ex_fixture_press','eq_fixture_stack',"
|
||||||
|
"'sets','reps',1,'external',96,4,10,NULL,50.0,'Program provenance press'),"
|
||||||
|
"('prepr_fixture_1','prepe_fixture_1b',1,'ex_fixture_hold','eq_fixture_mat',"
|
||||||
|
"'sets','duration',2,'none',51,3,NULL,36,NULL,'Program provenance hold'),"
|
||||||
|
"('prepr_fixture_2','prepe_fixture_2a',0,'ex_fixture_press','eq_fixture_stack',"
|
||||||
|
"'sets','reps',1,'external',100,5,8,NULL,52.5,'Adjusted press'),"
|
||||||
|
"('prepr_fixture_2','prepe_fixture_2b',1,'ex_fixture_hold','eq_fixture_mat',"
|
||||||
|
"'sets','duration',2,'none',55,3,NULL,40,NULL,'Adjusted hold');"
|
||||||
|
"COMMIT;") ||
|
||||||
|
scalar(database, "PRAGMA user_version") != 25 ||
|
||||||
|
scalar(database, "SELECT COUNT(*) FROM program_sessions") != 24 ||
|
||||||
|
scalar(database, "SELECT COUNT(*) FROM program_session_entries") != 48 ||
|
||||||
|
scalar(database, "SELECT COUNT(*) FROM pragma_foreign_key_check") != 0) {
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
ok = true;
|
||||||
|
cleanup:
|
||||||
|
if (database != NULL && sqlite3_close(database) != SQLITE_OK) {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int run_test(void) {
|
||||||
|
static const ProjectionSpec BUSINESS_PROJECTIONS[] = {
|
||||||
|
{"programs",
|
||||||
|
"SELECT program_id,title,note,state,start_date,end_date,created_at,updated_at,revision_id,"
|
||||||
|
"source_format,source_version,source_payload_sha256 FROM programs ORDER BY program_id"},
|
||||||
|
{"program_sessions",
|
||||||
|
"SELECT * FROM program_sessions ORDER BY program_id,position,program_session_id"},
|
||||||
|
{"program_session_entries",
|
||||||
|
"SELECT * FROM program_session_entries ORDER BY program_session_id,position,entry_id"},
|
||||||
|
{"session_preparations", "SELECT * FROM session_preparations ORDER BY preparation_id"},
|
||||||
|
{"session_preparation_revisions",
|
||||||
|
"SELECT * FROM session_preparation_revisions ORDER BY "
|
||||||
|
"preparation_id,created_at,revision_id"},
|
||||||
|
{"session_preparation_entries",
|
||||||
|
"SELECT * FROM session_preparation_entries ORDER BY revision_id,position,entry_id"},
|
||||||
|
{"exercises", "SELECT * FROM exercises ORDER BY id"},
|
||||||
|
{"custom_equipment", "SELECT * FROM custom_equipment ORDER BY equipment_id"},
|
||||||
|
{"sessions", "SELECT * FROM sessions ORDER BY id"},
|
||||||
|
{"session_exercises", "SELECT * FROM session_exercises ORDER BY id"},
|
||||||
|
{"performed_sets", "SELECT * FROM performed_sets ORDER BY id"},
|
||||||
|
{"body_observations", "SELECT * FROM body_observations ORDER BY id"},
|
||||||
|
{"profile_state", "SELECT * FROM exercise_profile_state ORDER BY exercise_row_id"},
|
||||||
|
{"profile_revisions",
|
||||||
|
"SELECT * FROM exercise_profile_revisions ORDER BY exercise_row_id,revision_id"},
|
||||||
|
};
|
||||||
|
static const ProjectionSpec UNCHANGED_SCHEMA[] = {
|
||||||
|
{"schema",
|
||||||
|
"SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' "
|
||||||
|
"AND name NOT IN('programs','program_deletions','program_deletions_pending') "
|
||||||
|
"ORDER BY type,name"},
|
||||||
|
};
|
||||||
|
char path[] = "/tmp/trainlog-v26-populated-XXXXXX";
|
||||||
|
char diagnostic[512] = {0};
|
||||||
|
int file_descriptor = -1;
|
||||||
|
sqlite3 *raw = NULL;
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
Projection business_before = {0};
|
||||||
|
Projection business_after = {0};
|
||||||
|
Projection schema_before = {0};
|
||||||
|
Projection schema_after = {0};
|
||||||
|
int version = 0;
|
||||||
|
int schema_object_count_before = 0;
|
||||||
|
bool ok = false;
|
||||||
|
|
||||||
|
file_descriptor = mkstemp(path);
|
||||||
|
CHECK(file_descriptor >= 0);
|
||||||
|
CHECK(close(file_descriptor) == 0);
|
||||||
|
file_descriptor = -1;
|
||||||
|
CHECK(build_v25_fixture(path));
|
||||||
|
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
|
||||||
|
CHECK(scalar(raw, "PRAGMA user_version") == 25);
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 12);
|
||||||
|
CHECK(scalar(raw,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE name IN('program_deletions',"
|
||||||
|
"'program_deletions_pending')") == 0);
|
||||||
|
schema_object_count_before =
|
||||||
|
scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'");
|
||||||
|
CHECK(schema_object_count_before > 0);
|
||||||
|
CHECK(capture_projection(raw,
|
||||||
|
BUSINESS_PROJECTIONS,
|
||||||
|
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]),
|
||||||
|
&business_before));
|
||||||
|
CHECK(capture_projection(raw, UNCHANGED_SCHEMA, 1U, &schema_before));
|
||||||
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
|
raw = NULL;
|
||||||
|
|
||||||
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(version == 26);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
database = NULL;
|
||||||
|
|
||||||
|
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
|
||||||
|
CHECK(scalar(raw, "PRAGMA user_version") == 26);
|
||||||
|
CHECK(capture_projection(raw,
|
||||||
|
BUSINESS_PROJECTIONS,
|
||||||
|
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]),
|
||||||
|
&business_after));
|
||||||
|
CHECK(capture_projection(raw, UNCHANGED_SCHEMA, 1U, &schema_after));
|
||||||
|
CHECK(business_before.length == business_after.length);
|
||||||
|
CHECK(memcmp(business_before.text, business_after.text, business_before.length) == 0);
|
||||||
|
CHECK(schema_before.length == schema_after.length);
|
||||||
|
CHECK(memcmp(schema_before.text, schema_after.text, schema_before.length) == 0);
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") ==
|
||||||
|
1);
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM programs WHERE deleted_at IS NOT NULL") == 0);
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM program_deletions") == 0);
|
||||||
|
CHECK(scalar(raw,
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('programs') WHERE name='deleted_at' AND "
|
||||||
|
"type='TEXT' AND \"notnull\"=0") == 1);
|
||||||
|
CHECK(scalar(raw,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE (type='table' AND "
|
||||||
|
"name='program_deletions') OR (type='index' AND "
|
||||||
|
"name='program_deletions_pending')") == 2);
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 13);
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'") ==
|
||||||
|
schema_object_count_before + 2);
|
||||||
|
ok = true;
|
||||||
|
cleanup:
|
||||||
|
if (!ok && diagnostic[0] != '\0') {
|
||||||
|
(void)fprintf(stderr, "migration diagnostic: %s\n", diagnostic);
|
||||||
|
}
|
||||||
|
free(schema_after.text);
|
||||||
|
free(schema_before.text);
|
||||||
|
free(business_after.text);
|
||||||
|
free(business_before.text);
|
||||||
|
if (raw != NULL) {
|
||||||
|
(void)sqlite3_close(raw);
|
||||||
|
}
|
||||||
|
if (database != NULL) {
|
||||||
|
trainlog_database_close(database);
|
||||||
|
}
|
||||||
|
if (file_descriptor >= 0) {
|
||||||
|
(void)close(file_descriptor);
|
||||||
|
}
|
||||||
|
if (unlink(path) != 0) {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
return ok ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
return run_test();
|
||||||
|
}
|
||||||
|
|
@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
|
||||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic,
|
CHECK(strncmp(diagnostic,
|
||||||
"migrate database to schema v25: SQLite rc=",
|
"migrate database to schema v26: SQLite rc=",
|
||||||
strlen("migrate database to schema v25: SQLite rc=")) == 0);
|
strlen("migrate database to schema v26: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||||
|
|
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
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(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
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(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
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)) ==
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
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(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,13 @@ static bool import_archive_and_prepare(void) {
|
||||||
char *second_entry;
|
char *second_entry;
|
||||||
|
|
||||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
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,
|
CHECK(trainlog_database_insert_exercise_profiled(database,
|
||||||
"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||||
"Exercise fixture",
|
"Exercise fixture",
|
||||||
|
|
@ -284,8 +291,15 @@ static bool import_archive_and_prepare(void) {
|
||||||
&response,
|
&response,
|
||||||
&response_size) == TRAINLOG_STATUS_OK);
|
&response_size) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(strstr(response, "\"state\":\"archived\"") != NULL);
|
CHECK(strstr(response, "\"state\":\"archived\"") != NULL);
|
||||||
|
CHECK(extract_string(response, "revision_id", revision, sizeof(revision)));
|
||||||
free(response);
|
free(response);
|
||||||
response = NULL;
|
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,
|
CHECK(trainlog_web_programs_archive_json(database,
|
||||||
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
|
@ -295,6 +309,59 @@ static bool import_archive_and_prepare(void) {
|
||||||
&response_size) == TRAINLOG_STATUS_OK);
|
&response_size) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(strstr(response, "\"state\":\"archived\"") != NULL);
|
CHECK(strstr(response, "\"state\":\"archived\"") != NULL);
|
||||||
free(response);
|
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);
|
trainlog_database_close(database);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ export interface ProgramListItem {
|
||||||
session_count: number
|
session_count: number
|
||||||
provenance: string
|
provenance: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
imported_at: string
|
||||||
|
revision_id: string
|
||||||
|
preparation_count: number
|
||||||
|
usage: 'used' | 'unused'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProgramOccurrence {
|
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}`
|
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)
|
const bytes = new Uint8Array(16)
|
||||||
crypto.getRandomValues(bytes)
|
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(
|
export async function fetchAllPrograms(
|
||||||
|
|
@ -127,6 +131,10 @@ export async function fetchAllPrograms(
|
||||||
if (!object(candidate) || typeof candidate.program_id !== 'string' ||
|
if (!object(candidate) || typeof candidate.program_id !== 'string' ||
|
||||||
typeof candidate.title !== 'string' ||
|
typeof candidate.title !== 'string' ||
|
||||||
(candidate.state !== 'active' && candidate.state !== 'archived') ||
|
(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)) {
|
identities.has(candidate.program_id)) {
|
||||||
throw new TypeError('pagination de programmes incohérente')
|
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
|
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()
|
const csrf = await mutationCsrfToken()
|
||||||
return fetch(path, {
|
return fetch(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
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,
|
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(
|
export async function createPreparationFromProgram(
|
||||||
programId: string,
|
programId: string,
|
||||||
programSessionId: string,
|
programSessionId: string,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,62 @@ function preview(imported: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Programs tab', () => {
|
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 () => {
|
it('shows a server preview before the only importing mutation', async () => {
|
||||||
const onOpen = vi.fn()
|
const onOpen = vi.fn()
|
||||||
const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => {
|
const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
archiveProgram,
|
archiveProgram,
|
||||||
createPreparationFromProgram,
|
createPreparationFromProgram,
|
||||||
|
deleteProgram,
|
||||||
fetchAllPrograms,
|
fetchAllPrograms,
|
||||||
fetchProgram,
|
fetchProgram,
|
||||||
importProgram,
|
importProgram,
|
||||||
|
|
@ -10,6 +11,7 @@ import {
|
||||||
type ProgramImportPreview,
|
type ProgramImportPreview,
|
||||||
type ProgramListItem,
|
type ProgramListItem,
|
||||||
} from '../api/programs'
|
} from '../api/programs'
|
||||||
|
import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog'
|
||||||
import { useDatePreferences } from '../presentation/DatePreferences'
|
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||||
import { formatCivilDate, formatDateTime } from '../presentation/dateFormat'
|
import { formatCivilDate, formatDateTime } from '../presentation/dateFormat'
|
||||||
|
|
||||||
|
|
@ -98,6 +100,8 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
|
||||||
<p>
|
<p>
|
||||||
{session.session_type === 'max_test'
|
{session.session_type === 'max_test'
|
||||||
? 'Test MAX planifié' : 'Entraînement planifié'}
|
? 'Test MAX planifié' : 'Entraînement planifié'}
|
||||||
|
{session.planned_for
|
||||||
|
? ` · ${formatCivilDate(session.planned_for, dateFormat)}` : ''}
|
||||||
{' · '}{session.occurrences.length} exercice
|
{' · '}{session.occurrences.length} exercice
|
||||||
{session.occurrences.length > 1 ? 's' : ''}
|
{session.occurrences.length > 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -131,6 +135,7 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
|
||||||
}) {
|
}) {
|
||||||
const { dateFormat } = useDatePreferences()
|
const { dateFormat } = useDatePreferences()
|
||||||
const fileInput = useRef<HTMLInputElement>(null)
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
|
const deleteReturnFocus = useRef<HTMLElement>(null)
|
||||||
const [items, setItems] = useState<ProgramListItem[]>([])
|
const [items, setItems] = useState<ProgramListItem[]>([])
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [state, setState] = useState('')
|
const [state, setState] = useState('')
|
||||||
|
|
@ -139,6 +144,9 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
|
||||||
const [rawImport, setRawImport] = useState('')
|
const [rawImport, setRawImport] = useState('')
|
||||||
const [preview, setPreview] = useState<ProgramImportPreview | null>(null)
|
const [preview, setPreview] = useState<ProgramImportPreview | null>(null)
|
||||||
const [fileName, setFileName] = useState('')
|
const [fileName, setFileName] = useState('')
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ProgramListItem | null>(null)
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState('')
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
setPending(true)
|
setPending(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
@ -199,6 +207,22 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
|
||||||
setPreview(null)
|
setPreview(null)
|
||||||
setRawImport('')
|
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">
|
return <section className="programs-tab">
|
||||||
<div className="sessions-toolbar">
|
<div className="sessions-toolbar">
|
||||||
<label className="session-search">
|
<label className="session-search">
|
||||||
|
|
@ -264,27 +288,57 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
|
||||||
{!pending && !error && items.length === 0 &&
|
{!pending && !error && items.length === 0 &&
|
||||||
<p className="empty-inline">Aucun programme.</p>}
|
<p className="empty-inline">Aucun programme.</p>}
|
||||||
<div className="program-list">
|
<div className="program-list">
|
||||||
{items.map((program) => <button
|
{items.map((program) => <article
|
||||||
type="button"
|
|
||||||
key={program.program_id}
|
key={program.program_id}
|
||||||
className="session-row program-row"
|
className="session-row program-row"
|
||||||
onClick={() => onOpen(program.program_id)}
|
|
||||||
>
|
>
|
||||||
<span>
|
<button
|
||||||
<small>{program.state === 'active' ? 'Programme actif' : 'Programme archivé'}</small>
|
type="button"
|
||||||
<strong>{program.title}</strong>
|
className="session-row-main"
|
||||||
</span>
|
onClick={() => onOpen(program.program_id)}
|
||||||
<span>
|
>
|
||||||
{program.start_date
|
<span>
|
||||||
? formatCivilDate(program.start_date, dateFormat) : 'Dates libres'}
|
<small>{program.state === 'active' ? 'Programme actif' : 'Programme archivé'}</small>
|
||||||
</span>
|
<strong>{program.title}</strong>
|
||||||
<span>
|
</span>
|
||||||
{program.session_count} séance{program.session_count > 1 ? 's' : ''}
|
<span>
|
||||||
</span>
|
{program.start_date
|
||||||
<span>
|
? formatCivilDate(program.start_date, dateFormat) : 'Dates libres'}
|
||||||
{program.provenance} · {formatDateTime(program.updated_at, dateFormat)}
|
</span>
|
||||||
</span>
|
<span>
|
||||||
</button>)}
|
{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>
|
</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>
|
</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:hover,
|
||||||
.trash-action:focus-visible { background: rgb(243 139 168 / 12%); }
|
.trash-action:focus-visible { background: rgb(243 139 168 / 12%); }
|
||||||
.program-list, .program-session-list { display: grid; gap: .75rem; }
|
.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 { 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 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); }
|
.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); }
|
.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; }
|
.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-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 li { display: grid; gap: .35rem; }
|
||||||
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
|
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue