feat: add AI session draft sync workflow

This commit is contained in:
fy59 2026-09-14 19:21:32 +02:00
parent 32fae197ee
commit 48d163b0a6
74 changed files with 4646 additions and 564 deletions

1
.gitignore vendored
View file

@ -25,6 +25,7 @@ local.properties
/data/ /data/
exports/ exports/
.local-backups/ .local-backups/
/trainlog_ai_export_v1.json
# Python helpers # Python helpers
__pycache__/ __pycache__/

View file

@ -192,7 +192,7 @@ Do not introduce a mandatory GVFS/FUSE mount.
Canonical exchange folder: Canonical exchange folder:
```text ```text
Download/Trainlog Documents/Trainlog
``` ```
The shared desktop synchronization engine is: The shared desktop synchronization engine is:

View file

@ -1,5 +1,44 @@
# Changelog # Changelog
- Added an Android confirmation dialog before deleting an imported AI session
draft. Cancel, Back and outside dismissal are non-mutating; only the
destructive confirmation invokes the existing deleted-tombstone operation.
- Replaced Android SAF folder grants with user-enabled
`MANAGE_EXTERNAL_STORAGE` access to the single fixed path
`/storage/emulated/0/Documents/Trainlog`. Android opens the system all-files
permission screen, writes canonical artifacts through fsync plus
same-directory replacement, reads PC artifacts directly, and removes stale
tree-URI preferences. Desktop publication remains `Documents/Trainlog`; the
historical Download tree and recovery backup remain untouched.
- Added `TRAINLOG_AI_SESSION_DRAFT_V1` as a separate strict proposal path:
desktop schema v18 imports one Drive source with `aid_` identity/digest
idempotence and post-commit archive bookkeeping, then publishes the bounded
`trainlog-ai-session-drafts` V1 Android companion in durable deterministic
batches, marking only successful MTP publications. Entry notes are rejected;
only draft-level notes belong to the source contract. Inbound helper outcomes
remain visible without turning Drive/archive/fetched-invalid results into a
failed device sync. Android schema v17 keeps
those proposals separate from its singleton active capture draft and retains
start/delete tombstones against replay. Automated contract coverage exists;
the required real Drive and Android-triggered bidirectional smoke validation
remains pending.
- Fixed automatic Android→PC exercise-alias imports when the desktop binary is
installed outside the repository build tree. Python sync helpers now resolve
from Meson's authoritative tools directory, stale result files are truncated
before resolution, and bounded child stderr/exit diagnostics reach sync
history instead of collapsing to a generic import failure.
- Added `TRAINLOG_AI_EXPORT_V1`, a deterministic local JSON history export for
manual external analysis. It opens desktop SQLite explicitly read-only,
preserves occurrence-owned immediate feedback and session-owned follow-ups,
and emits no inferred recommendations or progression.
- Added the shared desktop post-sync publication: successful TUI and
Android-requested sync runs regenerate the AI export and invoke external
`rclone copyto` for Google Drive. Export/Drive failures remain visible but do
not invalidate an already successful Trainlog synchronization.
- Fixed Android-triggered synchronization so the complete current Android→PC - Fixed Android-triggered synchronization so the complete current Android→PC
companion bundle is published before the request signal, including Training companion bundle is published before the request signal, including Training
Feedback V2 and Exercise Profile State V1. Desktop orchestration now refuses Feedback V2 and Exercise Profile State V1. Desktop orchestration now refuses
@ -8,6 +47,16 @@
- Made Android exchange-file reuse exact and directory-scoped: pending, - Made Android exchange-file reuse exact and directory-scoped: pending,
trashed, unrelated, and conflict-numbered MediaStore rows no longer enter trashed, unrelated, and conflict-numbered MediaStore rows no longer enter
canonical selection, while genuine duplicate exact names fail closed. canonical selection, while genuine duplicate exact names fail closed.
- Hardened the shared Android SAF publisher so every outbound companion and
sync request rewrites an existing exact canonical document with truncation,
never selects historical `(N)` copies, and reports
`SAF_CANONICAL_NAME_CONFLICT` before writing when a provider renames a newly
created document.
- Added an exact-document fallback for Samsung Android 16
`ExternalStorageProvider`, whose child query can omit ownerless MTP files even
though creation detects their names. Canonical rows are reconstructed only
beneath the granted tree and verified by exact provider display name;
historical suffixes remain untouched.
## Unreleased — Desktop historical tracking snapshot ## Unreleased — Desktop historical tracking snapshot

View file

@ -15,6 +15,7 @@ desktop.
```text ```text
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
DESKTOP_SCHEMA_V11=PASS DESKTOP_SCHEMA_V11=PASS
ANDROID_LOCAL_WORKFLOWS=PASS ANDROID_LOCAL_WORKFLOWS=PASS
@ -67,7 +68,7 @@ Android occurrence pages and latest-MAX context share the settled exact-instant
ordering and source-text cursor contract. The independent temporal review ordering and source-text cursor contract. The independent temporal review
returned PASS with no findings. The initial full-tranche audit's four findings returned PASS with no findings. The initial full-tranche audit's four findings
were resolved by one bounded repair chain and independently verified. Fresh were resolved by one bounded repair chain and independently verified. Fresh
validation passed: strict build, 42 Meson tests, Python knowledge/temporal validation passed: strict build, 56 Meson tests, Python knowledge/temporal
tests, validators, headers, sanitizers, 12-form temporal probes, Android 56 tests, validators, headers, sanitizers, 12-form temporal probes, Android 56
tests with one known fixture skip, and Java 17 debug assembly. tests with one known fixture skip, and Java 17 debug assembly.
The [temporal correction record](docs/reviews/training_knowledge_v1_temporal_contract.md) The [temporal correction record](docs/reviews/training_knowledge_v1_temporal_contract.md)
@ -82,7 +83,7 @@ defines the accepted grammar, regression evidence and remaining review boundary.
automatic mobile snapshot automatic mobile snapshot
| |
v v
Download/Trainlog on Android storage Documents/Trainlog on Android storage
| |
| direct MTP / libmtp | direct MTP / libmtp
v v
@ -192,6 +193,58 @@ python tools/validate_import_contract.py
git diff --check git diff --check
``` ```
## Local history export for external analysis
Create the read-only `TRAINLOG_AI_EXPORT_V1` artifact in the current directory:
```bash
python tools/export_ai_history.py --database ~/.local/share/trainlog/trainlog.db
```
The default output is `trainlog_ai_export_v1.json`. It is ignored by Git
because it contains user data. After every completely successful desktop sync,
the shared sync engine regenerates this artifact and asks the external desktop
`rclone` command to copy it to
`TrainLog Gdrive:Trainlog/AI/trainlog_ai_export_v1.json`. Export or Drive
failure is reported without changing the successful Trainlog sync result.
Android never runs `rclone`, and its configuration/OAuth credentials remain in
the user's normal `~/.config/rclone/` location outside this repository and the
APK. See [the exchange documentation](docs/exchange_format.md) for the
versioned raw-data contract and feedback ownership rules.
## AI session-draft exchange
`TRAINLOG_AI_SESSION_DRAFT_V1` is a separate, strict proposal exchange; it
does not alter `TRAINLOG_FORMAT_V1`, the mobile export, or the existing
read-only `TRAINLOG_AI_EXPORT_V1` history export. An external producer places
one source object at
`TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json`. The desktop
fetches it during synchronization, validates and imports it transactionally,
then copies those exact fetched bytes after that commit to
`TrainLog Gdrive:Trainlog/AI/archive/` under its stable `aid_<uuid-v4>`
identity. The mutable inbox object is never moved or deleted: its logical
replay is idempotent, and an archive failure is visible and retryable without
undoing the committed import.
The desktop then publishes the distinct PC-to-Android companion
`trainlog-ai-session-drafts-v1.json` in `Documents/Trainlog`. Android keeps its
pending proposals as a collection, separate from its exactly-one active
capture draft. Starting one proposal copies targets only into that singleton
draft and atomically tombstones the proposal; deleting a proposal also leaves
a tombstone, so replay cannot recreate it. No proposal creates performed work,
history, MAX, or feedback.
The source and companion are bounded and strict: UUIDv4 `aid_` identities,
nonempty trimmed text when supplied (title 120 and draft notes 2,000 characters;
entries have no notes), at most 64 ordered entries, target sets 1..99, target
reps 1..999, and at most 256 unpublished drafts per deterministic publication.
Successful MTP publication advances the durable batch cursor; failure retries
the same batch. Only `SETS` plans are
allowed; the selected exercise's REPS-or-DURATION profile determines the one
valid target metric. The final real Google Drive and Android-triggered
bidirectional smoke test remains manual, so this checkpoint is documented as
`VALIDATION_PENDING`, not PASS. See [AI draft exchange](docs/exchange_format.md).
## Android build ## Android build
Android keeps one durable in-progress workout in its local SQLite database. Android keeps one durable in-progress workout in its local SQLite database.
@ -205,7 +258,7 @@ export or desktop synchronization as completed sessions.
Schema migrations are additive and preserve existing capture data. See Schema migrations are additive and preserve existing capture data. See
[Android behavior](docs/android.md) and [validation](docs/tests.md). [Android behavior](docs/android.md) and [validation](docs/tests.md).
The current Android schema is v14. Its additive v4 -> v14 chain adds the shared The current Android schema is v17. Its additive v4 -> v17 chain adds the shared
equipment catalogue, per-occurrence equipment links, durable occurrence equipment catalogue, per-occurrence equipment links, durable occurrence
identities, custom-equipment definition support, explicit MAX results and identities, custom-equipment definition support, explicit MAX results and
stable-source Test max resumption, then direct primary/secondary body-zone stable-source Test max resumption, then direct primary/secondary body-zone

View file

@ -78,8 +78,6 @@ dependencies {
"androidx.compose.ui:ui-tooling-preview" "androidx.compose.ui:ui-tooling-preview"
) )
implementation("androidx.documentfile:documentfile:1.1.0")
debugImplementation( debugImplementation(
"androidx.compose.ui:ui-tooling" "androidx.compose.ui:ui-tooling"
) )

View file

@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Voice feedback stores recognition text only; no audio file is created. --> <!-- Voice feedback stores recognition text only; no audio file is created. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Personal sideloaded app: permanent bidirectional MTP exchange uses one
user-visible directory, Documents/Trainlog, without a SAF provider. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application <application
android:allowBackup="false" android:allowBackup="false"

View file

@ -0,0 +1,21 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
/* CONTRACT: only Android's settings UI can grant all-files access. Prefer the
* package-scoped screen and fall back to the platform-wide screen when an OEM
* does not expose the former activity. */
internal fun directStoragePermissionIntent(context: Context): Intent {
val packageIntent = Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:${context.packageName}"),
)
return if (packageIntent.resolveActivity(context.packageManager) != null) {
packageIntent
} else {
Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
}
}

View file

@ -1,14 +1,35 @@
package com.labfytools.trainlog.data package com.labfytools.trainlog.data
import android.content.Context import android.content.Context
import android.net.Uri import android.os.Build
import androidx.documentfile.provider.DocumentFile import android.os.Environment
import android.util.Log import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream import java.io.OutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.UUID
internal const val SYNC_PREFERENCES_NAME = "trainlog-sync" internal const val SYNC_PREFERENCES_NAME = "trainlog-sync"
internal const val SYNC_TREE_URI_KEY = "trainlog_tree_uri" internal const val SYNC_TREE_URI_KEY = "trainlog_tree_uri"
internal const val EXCHANGE_MEDIASTORE_LOG_TAG = "TrainlogMediaStore" internal const val DIRECT_EXCHANGE_LOG_TAG = "TrainlogDirectStorage"
internal const val EXCHANGE_FOLDER_DISPLAY_PATH = "Documents/Trainlog"
/* CONTRACT: direct exchange storage has one fixed public path. The legacy SAF
* preference is not authority and is removed without touching external files. */
internal fun clearLegacyExchangeTreePreference(context: Context) {
context.applicationContext.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE)
.edit().remove(SYNC_TREE_URI_KEY).apply()
}
internal fun hasDirectExchangePermission(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
runCatching { Environment.isExternalStorageManager() }.getOrDefault(false)
internal fun canonicalExchangeDirectory(): File =
File(Environment.getExternalStorageDirectory(), "Documents/Trainlog")
internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) { internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) {
"trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES" "trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES"
@ -22,141 +43,164 @@ internal fun exchangeArtifactIdentity(displayName: String): String = when (displ
else -> displayName else -> displayName
} }
/* WHY: document providers may throw the same message from different SAF internal fun logDirectExchange(displayName: String, phase: String, detail: String? = null) {
* operations. CONTRACT: traces contain artifact identity, operation phase,
* URI and exception metadata only; user JSON is never logged. */
internal fun logExchangeMediaStore(displayName: String, phase: String, detail: String? = null) {
val suffix = detail?.let { " $it" } ?: "" val suffix = detail?.let { " $it" } ?: ""
Log.d(EXCHANGE_MEDIASTORE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} $phase$suffix") Log.d(DIRECT_EXCHANGE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} $phase$suffix")
} }
internal fun logExchangeMediaStoreError(displayName: String, phase: String, error: Throwable) { internal fun logDirectExchangeError(displayName: String, phase: String, error: Throwable) {
Log.e( Log.e(DIRECT_EXCHANGE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} error phase=$phase " +
EXCHANGE_MEDIASTORE_LOG_TAG, "exception=${error.javaClass.name}:${error.message}")
"${exchangeArtifactIdentity(displayName)} error phase=$phase " +
"exception=${error.javaClass.name}:${error.message}",
)
} }
internal data class ExchangeSafDocument( internal data class DirectExchangeDocument(val file: File, val displayName: String)
val uri: Uri,
val displayName: String,
)
internal interface ExchangeSafDirectory { internal interface DirectExchangeDirectoryAccess {
fun listDirectChildren(): List<ExchangeSafDocument> fun listDirectChildren(): List<DirectExchangeDocument>
fun createJson(displayName: String): ExchangeSafDocument? fun findExact(displayName: String): List<DirectExchangeDocument>
fun openForRewrite(document: ExchangeSafDocument): OutputStream? fun createJson(displayName: String): DirectExchangeDocument?
fun openForRewrite(document: DirectExchangeDocument): OutputStream?
} }
private class DocumentFileExchangeSafDirectory( /* WHY: provider writes allowed Samsung to manufacture numbered conflict
private val context: Context, * copies. Direct files make the exact canonical name authoritative.
private val directory: DocumentFile, * INVARIANT: bytes are fully written and fsync'd in this directory before a
) : ExchangeSafDirectory { * same-directory move replaces the canonical path. */
override fun listDirectChildren(): List<ExchangeSafDocument> = private class DirectExchangeDirectory(private val directory: File) : DirectExchangeDirectoryAccess {
directory.listFiles().mapNotNull { child -> override fun listDirectChildren(): List<DirectExchangeDocument> =
child.name?.let { ExchangeSafDocument(child.uri, it) } directory.listFiles().orEmpty().filter(File::isFile).map { DirectExchangeDocument(it, it.name) }
override fun findExact(displayName: String): List<DirectExchangeDocument> {
requireCanonicalLeaf(displayName)
val candidate = File(directory, displayName)
return if (candidate.isFile) listOf(DirectExchangeDocument(candidate, displayName)) else emptyList()
} }
override fun createJson(displayName: String): ExchangeSafDocument? = override fun createJson(displayName: String): DirectExchangeDocument {
directory.createFile("application/json", displayName)?.let { created -> requireCanonicalLeaf(displayName)
val actualName = created.name return DirectExchangeDocument(File(directory, displayName), displayName)
require(actualName == displayName) {
"Le fournisseur SAF a créé $actualName au lieu de $displayName."
}
ExchangeSafDocument(created.uri, displayName)
} }
override fun openForRewrite(document: ExchangeSafDocument): OutputStream? = override fun openForRewrite(document: DirectExchangeDocument): OutputStream {
context.contentResolver.openOutputStream(document.uri, "wt") requireCanonicalLeaf(document.displayName)
require(document.file.parentFile?.canonicalFile == directory.canonicalFile) {
"Chemin d'artefact hors du dossier Trainlog."
}
val temporary = File(directory, ".trainlog-${document.displayName}-${UUID.randomUUID()}.tmp")
val delegate = FileOutputStream(temporary, false)
return object : OutputStream() {
private var closed = false
private var failed = false
override fun write(value: Int) = attempt { delegate.write(value) }
override fun write(bytes: ByteArray, offset: Int, length: Int) =
attempt { delegate.write(bytes, offset, length) }
override fun flush() = attempt { delegate.flush() }
override fun close() {
if (closed) return
closed = true
if (failed) {
runCatching { delegate.close() }
temporary.delete()
return
}
try {
delegate.flush()
delegate.fd.sync()
delegate.close()
try {
Files.move(temporary.toPath(), document.file.toPath(),
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(temporary.toPath(), document.file.toPath(),
StandardCopyOption.REPLACE_EXISTING)
}
} catch (error: Exception) {
runCatching { delegate.close() }
temporary.delete()
throw error
}
} }
internal fun persistedExchangeSafDirectory(context: Context): ExchangeSafDirectory? { private inline fun attempt(operation: () -> Unit) {
val appContext = context.applicationContext try {
val treeUri = appContext.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE) operation()
.getString(SYNC_TREE_URI_KEY, null)?.let(Uri::parse) ?: return null } catch (error: Exception) {
val directory = DocumentFile.fromTreeUri(appContext, treeUri) ?: return null failed = true
if (!directory.isDirectory || !directory.canWrite()) return null throw error
return DocumentFileExchangeSafDirectory(appContext, directory) }
}
}
} }
internal class ExchangeSafPublisher( private fun requireCanonicalLeaf(displayName: String) {
private val directoryProvider: () -> ExchangeSafDirectory?, require(displayName.isNotEmpty() && displayName != "." && displayName != "..")
) { require('/' !in displayName && '\\' !in displayName) { "Nom d'artefact invalide." }
fun snapshot(): ExchangeSafSnapshotResult { }
}
internal fun directExchangeDirectoryForPath(directory: File): DirectExchangeDirectoryAccess? {
if (!directory.exists() && !directory.mkdirs()) return null
if (!directory.isDirectory || !directory.canRead() || !directory.canWrite()) return null
return DirectExchangeDirectory(directory)
}
internal fun directExchangeDirectory(context: Context): DirectExchangeDirectoryAccess? {
clearLegacyExchangeTreePreference(context)
if (!hasDirectExchangePermission()) return null
return directExchangeDirectoryForPath(canonicalExchangeDirectory())
}
internal class DirectExchangePublisher(private val directoryProvider: () -> DirectExchangeDirectoryAccess?) {
fun snapshot(): DirectExchangeSnapshotResult {
val directory = directoryProvider() val directory = directoryProvider()
?: return ExchangeSafSnapshotResult.Error( ?: return DirectExchangeSnapshotResult.Error("Accès fichiers requis pour Documents/Trainlog.")
"Dossier Trainlog non autorisé ou inaccessible.",
)
val started = android.os.SystemClock.elapsedRealtime() val started = android.os.SystemClock.elapsedRealtime()
return try { return try {
logExchangeMediaStore("SAF_TREE", "snapshot.begin", "thread=${Thread.currentThread().name}") logDirectExchange("DIRECT_STORAGE", "snapshot.begin", "thread=${Thread.currentThread().name}")
val children = directory.listDirectChildren() val children = directory.listDirectChildren()
val byName = children.groupByTo(linkedMapOf()) { it.displayName } val byName = children.groupByTo(linkedMapOf()) { it.displayName }
val elapsed = android.os.SystemClock.elapsedRealtime() - started logDirectExchange("DIRECT_STORAGE", "snapshot.success",
logExchangeMediaStore( "children=${children.size} duration_ms=${android.os.SystemClock.elapsedRealtime() - started} " +
"SAF_TREE", "thread=${Thread.currentThread().name}")
"snapshot.success", DirectExchangeSnapshotResult.Ready(DirectExchangeSnapshot(directory, byName))
"children=${children.size} duration_ms=$elapsed thread=${Thread.currentThread().name}",
)
ExchangeSafSnapshotResult.Ready(ExchangeSafSnapshot(directory, byName))
} catch (error: Exception) { } catch (error: Exception) {
logExchangeMediaStoreError("SAF_TREE", "snapshot", error) logDirectExchangeError("DIRECT_STORAGE", "snapshot", error)
ExchangeSafSnapshotResult.Error(error.message ?: "Lecture du dossier SAF impossible.") DirectExchangeSnapshotResult.Error(error.message ?: "Lecture de Documents/Trainlog impossible.")
} }
} }
} }
internal sealed interface ExchangeSafSnapshotResult { internal sealed interface DirectExchangeSnapshotResult {
data class Ready(val snapshot: ExchangeSafSnapshot) : ExchangeSafSnapshotResult data class Ready(val snapshot: DirectExchangeSnapshot) : DirectExchangeSnapshotResult
data class Error(val message: String) : ExchangeSafSnapshotResult data class Error(val message: String) : DirectExchangeSnapshotResult
} }
internal class ExchangeSafSnapshot( internal class DirectExchangeSnapshot(
private val directory: ExchangeSafDirectory, private val directory: DirectExchangeDirectoryAccess,
private val byName: MutableMap<String, MutableList<ExchangeSafDocument>>, private val byName: MutableMap<String, MutableList<DirectExchangeDocument>>,
) { ) {
fun writeJson(displayName: String, json: String): String? { fun writeJson(displayName: String, json: String): String? {
var phase = "saf.resolve" var phase = "direct.resolve"
return try { return try {
logExchangeMediaStore(displayName, "saf.resolve.begin")
val exact = byName[displayName].orEmpty() val exact = byName[displayName].orEmpty()
require(exact.size <= 1) { require(exact.size <= 1) { "DIRECT_STORAGE_DUPLICATE_CANONICAL: nom=$displayName count=${exact.size}" }
"Plusieurs documents SAF canoniques exacts pour $displayName (${exact.size})." val resolved = exact.singleOrNull() ?: directory.findExact(displayName).also {
require(it.size <= 1) { "DIRECT_STORAGE_DUPLICATE_CANONICAL: nom=$displayName count=${it.size}" }
}.singleOrNull()
val document = resolved ?: directory.createJson(displayName)?.also {
require(it.displayName == displayName) {
"DIRECT_STORAGE_CANONICAL_NAME_CONFLICT: attendu=$displayName obtenu=${it.displayName}"
} }
val existing = exact.singleOrNull() byName.getOrPut(displayName) { mutableListOf() }.add(it)
val document = if (existing != null) { } ?: return "Création de $displayName impossible."
logExchangeMediaStore(displayName, "saf.resolve.existing", "uri=${existing.uri}") phase = "direct.atomicWrite"
existing val stream = directory.openForRewrite(document) ?: return "Écriture de $displayName impossible."
} else { stream.use { it.write(json.toByteArray(Charsets.UTF_8)) }
phase = "saf.create" logDirectExchange(displayName, "direct.write.success")
logExchangeMediaStore(displayName, "saf.resolve.absent")
logExchangeMediaStore(displayName, "saf.create.begin", "mime=application/json")
directory.createJson(displayName)?.also { created ->
/* INVARIANT: creation mutates this transaction-local index
* so later artifacts never rescan the live directory. */
byName.getOrPut(displayName) { mutableListOf() }.add(created)
logExchangeMediaStore(displayName, "saf.create.success", "uri=${created.uri}")
} ?: return "Création SAF de $displayName impossible."
}
phase = "saf.openOutputStream"
logExchangeMediaStore(
displayName,
"saf.write.begin",
"uri=${document.uri} mode=wt thread=${Thread.currentThread().name}",
)
val stream = directory.openForRewrite(document)
?: return "Écriture SAF de $displayName impossible."
phase = "saf.stream.write"
stream.use {
it.write(json.toByteArray(Charsets.UTF_8))
it.flush()
}
logExchangeMediaStore(displayName, "saf.write.success", "uri=${document.uri}")
null null
} catch (error: Exception) { } catch (error: Exception) {
logExchangeMediaStoreError(displayName, phase, error) logDirectExchangeError(displayName, phase, error)
error.message ?: "Publication SAF de $displayName impossible." error.message ?: "Publication de $displayName impossible."
} }
} }
} }

View file

@ -1,9 +1,7 @@
package com.labfytools.trainlog.data package com.labfytools.trainlog.data
import android.content.Context import android.content.Context
import android.content.Intent import java.io.File
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import org.json.JSONObject import org.json.JSONObject
sealed interface CatalogInboxResult { sealed interface CatalogInboxResult {
@ -50,96 +48,34 @@ class SyncCatalogInbox(
private val appContext = private val appContext =
context.applicationContext context.applicationContext
private val preferences =
appContext.getSharedPreferences(
SYNC_PREFERENCES_NAME,
Context.MODE_PRIVATE,
)
fun hasFolderAccess(): Boolean = fun hasFolderAccess(): Boolean =
savedTreeUri() != null directExchangeDirectory() != null
fun saveTreeUri(
uri: Uri,
): Boolean {
return try {
appContext
.contentResolver
.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
preferences
.edit()
.putString(
SYNC_TREE_URI_KEY,
uri.toString(),
)
.apply()
true
} catch (
error: SecurityException
) {
false
}
}
fun importPcCatalog(): fun importPcCatalog():
CatalogInboxResult { CatalogInboxResult {
val treeUri = val directory = directExchangeDirectory()
savedTreeUri() ?: return CatalogInboxResult.FolderNotAuthorized
?: return CatalogInboxResult
.FolderNotAuthorized
val directory = return importPcCatalogFromDirectory(directory)
DocumentFile }
.fromTreeUri(
appContext, /** Test seam for the production companion ordering on a fresh database. */
treeUri, internal fun importPcCatalogFromDirectoryForTest(directory: File): CatalogInboxResult =
) importPcCatalogFromDirectory(directory)
?: return CatalogInboxResult.Error(
"Dossier Trainlog inaccessible." private fun importPcCatalogFromDirectory(directory: File): CatalogInboxResult {
)
val file = val file =
directory.findFile( directory.findDirectFile("trainlog-pc-catalog-v1.json")
"trainlog-pc-catalog-v1.json"
)
?: return CatalogInboxResult.FileNotFound ?: return CatalogInboxResult.FileNotFound
return try { return try {
val stream = val json = file.readText(Charsets.UTF_8)
appContext
.contentResolver
.openInputStream(
file.uri
)
?: return CatalogInboxResult.Error(
"Lecture du catalogue impossible."
)
val json =
stream.bufferedReader(
Charsets.UTF_8
)
.use {
it.readText()
}
val definitionsError = importPcEquipmentDefinitions(directory) val definitionsError = importPcEquipmentDefinitions(directory)
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError) if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
val aliasesError = importExerciseAliases(directory) val profileFile = directory.findDirectFile("trainlog-exercise-profile-state-v1.json")
if (aliasesError != null) return CatalogInboxResult.Error(aliasesError) val profileJson = profileFile?.readText(Charsets.UTF_8)
val profileFile = directory.findFile("trainlog-exercise-profile-state-v1.json")
val profileJson = profileFile?.let { candidate ->
appContext.contentResolver.openInputStream(candidate.uri)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
}
if (profileFile != null && profileJson == null)
return CatalogInboxResult.Error("Lecture profile-state impossible.")
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson,allowPending=true)) { if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson,allowPending=true)) {
is ExerciseProfileStateImportResult.Applied -> Unit is ExerciseProfileStateImportResult.Applied -> Unit
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message) is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
@ -155,6 +91,12 @@ class SyncCatalogInbox(
) )
) { ) {
is PcCatalogImportResult.Applied -> { is PcCatalogImportResult.Applied -> {
/* WHY: a fresh Android database cannot validate an alias
* target until the catalog has created that canonical
* exercise. CONTRACT: aliases still precede session import
* so retired creator IDs resolve during the same sync. */
val aliasesError = importExerciseAliases(directory)
if (aliasesError != null) return CatalogInboxResult.Error(aliasesError)
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson)) { if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson)) {
is ExerciseProfileStateImportResult.Applied -> Unit is ExerciseProfileStateImportResult.Applied -> Unit
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message) is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
@ -163,23 +105,16 @@ class SyncCatalogInbox(
} }
/* Catalogue identities now exist and the strict second pass /* Catalogue identities now exist and the strict second pass
* has either installed or verified their causal state. */ * has either installed or verified their causal state. */
when (val sessions = importPcSessions(directory)) { importPcSessions(directory)?.let { return CatalogInboxResult.Error(it) }
null -> when (val equipment = importPcEquipmentAssociations(directory)) { importPcEquipmentAssociations(directory)?.let { return CatalogInboxResult.Error(it) }
null -> when (val bodyZones = importPcBodyZones(directory)) { importAiSessionDrafts(directory)?.let { return CatalogInboxResult.Error(it) }
null -> when (val feedback = importTrainingFeedback(directory)) { importPcBodyZones(directory)?.let { return CatalogInboxResult.Error(it) }
null -> CatalogInboxResult.Imported( importTrainingFeedback(directory)?.let { return CatalogInboxResult.Error(it) }
CatalogInboxResult.Imported(
imported = result.imported, imported = result.imported,
reconciled = result.reconciled, reconciled = result.reconciled,
skipped = result.skipped, skipped = result.skipped,
) )
else -> CatalogInboxResult.Error(feedback)
}
else -> CatalogInboxResult.Error(bodyZones)
}
else -> CatalogInboxResult.Error(equipment)
}
else -> CatalogInboxResult.Error(sessions)
}
} }
is PcCatalogImportResult.Invalid -> is PcCatalogImportResult.Invalid ->
@ -202,15 +137,13 @@ class SyncCatalogInbox(
} }
} }
private fun importPcSessions(directory: DocumentFile): String? { private fun importPcSessions(directory: File): String? {
/* CONTRACT: a present V3 artifact is authoritative. Invalid V3 must /* CONTRACT: a present V3 artifact is authoritative. Invalid V3 must
* surface its error and never fall back to a stale V2 snapshot. */ * surface its error and never fall back to a stale V2 snapshot. */
val v3 = directory.findFile("trainlog-pc-mobile-export-v3.json") val v3 = directory.findDirectFile("trainlog-pc-mobile-export-v3.json")
val file = v3 ?: directory.findFile("trainlog-pc-mobile-export-v2.json") ?: return null val file = v3 ?: directory.findDirectFile("trainlog-pc-mobile-export-v2.json") ?: return null
return try { return try {
val json = appContext.contentResolver.openInputStream(file.uri) val json = file.readText(Charsets.UTF_8)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
?: return "Lecture snapshot séances impossible."
val result = if (v3 != null) repository.applyPcMobileExportV3Json(json) val result = if (v3 != null) repository.applyPcMobileExportV3Json(json)
else repository.applyPcMobileExportV2Json(json) else repository.applyPcMobileExportV2Json(json)
when (result) { when (result) {
@ -221,14 +154,13 @@ class SyncCatalogInbox(
} catch (error: Exception) { error.message ?: "Import séances impossible." } } catch (error: Exception) { error.message ?: "Import séances impossible." }
} }
private fun importTrainingFeedback(directory: DocumentFile): String? { private fun importTrainingFeedback(directory: File): String? {
/* V2 is authoritative for revision history. V1 remains a fallback for /* V2 is authoritative for revision history. V1 remains a fallback for
* an older peer and can only add deterministic initial revisions. */ * an older peer and can only add deterministic initial revisions. */
val file = directory.findFile("trainlog-training-feedback-v2.json") val file = directory.findDirectFile("trainlog-training-feedback-v2.json")
?: directory.findFile("trainlog-training-feedback-v1.json") ?: return null ?: directory.findDirectFile("trainlog-training-feedback-v1.json") ?: return null
return try { return try {
val json = appContext.contentResolver.openInputStream(file.uri)?.bufferedReader(Charsets.UTF_8) val json = file.readText(Charsets.UTF_8)
?.use { it.readText() } ?: return "Lecture des ressentis impossible."
when (val result = repository.applyTrainingFeedbackJson(json)) { when (val result = repository.applyTrainingFeedbackJson(json)) {
is TrainingFeedbackImportResult.Applied -> null is TrainingFeedbackImportResult.Applied -> null
is TrainingFeedbackImportResult.Invalid -> result.message is TrainingFeedbackImportResult.Invalid -> result.message
@ -239,15 +171,31 @@ class SyncCatalogInbox(
} }
/** Test seam for the real filename-priority boundary; production uses the same method. */ /** Test seam for the real filename-priority boundary; production uses the same method. */
internal fun importPcSessionsFromDirectoryForTest(directory: DocumentFile): String? = internal fun importPcSessionsFromDirectoryForTest(directory: File): String? =
importPcSessions(directory) importPcSessions(directory)
private fun importPcBodyZones(directory: DocumentFile): String? { private fun importAiSessionDrafts(directory: File): String? {
val file = directory.findFile("trainlog-exercise-body-zones-v1.json") ?: return null val file = directory.findDirectFile("trainlog-ai-session-drafts-v1.json") ?: return null
return try { return try {
val json = appContext.contentResolver.openInputStream(file.uri) val json = file.readText(Charsets.UTF_8)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } when (val result = repository.applyAiSessionDraftsJson(json)) {
?: return "Lecture zones corporelles impossible." is AiSessionDraftImportResult.Applied -> null
is AiSessionDraftImportResult.Invalid -> result.message
AiSessionDraftImportResult.DatabaseError -> "Erreur base locale brouillons IA."
}
} catch (error: Exception) {
error.message ?: "Import des brouillons IA impossible."
}
}
/** Test seam for the production one-sync prerequisite ordering. */
internal fun importAiSessionDraftsFromDirectoryForTest(directory: File): String? =
importAiSessionDrafts(directory)
private fun importPcBodyZones(directory: File): String? {
val file = directory.findDirectFile("trainlog-exercise-body-zones-v1.json") ?: return null
return try {
val json = file.readText(Charsets.UTF_8)
when (val result = repository.applyExerciseBodyZonesJson(json)) { when (val result = repository.applyExerciseBodyZonesJson(json)) {
is ExerciseBodyZoneImportResult.Applied -> null is ExerciseBodyZoneImportResult.Applied -> null
is ExerciseBodyZoneImportResult.Invalid -> result.message is ExerciseBodyZoneImportResult.Invalid -> result.message
@ -261,12 +209,10 @@ class SyncCatalogInbox(
} }
} }
private fun importExerciseAliases(directory: DocumentFile): String? { private fun importExerciseAliases(directory: File): String? {
val file = directory.findFile("trainlog-exercise-aliases-v1.json") ?: return null val file = directory.findDirectFile("trainlog-exercise-aliases-v1.json") ?: return null
return try { return try {
val json = appContext.contentResolver.openInputStream(file.uri) val json = file.readText(Charsets.UTF_8)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
?: return "Lecture des alias exercice impossible."
when (val result = repository.applyExerciseAliasesJson(json)) { when (val result = repository.applyExerciseAliasesJson(json)) {
is ExerciseAliasImportResult.Applied -> null is ExerciseAliasImportResult.Applied -> null
is ExerciseAliasImportResult.Invalid -> result.message is ExerciseAliasImportResult.Invalid -> result.message
@ -280,12 +226,10 @@ class SyncCatalogInbox(
} }
} }
private fun importPcEquipmentDefinitions(directory: DocumentFile): String? { private fun importPcEquipmentDefinitions(directory: File): String? {
val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null val file = directory.findDirectFile("trainlog-pc-equipment-definitions-v1.json") ?: return null
return try { return try {
val json = appContext.contentResolver.openInputStream(file.uri) val json = file.readText(Charsets.UTF_8)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
?: return "Lecture définitions équipement impossible."
when (val result = repository.applyPcEquipmentDefinitionsJson(json)) { when (val result = repository.applyPcEquipmentDefinitionsJson(json)) {
is EquipmentDefinitionImportResult.Applied -> null is EquipmentDefinitionImportResult.Applied -> null
is EquipmentDefinitionImportResult.Invalid -> result.message is EquipmentDefinitionImportResult.Invalid -> result.message
@ -294,14 +238,12 @@ class SyncCatalogInbox(
} catch (error: Exception) { error.message ?: "Import définitions équipement impossible." } } catch (error: Exception) { error.message ?: "Import définitions équipement impossible." }
} }
private fun importPcEquipmentAssociations(directory: DocumentFile): String? { private fun importPcEquipmentAssociations(directory: File): String? {
val file = directory.findFile("trainlog-equipment-associations-v2.json") val file = directory.findDirectFile("trainlog-equipment-associations-v2.json")
?: directory.findFile("trainlog-equipment-associations-v1.json") ?: directory.findDirectFile("trainlog-equipment-associations-v1.json")
?: return null /* Historic PC sync: absence means no information. */ ?: return null /* Historic PC sync: absence means no information. */
return try { return try {
val json = appContext.contentResolver.openInputStream(file.uri) val json = file.readText(Charsets.UTF_8)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
?: return "Lecture extension équipement impossible."
when (val result = repository.applyPcEquipmentAssociationsJson(json)) { when (val result = repository.applyPcEquipmentAssociationsJson(json)) {
is EquipmentAssociationImportResult.Applied -> null is EquipmentAssociationImportResult.Applied -> null
is EquipmentAssociationImportResult.Invalid -> result.message is EquipmentAssociationImportResult.Invalid -> result.message
@ -315,45 +257,15 @@ class SyncCatalogInbox(
fun readSyncReceipt( fun readSyncReceipt(
requestId: String, requestId: String,
): SyncReceiptResult { ): SyncReceiptResult {
val treeUri = val directory = directExchangeDirectory()
savedTreeUri() ?: return SyncReceiptResult.FolderNotAuthorized
?: return SyncReceiptResult
.FolderNotAuthorized
val directory =
DocumentFile
.fromTreeUri(
appContext,
treeUri,
)
?: return SyncReceiptResult.Error(
"Dossier Trainlog inaccessible."
)
val file = val file =
directory.findFile( directory.findDirectFile("trainlog-sync-receipt-v1.json")
"trainlog-sync-receipt-v1.json"
)
?: return SyncReceiptResult.Pending ?: return SyncReceiptResult.Pending
return try { return try {
val stream = val json = file.readText(Charsets.UTF_8)
appContext
.contentResolver
.openInputStream(
file.uri
)
?: return SyncReceiptResult.Error(
"Lecture du reçu impossible."
)
val json =
stream.bufferedReader(
Charsets.UTF_8
)
.use {
it.readText()
}
val root = val root =
JSONObject(json) JSONObject(json)
@ -418,14 +330,17 @@ fun readSyncReceipt(
} }
} }
private fun savedTreeUri(): Uri? = private fun directExchangeDirectory(): File? {
preferences clearLegacyExchangeTreePreference(appContext)
.getString( if (!hasDirectExchangePermission()) return null
SYNC_TREE_URI_KEY, val directory = canonicalExchangeDirectory()
null, if (!directory.exists() && !directory.mkdirs()) return null
) return directory.takeIf { it.isDirectory && it.canRead() && it.canWrite() }
?.let { }
Uri.parse(it)
private fun File.findDirectFile(name: String): File? {
require('/' !in name && '\\' !in name)
return File(this, name).takeIf(File::isFile)
} }
} }

View file

@ -22,31 +22,31 @@ sealed interface SyncExportResult {
class SyncExporter private constructor( class SyncExporter private constructor(
private val repository: private val repository:
TrainlogRepository, TrainlogRepository,
private val safPublisher: ExchangeSafPublisher, private val publisher: DirectExchangePublisher,
) { ) {
constructor(context: Context, repository: TrainlogRepository) : this( constructor(context: Context, repository: TrainlogRepository) : this(
repository, repository,
ExchangeSafPublisher { persistedExchangeSafDirectory(context) }, DirectExchangePublisher { directExchangeDirectory(context) },
) )
internal constructor( internal constructor(
context: Context, context: Context,
repository: TrainlogRepository, repository: TrainlogRepository,
directoryProvider: () -> ExchangeSafDirectory?, directoryProvider: () -> DirectExchangeDirectoryAccess?,
) : this(repository, ExchangeSafPublisher(directoryProvider)) ) : this(repository, DirectExchangePublisher(directoryProvider))
/* CONTRACT: repository snapshot construction and every SAF operation are /* CONTRACT: repository snapshot construction and every filesystem operation are
* blocking I/O work and must never execute on the Compose/main thread. */ * blocking I/O work and must never execute on the Compose/main thread. */
suspend fun exportMobileBundle(): SyncExportResult = withContext(Dispatchers.IO) { suspend fun exportMobileBundle(): SyncExportResult = withContext(Dispatchers.IO) {
when (val opened = openSafSnapshot()) { when (val opened = openStorageSnapshot()) {
is ExchangeSafSnapshotResult.Ready -> exportMobileBundle(opened.snapshot) is DirectExchangeSnapshotResult.Ready -> exportMobileBundle(opened.snapshot)
is ExchangeSafSnapshotResult.Error -> SyncExportResult.Error(opened.message) is DirectExchangeSnapshotResult.Error -> SyncExportResult.Error(opened.message)
} }
} }
internal fun openSafSnapshot(): ExchangeSafSnapshotResult = safPublisher.snapshot() internal fun openStorageSnapshot(): DirectExchangeSnapshotResult = publisher.snapshot()
internal fun exportMobileBundle(snapshot: ExchangeSafSnapshot): SyncExportResult { internal fun exportMobileBundle(snapshot: DirectExchangeSnapshot): SyncExportResult {
if ( if (
Build.VERSION.SDK_INT < Build.VERSION.SDK_INT <
Build.VERSION_CODES.Q Build.VERSION_CODES.Q
@ -89,9 +89,8 @@ class SyncExporter private constructor(
"trainlog-training-feedback-v2.json" to feedbackJson, "trainlog-training-feedback-v2.json" to feedbackJson,
"trainlog-exercise-profile-state-v1.json" to profileStateJson, "trainlog-exercise-profile-state-v1.json" to profileStateJson,
) )
/* CONTRACT: the persisted user-authorized SAF tree is the sole /* CONTRACT: the fixed direct-storage directory is the sole outbound
* outbound directory authority. MediaStore ownership must neither hide * authority. Same-directory atomic replacement preserves exact names. */
* a canonical document nor cause creation of a numbered conflict. */
artifacts.forEach { (displayName, json) -> artifacts.forEach { (displayName, json) ->
snapshot.writeJson(displayName, json)?.let { error -> snapshot.writeJson(displayName, json)?.let { error ->
return SyncExportResult.Error(error) return SyncExportResult.Error(error)
@ -120,7 +119,7 @@ class SyncExporter private constructor(
associationsJson, associationsJson,
)?.let { return SyncExportResult.Error(it) } )?.let { return SyncExportResult.Error(it) }
return SyncExportResult.Exported( return SyncExportResult.Exported(
displayPath = "Download/Trainlog/trainlog-mobile-export-v3.json", displayPath = "Documents/Trainlog/trainlog-mobile-export-v3.json",
bytes = mobileJson.toByteArray(Charsets.UTF_8).size, bytes = mobileJson.toByteArray(Charsets.UTF_8).size,
) )
} }

View file

@ -22,24 +22,24 @@ sealed interface SyncRequestResult {
} }
class SyncRequestOutbox private constructor( class SyncRequestOutbox private constructor(
private val safPublisher: ExchangeSafPublisher, private val publisher: DirectExchangePublisher,
) { ) {
constructor(context: Context) : this( constructor(context: Context) : this(
ExchangeSafPublisher { persistedExchangeSafDirectory(context) }, DirectExchangePublisher { directExchangeDirectory(context) },
) )
internal constructor(directoryProvider: () -> ExchangeSafDirectory?) : this( internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this(
ExchangeSafPublisher(directoryProvider), DirectExchangePublisher(directoryProvider),
) )
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) { suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
when (val opened = safPublisher.snapshot()) { when (val opened = publisher.snapshot()) {
is ExchangeSafSnapshotResult.Ready -> requestSync(opened.snapshot) is DirectExchangeSnapshotResult.Ready -> requestSync(opened.snapshot)
is ExchangeSafSnapshotResult.Error -> SyncRequestResult.Error(opened.message) is DirectExchangeSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
} }
} }
internal fun requestSync(snapshot: ExchangeSafSnapshot): SyncRequestResult { internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult {
if ( if (
Build.VERSION.SDK_INT < Build.VERSION.SDK_INT <
Build.VERSION_CODES.Q Build.VERSION_CODES.Q

View file

@ -9,6 +9,7 @@ import android.database.sqlite.SQLiteOpenHelper
import android.util.JsonReader import android.util.JsonReader
import android.util.JsonToken import android.util.JsonToken
import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.AiSessionDraft
import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.BodyObservationDraft
import com.labfytools.trainlog.model.BodyObservationSummary import com.labfytools.trainlog.model.BodyObservationSummary
import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseDataFields
@ -275,6 +276,19 @@ sealed interface ActiveDraftMutationResult {
) : ActiveDraftMutationResult ) : ActiveDraftMutationResult
} }
sealed interface AiSessionDraftImportResult {
data class Applied(val imported: Int, val skipped: Int) : AiSessionDraftImportResult
data class Invalid(val message: String) : AiSessionDraftImportResult
data object DatabaseError : AiSessionDraftImportResult
}
sealed interface StartAiSessionDraftResult {
data object Started : StartAiSessionDraftResult
data object ExistingActiveDraft : StartAiSessionDraftResult
data object NotPending : StartAiSessionDraftResult
data class Error(val message: String) : StartAiSessionDraftResult
}
sealed interface FinalizeActiveDraftResult { sealed interface FinalizeActiveDraftResult {
data class Saved( data class Saved(
val sessionId: String, val sessionId: String,
@ -1318,7 +1332,20 @@ class TrainlogRepository(
if(!c.moveToFirst()) null else (0..12).map{if(c.isNull(it)) null else c.getString(it)}} if(!c.moveToFirst()) null else (0..12).map{if(c.isNull(it)) null else c.getString(it)}}
if(row==null){if(allowPending){skipped++;continue};return ExerciseProfileStateImportResult.Invalid("Identité exercice inconnue : $id")} if(row==null){if(allowPending){skipped++;continue};return ExerciseProfileStateImportResult.Invalid("Identité exercice inconnue : $id")}
val incomingAdj=arrayOf("load_semantics","machine_variant","machine_provenance","scientific_profile_id","science_state","legacy_equipment_id").map{key->if(x.value(key)===JSONObject.NULL)null else x.value(key) as String} val incomingAdj=arrayOf("load_semantics","machine_variant","machine_provenance","scientific_profile_id","science_state","legacy_equipment_id").map{key->if(x.value(key)===JSONObject.NULL)null else x.value(key) as String}
if((4..9).map{row[it]}!=incomingAdj)return ExerciseProfileStateImportResult.Conflict(id) val localAdj=(4..9).map{row[it]}
/* WHY: the V1 catalog intentionally omits machine/science
* identity. A fresh peer therefore creates a completely
* unresolved row before this companion can establish it.
* CONTRACT: only that all-null/unresolved bootstrap may adopt
* PC identity metadata. science_state may remain unresolved;
* any row carrying identity metadata is thereafter immutable. */
val adoptsInitialIdentity = localAdj != incomingAdj &&
localAdj == listOf<String?>(null,null,null,null,"unresolved",null)
if(localAdj!=incomingAdj && !adoptsInitialIdentity)return ExerciseProfileStateImportResult.Conflict(id)
if(adoptsInitialIdentity) db.execSQL(
"UPDATE exercises SET load_semantics=?,machine_variant=?,machine_provenance=?,scientific_profile_id=?,science_state=?,legacy_equipment_id=? WHERE id=?",
arrayOf<Any?>(*incomingAdj.toTypedArray(), row[0]!!.toLong()),
)
val localRevision=row[10]!!;val localParent=row[11];val localLegacy=row[12]=="1" val localRevision=row[10]!!;val localParent=row[11];val localLegacy=row[12]=="1"
val sameProfile=row[1]==recording&&row[2]==tracking&&row[3]?.toInt()==fields val sameProfile=row[1]==recording&&row[2]==tracking&&row[3]?.toInt()==fields
val accept = when { val accept = when {
@ -1328,7 +1355,7 @@ class TrainlogRepository(
db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revision)).use{it.moveToFirst()} -> false db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revision)).use{it.moveToFirst()} -> false
else -> return ExerciseProfileStateImportResult.Conflict(id) else -> return ExerciseProfileStateImportResult.Conflict(id)
} }
if(!accept){skipped++;continue} if(!accept){if(adoptsInitialIdentity)updated++ else skipped++;continue}
val knownCount=db.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",arrayOf(row[0])).use{it.moveToFirst();it.getInt(0)} val knownCount=db.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",arrayOf(row[0])).use{it.moveToFirst();it.getInt(0)}
val additions=revisions.count { revisionRow -> db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revisionRow[0] as String)).use{!it.moveToFirst()} } val additions=revisions.count { revisionRow -> db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revisionRow[0] as String)).use{!it.moveToFirst()} }
if(knownCount+additions>PROFILE_HISTORY_MAX)return ExerciseProfileStateImportResult.Invalid("Historique profile-state hors limite : $id") if(knownCount+additions>PROFILE_HISTORY_MAX)return ExerciseProfileStateImportResult.Invalid("Historique profile-state hors limite : $id")
@ -1572,6 +1599,248 @@ class TrainlogRepository(
) )
} }
/**
* Imports inert desktop proposals only after catalog/profile/equipment
* prerequisites have been reconciled by [SyncCatalogInbox].
*/
fun applyAiSessionDraftsJson(json: String): AiSessionDraftImportResult {
if (!hasStrictJsonShape(json))
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA JSON invalide.")
val root = try { JSONObject(json) } catch (_: Exception) {
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA JSON invalide.")
}
if (!root.hasExactKeys(setOf("format", "version", "generated_at", "drafts")) ||
root.value("format") != "trainlog-ai-session-drafts" ||
!root.value("version").isExactJsonInteger(1) ||
!root.value("generated_at").isNonemptyJsonString() || root.value("drafts") !is JSONArray ||
!isUtcTimestamp(root.optString("generated_at"))) {
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA v1 non supporté.")
}
val drafts = root.getJSONArray("drafts")
if (drafts.length() > MAX_AI_DRAFTS)
return AiSessionDraftImportResult.Invalid("Trop de brouillons IA.")
val db = database.writableDatabase
var imported = 0
var skipped = 0
val seenDrafts = mutableSetOf<String>()
val seenEntries = mutableSetOf<String>()
return try {
db.beginTransaction()
for (draftIndex in 0 until drafts.length()) {
val item = drafts.opt(draftIndex) as? JSONObject
?: throw IllegalArgumentException("Brouillon IA invalide.")
val draftKeys = setOf("draft_id", "created_at", "planned_for", "session_type", "title", "notes", "entries")
if (!item.hasExactKeys(draftKeys))
throw IllegalArgumentException("Champs de brouillon IA invalides.")
val draftId = item.value("draft_id") as? String
?: throw IllegalArgumentException("draft_id invalide.")
val createdAt = item.value("created_at") as? String
?: throw IllegalArgumentException("created_at invalide.")
val entries = item.value("entries") as? JSONArray
?: throw IllegalArgumentException("Entrées de brouillon IA invalides.")
if (!draftId.matches(AI_DRAFT_ID_V4_PATTERN) || !seenDrafts.add(draftId) ||
!isUtcTimestamp(createdAt) || item.value("session_type") != "training" ||
entries.length() !in 1..MAX_AI_DRAFT_ENTRIES)
throw IllegalArgumentException("En-tête de brouillon IA invalide.")
val plannedFor = optionalJsonString(item, "planned_for", 10)
if (plannedFor != null && runCatching { LocalDate.parse(plannedFor) }.getOrNull()?.toString() != plannedFor)
throw IllegalArgumentException("planned_for invalide.")
val title = optionalJsonString(item, "title", 120)
val notes = optionalJsonString(item, "notes", 2000)
val priorState = db.rawQuery(
"SELECT state FROM ai_session_drafts WHERE draft_id=?;", arrayOf(draftId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
if (priorState == null) {
db.insertOrThrow("ai_session_drafts", null, ContentValues().apply {
put("draft_id", draftId); put("created_at", createdAt)
putOptionalString("planned_for", plannedFor); putOptionalString("title", title)
putOptionalString("notes", notes); put("state", "pending")
put("state_changed_at", OffsetDateTime.now(java.time.ZoneOffset.UTC).toString())
})
}
for (entryIndex in 0 until entries.length()) {
val entry = entries.opt(entryIndex) as? JSONObject
?: throw IllegalArgumentException("Entrée de brouillon IA invalide.")
val base = setOf("entry_id", "position", "exercise_id", "recording_mode",
"tracking_mode", "data_fields", "equipment_id", "load_mode", "rest_seconds", "target")
if (!entry.hasExactKeys(base))
throw IllegalArgumentException("Champs d'entrée IA invalides.")
val entryId = entry.value("entry_id") as? String
?: throw IllegalArgumentException("entry_id invalide.")
val exerciseId = entry.value("exercise_id") as? String
?: throw IllegalArgumentException("exercise_id invalide.")
if (!entryId.matches(SESSION_ENTRY_ID_V4_PATTERN) || !seenEntries.add(entryId) ||
!entry.value("position").isExactJsonInteger(entryIndex) ||
!exerciseId.matches(EXERCISE_ID_V4_PATTERN) ||
entry.value("recording_mode") != "sets" ||
!entry.value("data_fields").isExactJsonInteger() ||
entry.value("tracking_mode") !in setOf("reps", "duration"))
throw IllegalArgumentException("Profil d'entrée IA invalide.")
val dataFields = (entry.value("data_fields") as Number).toInt()
if (dataFields !in 0..3)
throw IllegalArgumentException("data_fields IA invalide : $exerciseId")
val canonicalId = resolveExerciseId(db, exerciseId)
val exercise = findExerciseRow(db, "exercise_id=?", arrayOf(canonicalId))
?: throw IllegalArgumentException("Exercice IA inconnu : $exerciseId")
if (exercise.recordingMode != RecordingMode.SETS ||
exercise.trackingMode.wireValue != entry.optString("tracking_mode") || exercise.dataFields != dataFields)
throw IllegalArgumentException("Profil IA divergent : $exerciseId")
val equipmentId = optionalJsonString(entry, "equipment_id", 512)
val equipmentRowId = lookupEquipmentRowIdOrNull(db, equipmentId)
if (equipmentId != null && equipmentRowId == null)
throw IllegalArgumentException("Équipement IA inconnu : $equipmentId")
val loadMode = entry.value("load_mode") as? String
?: throw IllegalArgumentException("load_mode invalide.")
val rest = entry.value("rest_seconds")
if (loadMode !in setOf("none", "external") || !rest.isExactJsonInteger() ||
(rest as Number).toInt() !in 0..MAX_PLAN_REST_SECONDS)
throw IllegalArgumentException("Plan IA invalide.")
val target = entry.value("target") as? JSONObject
?: throw IllegalArgumentException("Cible IA manquante.")
val metric = if (exercise.trackingMode == TrackingMode.REPS) "reps" else "duration_seconds"
val targetKeys = setOf("sets", "reps", "duration_seconds", "weight_kg")
if (!target.hasExactKeys(targetKeys) ||
!target.value("sets").isExactJsonInteger() ||
(target.value("sets") as Number).toInt() !in 1..MAX_AI_TARGET_SETS ||
!target.value(metric).isExactJsonInteger() ||
(target.value(metric) as Number).toInt() !in 1..(if (metric == "reps") MAX_AI_TARGET_REPS else MAX_PLAN_DURATION_SECONDS) ||
!target.isNull(if (metric == "reps") "duration_seconds" else "reps"))
throw IllegalArgumentException("Cible IA invalide.")
val weight = if (target.isNull("weight_kg")) null else target.value("weight_kg")
if (weight != null && (!weight.isPositiveJsonNumber() || (weight as Number).toDouble() > 10000.0))
throw IllegalArgumentException("Charge cible IA invalide.")
if ((weight == null && loadMode != "none") ||
(weight != null && loadMode != "external"))
throw IllegalArgumentException("Mode de charge IA incohérent.")
if (priorState == null) {
db.insertOrThrow("ai_session_draft_entries", null, ContentValues().apply {
put("draft_id", draftId); put("entry_id", entryId); put("position", entryIndex)
put("exercise_row_id", exercise.rowId); put("recording_mode", "sets")
put("tracking_mode", exercise.trackingMode.wireValue); put("data_fields", dataFields)
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
put("load_mode", loadMode); put("rest_seconds", rest.toInt())
put("target_sets", (target.value("sets") as Number).toInt())
if (metric == "reps") { put("target_reps", (target.value(metric) as Number).toInt()); putNull("target_duration_seconds") }
else { putNull("target_reps"); put("target_duration_seconds", (target.value(metric) as Number).toInt()) }
if (weight == null) putNull("target_weight_kg") else put("target_weight_kg", weight.toDouble())
})
}
}
if (priorState == null) imported++ else {
/* CONTRACT: validate the complete companion before replay
* suppression. INVARIANT: started/deleted identities remain
* permanent tombstones and never recreate their children. */
skipped++
}
}
db.setTransactionSuccessful()
AiSessionDraftImportResult.Applied(imported, skipped)
} catch (error: IllegalArgumentException) {
AiSessionDraftImportResult.Invalid(error.message ?: "Brouillon IA invalide.")
} catch (_: Exception) {
AiSessionDraftImportResult.DatabaseError
} finally {
if (db.inTransaction()) db.endTransaction()
}
}
fun listAiSessionDrafts(): List<AiSessionDraft> {
val db = database.readableDatabase
val output = mutableListOf<AiSessionDraft>()
db.rawQuery(
"SELECT draft_id,created_at,planned_for,title,notes FROM ai_session_drafts " +
"WHERE state='pending' ORDER BY COALESCE(planned_for,'9999-12-31'),created_at,draft_id;", null,
).use { drafts -> while (drafts.moveToNext()) {
val draftId = drafts.getString(0)
output += AiSessionDraft(draftId, drafts.getString(1),
if (drafts.isNull(2)) null else drafts.getString(2),
if (drafts.isNull(3)) null else drafts.getString(3),
if (drafts.isNull(4)) null else drafts.getString(4),
readAiSessionDraftEntries(db, draftId))
}}
return output
}
fun deleteAiSessionDraft(draftId: String): ActiveDraftMutationResult = try {
val db = database.writableDatabase
db.beginTransaction()
try {
db.execSQL("UPDATE ai_session_drafts SET state='deleted',state_changed_at=? WHERE draft_id=? AND state='pending';",
arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), draftId))
db.delete("ai_session_draft_entries", "draft_id=?", arrayOf(draftId))
db.setTransactionSuccessful()
} finally { db.endTransaction() }
ActiveDraftMutationResult.Saved
} catch (error: Exception) {
ActiveDraftMutationResult.Error(error.message ?: "Suppression du brouillon IA impossible.")
}
fun startAiSessionDraft(draftId: String): StartAiSessionDraftResult {
val db = database.writableDatabase
return try {
db.beginTransaction()
try {
val active = db.rawQuery("SELECT 1 FROM active_session_draft WHERE id=1;", null).use { it.moveToFirst() }
if (active) return StartAiSessionDraftResult.ExistingActiveDraft
val state = db.rawQuery("SELECT state FROM ai_session_drafts WHERE draft_id=?;", arrayOf(draftId))
.use { if (it.moveToFirst()) it.getString(0) else null }
if (state != "pending") return StartAiSessionDraftResult.NotPending
val entries = readAiSessionDraftEntries(db, draftId)
check(entries.isNotEmpty()) { "Brouillon IA vide." }
/* CONTRACT: copying targets and marking the proposal started is
* one transaction. No performed rows, history, MAX or feedback
* are manufactured by this transition. */
persistActiveSessionDraft(db, ActiveSessionDraft(exercises = entries, sessionType = SessionType.TRAINING))
db.execSQL("UPDATE ai_session_drafts SET state='started',state_changed_at=? WHERE draft_id=?;",
arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), draftId))
db.delete("ai_session_draft_entries", "draft_id=?", arrayOf(draftId))
db.setTransactionSuccessful()
StartAiSessionDraftResult.Started
} finally { db.endTransaction() }
} catch (error: Exception) {
StartAiSessionDraftResult.Error(error.message ?: "Démarrage du brouillon IA impossible.")
}
}
private fun readAiSessionDraftEntries(db: SQLiteDatabase, draftId: String): List<SessionExerciseDraft> {
val output = mutableListOf<SessionExerciseDraft>()
db.rawQuery(
"SELECT de.entry_id,e.exercise_id,e.name,e.normalized_name,de.recording_mode,de.tracking_mode,de.data_fields," +
"eq.equipment_id,de.load_mode,de.rest_seconds,de.target_sets,de.target_reps,de.target_duration_seconds,de.target_weight_kg " +
"FROM ai_session_draft_entries de JOIN exercises e ON e.id=de.exercise_row_id " +
"LEFT JOIN equipment eq ON eq.id=de.equipment_row_id WHERE de.draft_id=? ORDER BY de.position;",
arrayOf(draftId),
).use { cursor -> while (cursor.moveToNext()) {
val profile = ExerciseProfile(cursor.getString(1), cursor.getString(2), cursor.getString(3),
recordingModeFromWire(cursor.getString(4)), trackingModeFromWire(cursor.getString(5)), cursor.getInt(6))
output += SessionExerciseDraft(entryId = cursor.getString(0), exercise = profile,
equipmentId = if (cursor.isNull(7)) null else cursor.getString(7),
plan = SessionExercisePlan(cursor.getInt(10),
reps = if (cursor.isNull(11)) null else cursor.getInt(11),
durationSeconds = if (cursor.isNull(12)) null else cursor.getInt(12),
weightKg = if (cursor.isNull(13)) null else cursor.getDouble(13),
loadMode = SessionLoadMode.fromWire(cursor.getString(8)), restSeconds = cursor.getInt(9)))
}}
return output
}
private fun optionalJsonString(item: JSONObject, key: String, maximumLength: Int): String? {
if (!item.has(key) || item.isNull(key)) return null
val value = item.value(key) as? String ?: throw IllegalArgumentException("$key invalide.")
if (value.isEmpty() || value != value.trim() ||
value.codePointCount(0, value.length) > maximumLength)
throw IllegalArgumentException("$key invalide.")
return value
}
private fun isUtcTimestamp(value: String): Boolean = try {
if (!Regex("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{1,6})?Z$").matches(value)) return false
val parsed = OffsetDateTime.parse(value)
parsed.offset.totalSeconds == 0 && value.endsWith("Z")
} catch (_: Exception) { false }
fun loadActiveSessionDraft(): fun loadActiveSessionDraft():
ActiveDraftLoadResult = ActiveDraftLoadResult =
try { try {
@ -6604,6 +6873,16 @@ private const val DESKTOP_LEG_PRESS_CANONICAL_ID =
private val EXERCISE_ID_V4_PATTERN = Regex( private val EXERCISE_ID_V4_PATTERN = Regex(
"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", "^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
) )
private val AI_DRAFT_ID_V4_PATTERN = Regex(
"^aid_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
)
private val SESSION_ENTRY_ID_V4_PATTERN = Regex(
"^sxe_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
)
private const val MAX_AI_DRAFTS = 256
private const val MAX_AI_DRAFT_ENTRIES = 64
private const val MAX_AI_TARGET_SETS = 99
private const val MAX_AI_TARGET_REPS = 999
private class TrainlogDatabaseHelper( private class TrainlogDatabaseHelper(
private val appContext: Context, private val appContext: Context,
@ -6612,7 +6891,7 @@ private class TrainlogDatabaseHelper(
appContext, appContext,
databaseName, databaseName,
null, null,
16, 17,
) { ) {
override fun onConfigure( override fun onConfigure(
db: SQLiteDatabase, db: SQLiteDatabase,
@ -6653,6 +6932,7 @@ private class TrainlogDatabaseHelper(
createTrainingFeedbackTables(db) createTrainingFeedbackTables(db)
createTrainingFeedbackRevisionTables(db) createTrainingFeedbackRevisionTables(db)
createExerciseProfileStateTable(db, seedLegacy = false) createExerciseProfileStateTable(db, seedLegacy = false)
createAiSessionDraftTables(db)
seedEquipment(db) seedEquipment(db)
} }
@ -6780,6 +7060,14 @@ private class TrainlogDatabaseHelper(
version = 16 version = 16
} }
if (version < 17 && newVersion >= 17) {
/* WHY: PC-authored proposals must survive replay without entering
* the singleton capture draft. CONTRACT: v17 is additive.
* INVARIANT: existing active and completed sessions are untouched. */
createAiSessionDraftTables(db)
version = 17
}
if (version != newVersion) { if (version != newVersion) {
error( error(
"Unsupported Android DB upgrade " + "Unsupported Android DB upgrade " +
@ -6788,6 +7076,33 @@ private class TrainlogDatabaseHelper(
} }
} }
private fun createAiSessionDraftTables(db: SQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +
"draft_id TEXT PRIMARY KEY,created_at TEXT NOT NULL,planned_for TEXT," +
"title TEXT,notes TEXT,state TEXT NOT NULL DEFAULT 'pending' " +
"CHECK(state IN('pending','started','deleted')),state_changed_at TEXT NOT NULL);",
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS ai_session_draft_entries(" +
"id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL REFERENCES ai_session_drafts(draft_id) ON DELETE CASCADE," +
"entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL CHECK(position>=0)," +
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT," +
"recording_mode TEXT NOT NULL CHECK(recording_mode='sets')," +
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration'))," +
"data_fields INTEGER NOT NULL CHECK(data_fields>=0 AND (data_fields & ~3)=0)," +
"equipment_row_id INTEGER REFERENCES equipment(id) ON DELETE RESTRICT," +
"load_mode TEXT NOT NULL CHECK(load_mode IN('none','external'))," +
"rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400)," +
"target_sets INTEGER NOT NULL CHECK(target_sets BETWEEN 1 AND 99)," +
"target_reps INTEGER CHECK(target_reps BETWEEN 1 AND 999)," +
"target_duration_seconds INTEGER CHECK(target_duration_seconds BETWEEN 1 AND 86400)," +
"target_weight_kg REAL CHECK(target_weight_kg>0.0)," +
"CHECK((target_reps IS NULL)!=(target_duration_seconds IS NULL))," +
"UNIQUE(draft_id,position));",
)
}
private fun createBodyZoneTables(db: SQLiteDatabase) { private fun createBodyZoneTables(db: SQLiteDatabase) {
/* CONTRACT: canonical zone definitions remain in the asset; SQLite /* CONTRACT: canonical zone definitions remain in the asset; SQLite
* stores only direct exercise relations and role cardinality. */ * stores only direct exercise relations and role cardinality. */

View file

@ -104,6 +104,20 @@ data class ActiveSessionDraft(
val updatedAt: String = "", val updatedAt: String = "",
) )
/**
* A desktop-authored proposal is inert until the user explicitly starts it.
* INVARIANT: entries contain targets only; performed values remain owned by
* [ActiveSessionDraft] after an atomic start operation.
*/
data class AiSessionDraft(
val draftId: String,
val createdAt: String,
val plannedFor: String? = null,
val title: String? = null,
val notes: String? = null,
val entries: List<SessionExerciseDraft>,
)
data class SessionSummary( data class SessionSummary(
val sessionId: String, val sessionId: String,
val startedAt: String, val startedAt: String,

View file

@ -16,6 +16,7 @@ sealed interface AppRoute {
data object Sessions : AppRoute { override val section = AppSection.SESSIONS } data object Sessions : AppRoute { override val section = AppSection.SESSIONS }
data object SessionEditor : AppRoute { override val section = AppSection.SESSIONS } data object SessionEditor : AppRoute { override val section = AppSection.SESSIONS }
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 CompletedSessions : AppRoute { override val section = AppSection.SESSIONS } data object CompletedSessions : 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 }
@ -157,6 +158,7 @@ internal fun routeTitle(route: AppRoute): String = when (route) {
AppRoute.Sessions -> "Séances" AppRoute.Sessions -> "Séances"
AppRoute.SessionEditor -> "Séance en cours" AppRoute.SessionEditor -> "Séance en cours"
AppRoute.SessionGenerator -> "Programmer une séance" AppRoute.SessionGenerator -> "Programmer une séance"
AppRoute.AiSessionDrafts -> "Brouillons"
AppRoute.CompletedSessions -> "Séances effectuées" AppRoute.CompletedSessions -> "Séances effectuées"
is AppRoute.SessionDetail -> "Détail de la séance" is AppRoute.SessionDetail -> "Détail de la séance"
is AppRoute.SessionCorrection -> "Modifier la séance" is AppRoute.SessionCorrection -> "Modifier la séance"

View file

@ -7,21 +7,27 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.labfytools.trainlog.data.CreateEquipmentResult import com.labfytools.trainlog.data.CreateEquipmentResult
import com.labfytools.trainlog.data.CatalogInboxResult import com.labfytools.trainlog.data.CatalogInboxResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.EquipmentCatalogEntry import com.labfytools.trainlog.data.EquipmentCatalogEntry
import com.labfytools.trainlog.data.EquipmentLoadSemantics import com.labfytools.trainlog.data.EquipmentLoadSemantics
import com.labfytools.trainlog.data.KnowledgeConfidence import com.labfytools.trainlog.data.KnowledgeConfidence
import com.labfytools.trainlog.data.SyncCatalogInbox import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.StartAiSessionDraftResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable @Composable
fun SessionsHub( fun SessionsHub(
draft: ActiveSessionDraft?, draft: ActiveSessionDraft?,
pendingAiDraftCount: Int,
onResume: () -> Unit, onResume: () -> Unit,
onManual: () -> Unit, onManual: () -> Unit,
onDrafts: () -> Unit,
onHistory: () -> Unit, onHistory: () -> Unit,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
@ -32,11 +38,94 @@ fun SessionsHub(
} }
TrainlogFrame("Préparer") { TrainlogFrame("Préparer") {
TrainlogAction("Nouvelle séance manuelle", if (draft == null) "Créer explicitement un brouillon de séance." else "Ouvrir la séance en cours sans l'écraser.", onManual) TrainlogAction("Nouvelle séance manuelle", if (draft == null) "Créer explicitement un brouillon de séance." else "Ouvrir la séance en cours sans l'écraser.", onManual)
TrainlogAction(
"Brouillons",
"$pendingAiDraftCount séance(s) préparée(s) · consulter les propositions importées depuis le PC.",
onDrafts,
)
} }
TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory) TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory)
} }
} }
@Composable
fun AiSessionDraftsScreen(
repository: TrainlogRepository,
externalRevision: Int,
onPendingChanged: () -> Unit = {},
onStarted: () -> Unit,
) {
val colors = LocalTrainlogColors.current
var revision by remember { mutableStateOf(0) }
var message by remember { mutableStateOf<String?>(null) }
var pendingDeletion by remember { mutableStateOf<Pair<String, String>?>(null) }
val drafts = remember(externalRevision, revision) { repository.listAiSessionDrafts() }
TrainlogScreen("Brouillons") {
message?.let { TrainlogInfo(it) }
if (drafts.isEmpty()) TrainlogInfo("Aucun brouillon importé.")
drafts.forEach { draft ->
TrainlogFrame(draft.title ?: "Proposition de séance", active = true) {
TrainlogInfo(listOfNotNull(
draft.plannedFor?.let { "Prévue le $it" },
"${draft.entries.size} exercice(s)",
).joinToString(" · "))
draft.notes?.let { TrainlogInfo(it) }
draft.entries.forEach { entry ->
val plan = checkNotNull(entry.plan)
val metric = plan.reps?.let { "$it répétitions" }
?: "${plan.durationSeconds} secondes"
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
TrainlogInfo("${entry.exercise.name} · ${plan.sets} × $metric$weight · repos ${plan.restSeconds} s")
}
TrainlogPrimaryAction("Démarrer", "Copier cette proposition dans la séance en cours.") {
when (val result = repository.startAiSessionDraft(draft.draftId)) {
StartAiSessionDraftResult.Started -> {
onPendingChanged()
onStarted()
}
StartAiSessionDraftResult.ExistingActiveDraft ->
message = "Une séance est déjà en cours. Le brouillon importé a été conservé."
StartAiSessionDraftResult.NotPending -> {
message = "Ce brouillon n'est plus disponible."
revision++
onPendingChanged()
}
is StartAiSessionDraftResult.Error -> message = result.message
}
}
TrainlogAction(
"Supprimer",
"Conserver un tombstone pour empêcher sa réapparition lors d'un replay.",
onClick = {
pendingDeletion = draft.draftId to (draft.title ?: "Proposition de séance")
},
accent = colors.error,
)
}
}
}
pendingDeletion?.let { (draftId, title) ->
DestructiveConfirmationDialog(
title = "Supprimer ce brouillon ?",
detail = "Le brouillon « $title » sera supprimé.\nCette action est irréversible.",
confirmLabel = "Supprimer",
onCancel = { pendingDeletion = null },
dismissOnClickOutside = true,
) {
/* CONTRACT: dialog confirmation is the sole UI path to the existing
* tombstone mutation. Dismissal never reaches the repository. */
pendingDeletion = null
when (val result = repository.deleteAiSessionDraft(draftId)) {
ActiveDraftMutationResult.Saved -> {
revision++
onPendingChanged()
}
is ActiveDraftMutationResult.Error -> message = result.message
}
}
}
}
@Composable @Composable
fun LatestMaximaScreen(repository: TrainlogRepository) { fun LatestMaximaScreen(repository: TrainlogRepository) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
@ -155,12 +244,12 @@ private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics) = when (value
@Composable @Composable
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) { fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val context = LocalContext.current
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) } var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
var message by remember { mutableStateOf<String?>(null) } var message by remember { mutableStateOf<String?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (uri != null) { authorized = inbox.hasFolderAccess()
authorized = inbox.saveTreeUri(uri) message = if (authorized) "Accès fichiers autorisé." else "Accès fichiers toujours requis."
message = if (authorized) "Dossier d'échange autorisé." else "Autorisation du dossier impossible."
if (authorized) { if (authorized) {
when (val result = inbox.importPcCatalog()) { when (val result = inbox.importPcCatalog()) {
is CatalogInboxResult.Imported -> { is CatalogInboxResult.Imported -> {
@ -173,11 +262,10 @@ fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
} }
} }
} }
}
TrainlogScreen("Paramètres") { TrainlogScreen("Paramètres") {
TrainlogFrame("DOSSIER D'ÉCHANGE") { TrainlogFrame("DOSSIER D'ÉCHANGE") {
TrainlogInfo(if (authorized) "Téléchargements/Trainlog autorisé." else "Aucun dossier Trainlog autorisé.", if (authorized) colors.success else colors.warning) TrainlogInfo(if (authorized) "Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé" else "Accès fichiers requis", if (authorized) colors.success else colors.warning)
TrainlogAction(if (authorized) "Changer de dossier" else "Autoriser le dossier", "Choisir le dossier d'échange avec le sélecteur Android.", { launcher.launch(null) }) TrainlogAction(if (authorized) "Ouvrir les réglages d'accès" else "Autoriser", "Autoriser l'accès au dossier Trainlog dans Android.", { launcher.launch(directStoragePermissionIntent(context)) })
if (authorized) TrainlogAction("Relire le catalogue PC", "Appliquer explicitement le catalogue présent dans le dossier autorisé.", { if (authorized) TrainlogAction("Relire le catalogue PC", "Appliquer explicitement le catalogue présent dans le dossier autorisé.", {
when (val result = inbox.importPcCatalog()) { when (val result = inbox.importPcCatalog()) {
is CatalogInboxResult.Imported -> { message = "Catalogue relu · ${result.imported} nouveau(x), ${result.reconciled} réconcilié(s)."; onCatalogChanged() } is CatalogInboxResult.Imported -> { message = "Catalogue relu · ${result.imported} nouveau(x), ${result.reconciled} réconcilié(s)."; onCatalogChanged() }

View file

@ -4,6 +4,7 @@ package com.labfytools.trainlog.ui
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -18,7 +19,8 @@ import com.labfytools.trainlog.data.SyncExportResult
import com.labfytools.trainlog.data.SyncReceiptResult import com.labfytools.trainlog.data.SyncReceiptResult
import com.labfytools.trainlog.data.SyncRequestOutbox import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.SyncRequestResult import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.data.logExchangeMediaStore import com.labfytools.trainlog.data.logDirectExchange
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -31,19 +33,19 @@ internal suspend fun publishBundleAndRequest(
exporter: SyncExporter, exporter: SyncExporter,
requestOutbox: SyncRequestOutbox, requestOutbox: SyncRequestOutbox,
): SyncRequestResult = withContext(Dispatchers.IO) { ): SyncRequestResult = withContext(Dispatchers.IO) {
logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.begin") logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
when (val opened = exporter.openSafSnapshot()) { when (val opened = exporter.openStorageSnapshot()) {
is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Error -> is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error ->
SyncRequestResult.Error("Préparation Android → PC : ${opened.message}") SyncRequestResult.Error("Préparation Android → PC : ${opened.message}")
is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Ready -> is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready ->
when (val publication = exporter.exportMobileBundle(opened.snapshot)) { when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
is SyncExportResult.Error -> SyncRequestResult.Error( is SyncExportResult.Error -> SyncRequestResult.Error(
"Préparation Android → PC : ${publication.message}", "Préparation Android → PC : ${publication.message}",
) )
is SyncExportResult.Exported -> { is SyncExportResult.Exported -> {
logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.success") logDirectExchange("SYNC_BUNDLE", "coordinator.export.success")
logExchangeMediaStore("trainlog-sync-request-v1.json", "coordinator.request.begin") logDirectExchange("trainlog-sync-request-v1.json", "coordinator.request.begin")
requestOutbox.requestSync(opened.snapshot) requestOutbox.requestSync(opened.snapshot)
} }
} }
@ -61,6 +63,7 @@ fun SyncScreen(
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
var status by var status by
remember { remember {
@ -208,60 +211,29 @@ fun SyncScreen(
null null
} }
val folderLauncher = val permissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = contract = ActivityResultContracts.StartActivityForResult(),
ActivityResultContracts
.OpenDocumentTree(),
) { ) {
uri -> folderAuthorized = inbox.hasFolderAccess()
if (uri != null) { success = folderAuthorized
val saved = status = if (folderAuthorized) {
inbox.saveTreeUri( "Accès fichiers autorisé · Documents/Trainlog prêt."
uri
)
folderAuthorized =
saved
success =
saved
status =
if (saved) {
"Dossier Trainlog autorisé."
} else { } else {
"Autorisation du dossier impossible." "Accès fichiers requis. Activez l'autorisation dans Android."
} }
if (folderAuthorized) {
if (saved) {
coroutineScope.launch { coroutineScope.launch {
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) { when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
is CatalogInboxResult.Imported -> { is CatalogInboxResult.Imported -> {
success = true status = "Accès autorisé · catalogue PC : ${result.imported} nouveau(x), " +
status =
(
"Dossier autorisé · catalogue PC : " +
"${result.imported} nouveau(x), " +
"${result.reconciled} réconcilié(s)." "${result.reconciled} réconcilié(s)."
)
onCatalogChanged() onCatalogChanged()
} }
CatalogInboxResult.FileNotFound ->
CatalogInboxResult.FileNotFound -> { status = "Accès autorisé · aucun catalogue PC reçu."
success = true CatalogInboxResult.FolderNotAuthorized -> folderAuthorized = false
is CatalogInboxResult.Error -> status = result.message
status =
"Dossier autorisé · aucun catalogue PC reçu."
}
CatalogInboxResult.FolderNotAuthorized,
is CatalogInboxResult.Error -> {
/* Keep permission state. */
}
}
} }
} }
} }
@ -305,7 +277,7 @@ fun SyncScreen(
success = false success = false
status = status =
"Autorisez d'abord Téléchargements/Trainlog." "Accès fichiers requis. Autorisez d'abord Documents/Trainlog."
return@TrainlogAction return@TrainlogAction
} }
@ -347,7 +319,7 @@ fun SyncScreen(
) { ) {
if (folderAuthorized) { if (folderAuthorized) {
TrainlogInfo( TrainlogInfo(
"Téléchargements/Trainlog autorisé.", "Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé",
color = color =
colors.success, colors.success,
) )
@ -355,17 +327,11 @@ fun SyncScreen(
TrainlogAction( TrainlogAction(
label = label =
if (folderAuthorized) { if (folderAuthorized) "Ouvrir les réglages d'accès" else "Autoriser l'accès au dossier Trainlog",
"Changer le dossier Trainlog"
} else {
"Autoriser le dossier Trainlog"
},
description = description =
"Choisir Téléchargements/Trainlog.", "Autoriser l'accès pour gérer tous les fichiers dans Android.",
onClick = { onClick = {
folderLauncher.launch( permissionLauncher.launch(directStoragePermissionIntent(context))
null
)
}, },
accent = accent =
colors.warning, colors.warning,
@ -397,7 +363,7 @@ fun SyncScreen(
success = false success = false
status = status =
"Autorisez d'abord Téléchargements/Trainlog." "Accès fichiers requis pour Documents/Trainlog."
} }
CatalogInboxResult.FileNotFound -> { CatalogInboxResult.FileNotFound -> {

View file

@ -53,6 +53,9 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() } val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft
val pendingAiDraftCount = remember(draftRevision, catalogRevision) {
repository.listAiSessionDrafts().size
}
fun open(route: AppRoute) { navigationController.open(route) } fun open(route: AppRoute) { navigationController.open(route) }
fun openSection(section: AppSection) { navigationController.openSection(section) } fun openSection(section: AppSection) { navigationController.openSection(section) }
@ -94,7 +97,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
{ open(AppRoute.Statistics) }, { open(AppRoute.Statistics) },
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) }, { open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
) )
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) }) AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) })
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 = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée." draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."
@ -105,6 +109,12 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
* Back and drawer navigation. */ * Back and drawer navigation. */
openSection(AppSection.SESSIONS) openSection(AppSection.SESSIONS)
}) })
AppRoute.AiSessionDrafts -> AiSessionDraftsScreen(
repository = repository,
externalRevision = catalogRevision,
onPendingChanged = { draftRevision++ },
onStarted = { open(AppRoute.SessionEditor) },
)
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) } AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
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 ->

View file

@ -24,6 +24,7 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -377,14 +378,24 @@ fun DestructiveConfirmationDialog(
detail: String, detail: String,
confirmLabel: String, confirmLabel: String,
onCancel: () -> Unit, onCancel: () -> Unit,
dismissOnClickOutside: Boolean = false,
onConfirm: () -> Unit, onConfirm: () -> Unit,
) { ) {
val colors = LocalTrainlogColors.current
AlertDialog( AlertDialog(
onDismissRequest = onCancel, onDismissRequest = onCancel,
title = { Text(title) }, title = { Text(title) },
text = { Text(detail) }, text = { Text(detail) },
dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } }, dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } },
confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } }, confirmButton = {
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false), TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
) { Text(confirmLabel) }
},
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = dismissOnClickOutside,
),
) )
} }

View file

@ -0,0 +1,283 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.TrackingMode
import org.json.JSONArray
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.UUID
import java.io.File
import java.nio.file.Files
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AiSessionDraftRepositoryTest {
private lateinit var context: Context
private lateinit var databaseName: String
private lateinit var repository: TrainlogRepository
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseName = "ai-drafts-${UUID.randomUUID()}.db"
repository = TrainlogRepository(context, databaseName)
}
@After
fun tearDown() {
repository.close()
context.deleteDatabase(databaseName)
}
@Test
fun importReplayAndDeleteTombstoneNeverDuplicateOrResurrect() {
val exercise = exercise()
val json = artifact(exercise.exerciseId)
assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applyAiSessionDraftsJson(json))
assertEquals(AiSessionDraftImportResult.Applied(0, 1), repository.applyAiSessionDraftsJson(json))
assertEquals(1, repository.listAiSessionDrafts().size)
assertEquals(ActiveDraftMutationResult.Saved, repository.deleteAiSessionDraft(DRAFT_ID))
assertTrue(repository.listAiSessionDrafts().isEmpty())
assertEquals(AiSessionDraftImportResult.Applied(0, 1), repository.applyAiSessionDraftsJson(json))
assertTrue(repository.listAiSessionDrafts().isEmpty())
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
assertTrue(repository.listSessions().isEmpty())
}
@Test
fun pendingDraftIsListed() {
val exercise = exercise()
assertEquals(AiSessionDraftImportResult.Applied(1, 0),
repository.applyAiSessionDraftsJson(artifact(exercise.exerciseId)))
assertEquals(listOf(DRAFT_ID), repository.listAiSessionDrafts().map { it.draftId })
}
@Test
fun startedDraftIsExcludedFromPendingList() {
val exercise = exercise()
repository.applyAiSessionDraftsJson(artifact(exercise.exerciseId))
assertEquals(StartAiSessionDraftResult.Started, repository.startAiSessionDraft(DRAFT_ID))
assertTrue(repository.listAiSessionDrafts().isEmpty())
}
@Test
fun deletedDraftIsExcludedFromPendingList() {
val exercise = exercise()
repository.applyAiSessionDraftsJson(artifact(exercise.exerciseId))
assertEquals(ActiveDraftMutationResult.Saved, repository.deleteAiSessionDraft(DRAFT_ID))
assertTrue(repository.listAiSessionDrafts().isEmpty())
}
@Test
fun twoPendingDraftsUseDeterministicPlannedDateOrder() {
val exercise = exercise()
val laterDraftId = "aid_22345678-1234-4abc-8abc-123456789abc"
val laterEntryId = "sxe_bbcdefab-cdef-4abc-8abc-abcdefabcdef"
val drafts = JSONArray()
.put(JSONObject(artifact(exercise.exerciseId, draftId = laterDraftId,
entryId = laterEntryId, plannedFor = "2026-09-16")).getJSONArray("drafts").getJSONObject(0))
.put(JSONObject(artifact(exercise.exerciseId, plannedFor = "2026-09-15"))
.getJSONArray("drafts").getJSONObject(0))
val json = JSONObject().put("format", "trainlog-ai-session-drafts").put("version", 1)
.put("generated_at", "2026-09-14T08:00:00Z").put("drafts", drafts).toString()
assertEquals(AiSessionDraftImportResult.Applied(2, 0), repository.applyAiSessionDraftsJson(json))
assertEquals(listOf(DRAFT_ID, laterDraftId), repository.listAiSessionDrafts().map { it.draftId })
}
@Test
fun explicitStartCopiesTargetsOnlyAndTombstonesProposal() {
val exercise = exercise()
assertEquals(AiSessionDraftImportResult.Applied(1, 0),
repository.applyAiSessionDraftsJson(artifact(exercise.exerciseId)))
assertEquals(StartAiSessionDraftResult.Started, repository.startAiSessionDraft(DRAFT_ID))
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(1, active.exercises.size)
assertEquals(3, active.exercises.single().plan?.sets)
assertEquals(10, active.exercises.single().plan?.reps)
assertTrue(active.exercises.single().sets.isEmpty())
assertTrue(repository.listAiSessionDrafts().isEmpty())
assertTrue(repository.listSessions().isEmpty())
assertEquals(AiSessionDraftImportResult.Applied(0, 1),
repository.applyAiSessionDraftsJson(artifact(exercise.exerciseId)))
}
@Test
fun activeDraftConflictLeavesBothDraftsUnchanged() {
val exercise = exercise()
repository.applyAiSessionDraftsJson(artifact(exercise.exerciseId))
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
val before = repository.loadActiveSessionDraft()
assertEquals(StartAiSessionDraftResult.ExistingActiveDraft, repository.startAiSessionDraft(DRAFT_ID))
assertEquals(before, repository.loadActiveSessionDraft())
assertEquals(listOf(DRAFT_ID), repository.listAiSessionDrafts().map { it.draftId })
}
@Test
fun strictParserRejectsUnknownKeysAndNonUtcTimeWithoutWrites() {
val exercise = exercise()
val unknown = JSONObject(artifact(exercise.exerciseId)).put("extra", true).toString()
assertTrue(repository.applyAiSessionDraftsJson(unknown) is AiSessionDraftImportResult.Invalid)
val nonUtc = artifact(exercise.exerciseId).replace("2026-09-14T08:00:00Z", "2026-09-14T10:00:00+02:00")
assertTrue(repository.applyAiSessionDraftsJson(nonUtc) is AiSessionDraftImportResult.Invalid)
assertTrue(repository.listAiSessionDrafts().isEmpty())
}
@Test
fun syncInboxImportsExactCompanionFilenameWithExistingPrerequisites() {
val exercise = exercise()
val directory = Files.createTempDirectory("trainlog-ai-inbox-").toFile()
try {
File(directory, "trainlog-ai-session-drafts-v1.json").writeText(artifact(exercise.exerciseId))
val inbox = SyncCatalogInbox(context, repository)
assertEquals(null, inbox.importAiSessionDraftsFromDirectoryForTest(directory))
assertEquals(listOf(DRAFT_ID), repository.listAiSessionDrafts().map { it.draftId })
} finally {
directory.deleteRecursively()
}
}
@Test
fun importsAliasNonzeroFieldsAndUpperTargetBoundsUsingCanonicalExercise() {
val source = exercise("Source")
val canonical = exercise("Canonique")
val aliases = JSONObject().put("format", "trainlog-exercise-aliases").put("version", 1)
.put("aliases", JSONArray().put(JSONObject()
.put("source_exercise_id", source.exerciseId)
.put("canonical_exercise_id", canonical.exerciseId))).toString()
assertEquals(ExerciseAliasImportResult.Applied(1, 0), repository.applyExerciseAliasesJson(aliases))
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null,
SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("PRAGMA ignore_check_constraints=ON")
db.execSQL("UPDATE exercises SET data_fields=3 WHERE exercise_id=?", arrayOf(canonical.exerciseId))
}
assertEquals(AiSessionDraftImportResult.Applied(1, 0),
repository.applyAiSessionDraftsJson(artifact(source.exerciseId, 3, 99, 999)))
val entry = repository.listAiSessionDrafts().single().entries.single()
assertEquals(canonical.exerciseId, entry.exercise.exerciseId)
assertEquals(3, entry.exercise.dataFields)
assertEquals(99, entry.plan?.sets)
assertEquals(999, entry.plan?.reps)
}
@Test
fun rejectsUnknownAliasAssistanceAndRepsAboveSharedLimit() {
val exercise = exercise()
assertTrue(repository.applyAiSessionDraftsJson(
artifact("ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")) is AiSessionDraftImportResult.Invalid)
assertTrue(repository.applyAiSessionDraftsJson(
artifact(exercise.exerciseId).replace("\"load_mode\":\"none\"", "\"load_mode\":\"assistance\""))
is AiSessionDraftImportResult.Invalid)
assertTrue(repository.applyAiSessionDraftsJson(
artifact(exercise.exerciseId, targetReps = 1000)) is AiSessionDraftImportResult.Invalid)
}
@Test fun `replay validates full companion and optional text is canonical`() {
val exercise = exercise()
val valid = artifact(exercise.exerciseId)
assertTrue(repository.applyAiSessionDraftsJson(valid) is AiSessionDraftImportResult.Applied)
val invalidReplay = valid.replace("\"reps\":10", "\"reps\":1000")
assertTrue(repository.applyAiSessionDraftsJson(invalidReplay) is AiSessionDraftImportResult.Invalid)
assertTrue(repository.applyAiSessionDraftsJson(
valid.replace("Haut du corps", " Haut du corps")) is AiSessionDraftImportResult.Invalid)
}
@Test fun `optional title and notes limits count Unicode code points atomically`() {
val exercise = exercise()
val glyph = "\uD83D\uDE00"
assertTrue(repository.applyAiSessionDraftsJson(artifact(exercise.exerciseId,
title = glyph.repeat(120), notes = glyph.repeat(2000))) is AiSessionDraftImportResult.Applied)
assertEquals(1, repository.listAiSessionDrafts().size)
val first = JSONObject(artifact(exercise.exerciseId, draftId =
"aid_22345678-1234-4abc-8abc-123456789abc", entryId =
"sxe_bbcdefab-cdef-4abc-8abc-abcdefabcdef")).getJSONArray("drafts").getJSONObject(0)
val tooLongTitle = JSONObject(artifact(exercise.exerciseId, draftId =
"aid_32345678-1234-4abc-8abc-123456789abc", entryId =
"sxe_cbcdefab-cdef-4abc-8abc-abcdefabcdef", title = glyph.repeat(121)))
.getJSONArray("drafts").getJSONObject(0)
val tooLongNotes = JSONObject(artifact(exercise.exerciseId, draftId =
"aid_42345678-1234-4abc-8abc-123456789abc", entryId =
"sxe_dbcdefab-cdef-4abc-8abc-abcdefabcdef", notes = glyph.repeat(2001)))
.getJSONArray("drafts").getJSONObject(0)
fun companion(second: JSONObject) = JSONObject().put("format", "trainlog-ai-session-drafts")
.put("version", 1).put("generated_at", "2026-09-14T08:00:00Z")
.put("drafts", JSONArray().put(first).put(second)).toString()
assertTrue(repository.applyAiSessionDraftsJson(companion(tooLongTitle)) is AiSessionDraftImportResult.Invalid)
assertTrue(repository.applyAiSessionDraftsJson(companion(tooLongNotes)) is AiSessionDraftImportResult.Invalid)
assertEquals(1, repository.listAiSessionDrafts().size)
assertTrue(repository.applyAiSessionDraftsJson(artifact(exercise.exerciseId,
draftId = "aid_52345678-1234-4abc-8abc-123456789abc", entryId =
"sxe_ebcdefab-cdef-4abc-8abc-abcdefabcdef", title = "x".repeat(120),
notes = "x".repeat(2000))) is AiSessionDraftImportResult.Applied)
}
private fun exercise(name: String = "Développé test", dataFields: Int = 0) = (repository.createExercise(NewExerciseProfile(
name = name, recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS, dataFields = dataFields,
)) as CreateExerciseResult.Created).exercise
private fun artifact(
exerciseId: String,
dataFields: Int = 0,
targetSets: Int = 3,
targetReps: Int = 10,
draftId: String = DRAFT_ID,
entryId: String = ENTRY_ID,
plannedFor: String = "2026-09-15",
title: String = "Haut du corps",
notes: String = "Proposition de test",
): String {
val target = JSONObject().put("sets", targetSets).put("reps", targetReps)
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)
val entry = JSONObject()
.put("entry_id", entryId)
.put("position", 0)
.put("exercise_id", exerciseId)
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", dataFields)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", "none")
.put("rest_seconds", 90)
.put("target", target)
val draft = JSONObject()
.put("draft_id", draftId)
.put("created_at", "2026-09-14T08:00:00Z")
.put("planned_for", plannedFor)
.put("session_type", "training")
.put("title", title)
.put("notes", notes)
.put("entries", JSONArray().put(entry))
return JSONObject()
.put("format", "trainlog-ai-session-drafts")
.put("version", 1)
.put("generated_at", "2026-09-14T08:00:00Z")
.put("drafts", JSONArray().put(draft))
.toString()
}
companion object {
private const val DRAFT_ID = "aid_12345678-1234-4abc-8abc-123456789abc"
private const val ENTRY_ID = "sxe_abcdefab-cdef-4abc-8abc-abcdefabcdef"
}
}

View file

@ -61,7 +61,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(16, scalar(db, "PRAGMA user_version")) assertEquals(17, 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"))

View file

@ -64,7 +64,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(16, scalar(db, "PRAGMA user_version")) assertEquals(17, 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") }

View file

@ -0,0 +1,98 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.UUID
/** Physical v16 fixture: v17 adds only the separate inert AI proposal tables. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV16AiSessionDraftMigrationTest {
private lateinit var context: Context
private lateinit var name: String
@Before fun setUp() {
context = ApplicationProvider.getApplicationContext()
name = "ai-drafts-v16-${UUID.randomUUID()}.db"
}
@After fun tearDown() { context.deleteDatabase(name) }
@Test fun v16FixtureAddsEmptyAiTablesWithoutChangingHistoryOrActiveDraft() {
val repository = TrainlogRepository(context, name)
val exercise = (repository.createExercise(NewExerciseProfile(
"V16 migration fixture", RecordingMode.SETS, TrackingMode.REPS, 0,
)) as CreateExerciseResult.Created).exercise
assertTrue(repository.saveSession(SessionDraft(listOf(SessionExerciseDraft(
entryId = "sxe_11111111-1111-4111-8111-111111111111", exercise = exercise,
sets = listOf(SessionSetDraft(reps = 8, weightKg = 50.0)),
)))) is SaveSessionResult.Saved)
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(SessionExerciseDraft(
entryId = "sxe_22222222-2222-4222-8222-222222222222", exercise = exercise,
sets = listOf(SessionSetDraft(reps = 5, weightKg = 30.0)),
))),
))
repository.close()
val path = context.getDatabasePath(name).path
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("DROP TABLE ai_session_draft_entries")
db.execSQL("DROP TABLE ai_session_drafts")
db.execSQL("PRAGMA user_version=16")
}
val before = SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
scalar(db, "SELECT COUNT(*) FROM active_session_draft"),
scalar(db, "SELECT COUNT(*) FROM draft_session_exercises"),
scalar(db, "SELECT COUNT(*) FROM draft_performed_sets"),
)
}
TrainlogRepository(context, name).let { migrated ->
migrated.listSessions()
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(17, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
scalar(db, "SELECT COUNT(*) FROM active_session_draft"),
scalar(db, "SELECT COUNT(*) FROM draft_session_exercises"),
scalar(db, "SELECT COUNT(*) FROM draft_performed_sets"),
))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM ai_session_drafts"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM ai_session_draft_entries"))
assertEquals("ok", string(db, "PRAGMA integrity_check"))
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
}
}
private fun scalar(db: SQLiteDatabase, sql: String) = db.rawQuery(sql, null).use {
assertTrue(it.moveToFirst()); it.getInt(0)
}
private fun string(db: SQLiteDatabase, sql: String) = db.rawQuery(sql, null).use {
assertTrue(it.moveToFirst()); it.getString(0)
}
}

View file

@ -323,7 +323,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(16, cursor.getInt(0)) assertTrue(cursor.moveToFirst()); assertEquals(17, 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))

View file

@ -0,0 +1,162 @@
package com.labfytools.trainlog.data
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.Settings
import androidx.test.core.app.ApplicationProvider
import java.io.File
import java.nio.file.Files
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
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 DirectExchangeStorageTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test fun `missing permission backend refuses publication explicitly`() {
val result = DirectExchangePublisher { null }.snapshot()
assertTrue(result is DirectExchangeSnapshotResult.Error)
assertTrue((result as DirectExchangeSnapshotResult.Error).message.contains("Accès fichiers requis"))
}
@Test fun `manifest and authorization action expose all files access`() {
val permission = context.packageManager.getPackageInfo(
context.packageName,
PackageManager.GET_PERMISSIONS,
).requestedPermissions.orEmpty()
assertTrue(permission.contains(Manifest.permission.MANAGE_EXTERNAL_STORAGE))
val intent = directStoragePermissionIntent(context)
assertTrue(
intent.action == Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION ||
intent.action == Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION,
)
if (intent.action == Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) {
assertEquals("package:${context.packageName}", intent.dataString)
}
}
@Test fun `directory is created and canonical file is atomically replaced`() {
val root = Files.createTempDirectory("trainlog-direct-storage-").toFile()
val directory = File(root, "Documents/Trainlog")
try {
val backend = directExchangeDirectoryForPath(directory)
assertNotNull(backend)
assertTrue(directory.isDirectory)
val publisher = DirectExchangePublisher { backend }
repeat(10) { publication ->
val snapshot = (publisher.snapshot() as DirectExchangeSnapshotResult.Ready).snapshot
assertEquals(null, snapshot.writeJson("artifact.json", "value-$publication"))
}
assertEquals("value-9", File(directory, "artifact.json").readText())
assertEquals(listOf("artifact.json"), directory.listFiles().orEmpty().map(File::getName))
assertFalse(directory.listFiles().orEmpty().any { it.name.matches(Regex("artifact \\(\\d+\\)\\.json")) })
} finally {
root.deleteRecursively()
}
}
@Test fun `historical suffixes and Download backup remain untouched`() {
val root = Files.createTempDirectory("trainlog-direct-legacy-").toFile()
val active = File(root, "Documents/Trainlog").apply { mkdirs() }
val legacy = File(root, "Download/Trainlog").apply { mkdirs() }
val recovery = File(root, "Download/Trainlog-saf-recovery-20260914").apply { mkdirs() }
File(active, "artifact.json").writeText("old")
File(active, "artifact (1).json").writeText("history")
File(legacy, "keep.json").writeText("legacy")
File(recovery, "keep.json").writeText("recovery")
try {
val backend = checkNotNull(directExchangeDirectoryForPath(active))
val snapshot = (DirectExchangePublisher { backend }.snapshot() as DirectExchangeSnapshotResult.Ready).snapshot
assertEquals(null, snapshot.writeJson("artifact.json", "new"))
assertEquals("new", File(active, "artifact.json").readText())
assertEquals("history", File(active, "artifact (1).json").readText())
assertEquals("legacy", File(legacy, "keep.json").readText())
assertEquals("recovery", File(recovery, "keep.json").readText())
} finally {
root.deleteRecursively()
}
}
@Test fun `fresh database installs catalog before aliases in one inbox pass`() {
val directory = Files.createTempDirectory("trainlog-direct-fresh-").toFile()
val databaseName = "direct-fresh-${UUID.randomUUID()}.db"
val sourceId = "ex_11111111-1111-4111-8111-111111111111"
val canonicalId = "ex_22222222-2222-4222-8222-222222222222"
val repository = TrainlogRepository(context, databaseName)
try {
fun exercise(id: String, name: String) = JSONObject()
.put("exercise_id", id).put("name", name)
.put("recording_mode", "sets").put("tracking_mode", "reps")
.put("data_fields", 0)
File(directory, "trainlog-pc-catalog-v1.json").writeText(
JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", JSONArray().put(exercise(sourceId, "Source"))
.put(exercise(canonicalId, "Canonique"))).toString(),
)
File(directory, "trainlog-exercise-aliases-v1.json").writeText(
JSONObject().put("format", "trainlog-exercise-aliases").put("version", 1)
.put("aliases", JSONArray().put(JSONObject()
.put("source_exercise_id", sourceId)
.put("canonical_exercise_id", canonicalId))).toString(),
)
val result = SyncCatalogInbox(context, repository)
.importPcCatalogFromDirectoryForTest(directory)
assertTrue(result is CatalogInboxResult.Imported)
assertEquals(listOf(canonicalId), repository.listExercises().map { it.exerciseId })
} finally {
repository.close()
context.deleteDatabase(databaseName)
directory.deleteRecursively()
}
}
@Test fun `fresh catalog row adopts only initial resolved machine identity`() {
val databaseName = "direct-profile-${UUID.randomUUID()}.db"
val id = "ex_33333333-3333-4333-8333-333333333333"
val repository = TrainlogRepository(context, databaseName)
try {
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", JSONArray().put(JSONObject().put("exercise_id", id)
.put("name", "Machine").put("recording_mode", "sets")
.put("tracking_mode", "reps").put("data_fields", 0)))
assertTrue(repository.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
val history = JSONArray().put(JSONObject().put("revision_id", "pr_legacy_v1")
.put("parent_revision_id", JSONObject.NULL).put("recording_mode", "sets")
.put("tracking_mode", "reps").put("data_fields", 0).put("legacy_seed", true))
val item = JSONObject().put("exercise_id", id).put("recording_mode", "sets")
.put("tracking_mode", "reps").put("data_fields", 0)
.put("load_semantics", "external").put("machine_variant", "plate_loaded")
.put("machine_provenance", JSONObject.NULL).put("scientific_profile_id", "sp_test_v1")
.put("science_state", "unresolved").put("legacy_equipment_id", "test_machine")
.put("revision_id", "pr_legacy_v1").put("parent_revision_id", JSONObject.NULL)
.put("legacy_seed", true).put("history", history)
val profile = JSONObject().put("format", "trainlog-exercise-profile-state")
.put("version", 1).put("generated_at", "2026-09-14T18:00:00+02:00")
.put("exercises", JSONArray().put(item))
assertTrue(repository.applyExerciseProfileStateJson(profile.toString()) is ExerciseProfileStateImportResult.Applied)
val published = JSONObject(repository.buildExerciseProfileStateJson())
.getJSONArray("exercises").getJSONObject(0)
assertEquals("unresolved", published.getString("science_state"))
assertEquals("sp_test_v1", published.getString("scientific_profile_id"))
val changed = JSONObject(profile.toString())
changed.getJSONArray("exercises").getJSONObject(0).put("machine_variant", "changed")
assertEquals(ExerciseProfileStateImportResult.Conflict(id),
repository.applyExerciseProfileStateJson(changed.toString()))
} finally {
repository.close()
context.deleteDatabase(databaseName)
}
}
}

View file

@ -125,7 +125,7 @@ class RealAndroidV12MachineExerciseMigrationTest {
private fun assertValidVersionThirteen(databasePath: File) { private fun assertValidVersionThirteen(databasePath: File) {
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database -> SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
assertEquals(16, scalarInt(database, "PRAGMA user_version;")) assertEquals(17, scalarInt(database, "PRAGMA user_version;"))
assertEquals("ok", scalarString(database, "PRAGMA integrity_check;")) assertEquals("ok", scalarString(database, "PRAGMA integrity_check;"))
database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) } database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
} }

View file

@ -33,7 +33,7 @@ class RealAndroidV14FeedbackRevisionMigrationTest {
} }
TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() } TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() }
SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db-> SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db->
assertEquals(16,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())} assertEquals(17,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())}
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_state WHERE revision_id='pr_legacy_v1' AND legacy_seed=1")) assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_state WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_revisions WHERE revision_id='pr_legacy_v1' AND legacy_seed=1")) assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_revisions WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
assertEquals(before.first,values(db,"SELECT entry_id FROM draft_session_exercises ORDER BY entry_id"));assertEquals(before.second,values(db,"SELECT feedback_id||'|'||observed_at||'|'||raw_text FROM draft_exercise_feedback ORDER BY feedback_id")) assertEquals(before.first,values(db,"SELECT entry_id FROM draft_session_exercises ORDER BY entry_id"));assertEquals(before.second,values(db,"SELECT feedback_id||'|'||observed_at||'|'||raw_text FROM draft_exercise_feedback ORDER BY feedback_id"))

View file

@ -3,7 +3,6 @@ package com.labfytools.trainlog.data
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.documentfile.provider.DocumentFile
import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.BodyObservationDraft
import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseDataFields
@ -1669,7 +1668,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(16, cursor.getInt(0)) assertEquals(17, 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 " +
@ -1796,7 +1795,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(16, cursor.getInt(0)) assertEquals(17, 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())
@ -2287,12 +2286,11 @@ class TrainlogRepositoryDraftTest {
v3File.writeText(malformedV3.toString()) v3File.writeText(malformedV3.toString())
File(directory, "trainlog-pc-mobile-export-v2.json").writeText(validV2.toString()) File(directory, "trainlog-pc-mobile-export-v2.json").writeText(validV2.toString())
val inbox = SyncCatalogInbox(context, destination) val inbox = SyncCatalogInbox(context, destination)
val documentDirectory = DocumentFile.fromFile(directory) val error = inbox.importPcSessionsFromDirectoryForTest(directory)
val error = inbox.importPcSessionsFromDirectoryForTest(documentDirectory)
assertTrue(error!!.contains("started_at")) assertTrue(error!!.contains("started_at"))
assertTrue(destination.listSessions().isEmpty()) assertTrue(destination.listSessions().isEmpty())
assertTrue(v3File.delete()) assertTrue(v3File.delete())
assertEquals(null, inbox.importPcSessionsFromDirectoryForTest(documentDirectory)) assertEquals(null, inbox.importPcSessionsFromDirectoryForTest(directory))
assertEquals(1, destination.listSessions().size) assertEquals(1, destination.listSessions().size)
} finally { } finally {
directory.deleteRecursively() directory.deleteRecursively()

View file

@ -0,0 +1,272 @@
package com.labfytools.trainlog.ui
import android.content.Context
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.click
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.AiSessionDraftImportResult
import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.TrainlogTheme
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
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 AiSessionDraftUiWiringTest {
@Suppress("DEPRECATION")
@get:Rule val compose = createComposeRule()
private lateinit var context: Context
private lateinit var databaseName: String
private lateinit var repository: TrainlogRepository
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseName = "ai-draft-ui-${UUID.randomUUID()}.db"
repository = TrainlogRepository(context, databaseName)
}
@After
fun tearDown() {
repository.close()
context.deleteDatabase(databaseName)
}
@Test
fun sessionsHubAlwaysExposesBrouillonsAndZeroCount() {
compose.setContent { TrainlogTheme {
SessionsHub(null, 0, {}, {}, {}, {})
} }
compose.onNodeWithText("Brouillons").assertIsDisplayed()
compose.onNodeWithText("0 séance(s) préparée(s)", substring = true).assertIsDisplayed()
}
@Test
fun sessionsHubShowsPendingCountAndPreservesManualAndHistoryActions() {
var manualCalls = 0
var historyCalls = 0
compose.setContent { TrainlogTheme {
SessionsHub(null, 2, {}, { manualCalls++ }, {}, { historyCalls++ })
} }
compose.onNodeWithText("2 séance(s) préparée(s)", substring = true).assertIsDisplayed()
compose.onNodeWithText("Nouvelle séance manuelle").performClick()
compose.onNodeWithText("Séances effectuées").performClick()
compose.runOnIdle {
assertEquals(1, manualCalls)
assertEquals(1, historyCalls)
}
}
@Test
fun draftScreenShowsDosBicepsAndTargetDetails() {
importDraft()
compose.setContent { TrainlogTheme {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} }
compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
compose.onNodeWithText("Tractions supination · 3 × 10 répétitions · repos 90 s")
.assertIsDisplayed()
compose.onNodeWithText("Accent sur le dos et les biceps.").assertIsDisplayed()
}
@Test
fun companionImportAppearsAfterExternalRevisionChanges() {
val externalRevision = mutableIntStateOf(0)
compose.setContent { TrainlogTheme {
AiSessionDraftsScreen(repository, externalRevision.intValue, onStarted = {})
} }
compose.onNodeWithText("Aucun brouillon importé.").assertIsDisplayed()
importDraft()
compose.runOnIdle { externalRevision.intValue++ }
compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
}
@Test
fun renderingPendingDraftDoesNotCreateActiveSession() {
importDraft()
compose.setContent { TrainlogTheme {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} }
compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
}
@Test
fun startActionUsesRepositoryTransactionAndNotifiesOwner() {
importDraft()
var pendingChanges = 0
var starts = 0
compose.setContent { TrainlogTheme {
AiSessionDraftsScreen(
repository,
externalRevision = 0,
onPendingChanged = { pendingChanges++ },
onStarted = { starts++ },
)
} }
compose.onNodeWithText("Démarrer").performClick()
compose.runOnIdle {
assertEquals(1, pendingChanges)
assertEquals(1, starts)
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(3, active.draft.exercises.single().plan?.sets)
assertEquals(10, active.draft.exercises.single().plan?.reps)
assertTrue(active.draft.exercises.single().sets.isEmpty())
assertTrue(repository.listAiSessionDrafts().isEmpty())
}
}
@Test
fun deleteActionOnlyOpensConfirmationWithoutMutation() {
importDraft()
compose.setContent { TrainlogTheme {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} }
compose.onNodeWithText("Supprimer").performClick()
compose.onNodeWithText("Supprimer ce brouillon ?").assertIsDisplayed()
compose.onNodeWithText("Le brouillon « Dos + biceps » sera supprimé.", substring = true)
.assertIsDisplayed()
compose.runOnIdle { assertEquals(1, repository.listAiSessionDrafts().size) }
}
@Test
fun cancelDeleteKeepsPendingDraft() {
importDraft()
compose.setContent { TrainlogTheme {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} }
compose.onNodeWithText("Supprimer").performClick()
compose.onNodeWithText("Annuler").performClick()
compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
compose.runOnIdle { assertEquals(1, repository.listAiSessionDrafts().size) }
}
@Test
fun backDismissesDeleteAndKeepsPendingDraft() {
importDraft()
compose.setContent { TrainlogTheme {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} }
compose.onNodeWithText("Supprimer").performClick()
compose.onNodeWithText("Supprimer ce brouillon ?").performKeyInput {
pressKey(Key.Back)
}
compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
compose.runOnIdle { assertEquals(1, repository.listAiSessionDrafts().size) }
}
@Test
fun outsideTapDismissesDeleteAndKeepsPendingDraft() {
importDraft()
compose.setContent { TrainlogTheme {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} }
compose.onNodeWithText("Supprimer").performClick()
compose.onNodeWithText("Supprimer ce brouillon ?").performTouchInput {
click(Offset(-40f, -40f))
}
compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
compose.runOnIdle { assertEquals(1, repository.listAiSessionDrafts().size) }
}
@Test
fun confirmedDeleteCreatesTombstoneAndRemovesPendingDraft() {
val payload = importDraft()
var pendingChanges = 0
compose.setContent { TrainlogTheme {
AiSessionDraftsScreen(
repository,
externalRevision = 0,
onPendingChanged = { pendingChanges++ },
onStarted = {},
)
} }
compose.onNodeWithText("Supprimer").performClick()
compose.onNodeWithText("Supprimer ce brouillon ?").assertIsDisplayed()
compose.onAllNodesWithText("Supprimer")[1].performClick()
compose.onNodeWithText("Aucun brouillon importé.").assertIsDisplayed()
compose.runOnIdle {
assertEquals(1, pendingChanges)
assertTrue(repository.listAiSessionDrafts().isEmpty())
assertTrue(repository.applyAiSessionDraftsJson(payload) is AiSessionDraftImportResult.Applied)
assertTrue(repository.listAiSessionDrafts().isEmpty())
}
}
private fun importDraft(): String {
val exercise = (repository.createExercise(NewExerciseProfile(
name = "Tractions supination",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) as CreateExerciseResult.Created).exercise
val payload = artifact(exercise.exerciseId)
assertTrue(repository.applyAiSessionDraftsJson(payload)
is AiSessionDraftImportResult.Applied)
return payload
}
private fun artifact(exerciseId: String): String {
val target = JSONObject().put("sets", 3).put("reps", 10)
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)
val entry = JSONObject()
.put("entry_id", "sxe_abcdefab-cdef-4abc-8abc-abcdefabcdef")
.put("position", 0).put("exercise_id", exerciseId)
.put("recording_mode", "sets").put("tracking_mode", "reps")
.put("data_fields", 0).put("equipment_id", JSONObject.NULL)
.put("load_mode", "none").put("rest_seconds", 90).put("target", target)
val draft = JSONObject()
.put("draft_id", "aid_12345678-1234-4abc-8abc-123456789abc")
.put("created_at", "2026-09-14T08:00:00Z").put("planned_for", "2026-09-15")
.put("session_type", "training").put("title", "Dos + biceps")
.put("notes", "Accent sur le dos et les biceps.").put("entries", JSONArray().put(entry))
return JSONObject().put("format", "trainlog-ai-session-drafts").put("version", 1)
.put("generated_at", "2026-09-14T08:00:00Z").put("drafts", JSONArray().put(draft))
.toString()
}
}

View file

@ -1,7 +1,6 @@
package com.labfytools.trainlog.ui package com.labfytools.trainlog.ui
import android.content.Context import android.content.Context
import android.net.Uri
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.SaveFeedbackResult import com.labfytools.trainlog.data.SaveFeedbackResult
import com.labfytools.trainlog.data.SaveSessionResult import com.labfytools.trainlog.data.SaveSessionResult
@ -9,21 +8,23 @@ import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncRequestOutbox import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.SyncRequestResult import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.ExchangeSafDirectory import com.labfytools.trainlog.data.DirectExchangeDirectoryAccess
import com.labfytools.trainlog.data.ExchangeSafDocument import com.labfytools.trainlog.data.DirectExchangeDocument
import com.labfytools.trainlog.data.EXCHANGE_MEDIASTORE_LOG_TAG import com.labfytools.trainlog.data.DIRECT_EXCHANGE_LOG_TAG
import com.labfytools.trainlog.data.ExchangeSafPublisher import com.labfytools.trainlog.data.DirectExchangePublisher
import com.labfytools.trainlog.data.ExchangeSafSnapshotResult import com.labfytools.trainlog.data.DirectExchangeSnapshotResult
import com.labfytools.trainlog.model.SessionDraft import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionSetDraft
import java.util.UUID import java.util.UUID
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.OutputStream import java.io.OutputStream
import java.io.File
import org.json.JSONObject import org.json.JSONObject
import org.json.JSONArray import org.json.JSONArray
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -134,27 +135,20 @@ class SyncBundlePublicationTest {
val writesBeforeSecondClick = directory.writeCounts.toMap() val writesBeforeSecondClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested) assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertEquals(3, directory.enumerationCount) assertEquals(3, directory.enumerationCount)
val phases = ShadowLog.getLogsForTag(EXCHANGE_MEDIASTORE_LOG_TAG).map { it.msg } val phases = ShadowLog.getLogsForTag(DIRECT_EXCHANGE_LOG_TAG).map { it.msg }
assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") }) assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") })
val snapshotPhases = phases.filter { it.startsWith("SAF_TREE snapshot.success ") } val snapshotPhases = phases.filter { it.startsWith("DIRECT_STORAGE snapshot.success ") }
assertEquals(1, snapshotPhases.size) assertEquals(1, snapshotPhases.size)
assertTrue("SAF snapshot ran on main: $snapshotPhases", assertTrue("SAF snapshot ran on main: $snapshotPhases",
snapshotPhases.none { it.contains("thread=main") }) snapshotPhases.none { it.contains("thread=main") })
val writeBegins = phases.filter { it.contains(" saf.write.begin ") } val writeBegins = phases.filter { it.contains(" direct.write.success") }
assertEquals(required.size, writeBegins.size) assertEquals(required.size, writeBegins.size)
assertTrue("SAF write ran on main: $writeBegins", assertTrue("SAF write ran on main: $writeBegins",
writeBegins.none { it.contains("thread=main") }) writeBegins.none { it.contains("thread=main") })
val bodyResolve = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.resolve.existing ") } val bodyWritten = phases.indexOfFirst { it.startsWith("BODY_ZONES direct.write.success") }
val bodyWrite = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.begin ") }
val bodyWritten = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.success ") }
val requestStart = phases.indexOfFirst { it == "SYNC_REQUEST coordinator.request.begin" } val requestStart = phases.indexOfFirst { it == "SYNC_REQUEST coordinator.request.begin" }
assertTrue("production coordinator did not resolve existing BODY ZONES", bodyResolve >= 0) assertTrue("BODY ZONES write did not complete", bodyWritten >= 0)
assertTrue("BODY ZONES must open only after exact resolution", bodyWrite > bodyResolve)
assertTrue("BODY ZONES write did not complete", bodyWritten > bodyWrite)
assertTrue("request must be published after the complete export", requestStart > bodyWritten) assertTrue("request must be published after the complete export", requestStart > bodyWritten)
val beforeBodyOpen = phases.subList(bodyResolve + 1, bodyWrite)
assertTrue("existing BODY ZONES unexpectedly created: $beforeBodyOpen",
beforeBodyOpen.none { it.startsWith("BODY_ZONES saf.create.") })
assertTrue("outbound path must not call MediaStore insert/update/pending: $phases", assertTrue("outbound path must not call MediaStore insert/update/pending: $phases",
phases.none { it.contains(" insert.") || it.contains(" publish.") || it.contains("IS_PENDING") }) phases.none { it.contains(" insert.") || it.contains(" publish.") || it.contains("IS_PENDING") })
assertEquals(bodyCreatesBefore, directory.createCount("trainlog-exercise-body-zones-v1.json")) assertEquals(bodyCreatesBefore, directory.createCount("trainlog-exercise-body-zones-v1.json"))
@ -173,8 +167,8 @@ class SyncBundlePublicationTest {
} }
@Test fun `created exact document updates transaction index without rescan`() { @Test fun `created exact document updates transaction index without rescan`() {
val publisher = ExchangeSafPublisher { directory } val publisher = DirectExchangePublisher { directory }
val opened = publisher.snapshot() as ExchangeSafSnapshotResult.Ready val opened = publisher.snapshot() as DirectExchangeSnapshotResult.Ready
assertEquals(1, directory.enumerationCount) assertEquals(1, directory.enumerationCount)
assertEquals(null, opened.snapshot.writeJson("foo.json", "first")) assertEquals(null, opened.snapshot.writeJson("foo.json", "first"))
assertEquals(null, opened.snapshot.writeJson("foo.json", "second")) assertEquals(null, opened.snapshot.writeJson("foo.json", "second"))
@ -185,34 +179,135 @@ class SyncBundlePublicationTest {
assertEquals(0, directory.canonicalCount("foo (1).json")) assertEquals(0, directory.canonicalCount("foo (1).json"))
} }
@Test fun `empty directory creates exact canonical name and final content`() {
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
assertEquals(null, opened.snapshot.writeJson("artifact.json", "new export"))
assertEquals(1, directory.canonicalCount("artifact.json"))
assertEquals("new export", directory.read("artifact.json"))
assertFalse(directory.names().any { it.matches(Regex("artifact \\(\\d+\\)\\.json")) })
}
@Test fun `ten publications rewrite one canonical document without numbered copies`() {
repeat(10) { publication ->
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
assertEquals(null, opened.snapshot.writeJson("artifact.json", "export-$publication"))
}
assertEquals(1, directory.createCount("artifact.json"))
assertEquals(10, directory.writeCount("artifact.json"))
assertEquals("export-9", directory.read("artifact.json"))
assertEquals(setOf("artifact.json"), directory.names())
}
@Test fun `historical numbered copies are never selected instead of canonical`() {
directory.seed("artifact (1).json", "history one")
directory.seed("artifact (2).json", "history two")
directory.seed("artifact.json", "old canonical")
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
assertEquals(null, opened.snapshot.writeJson("artifact.json", "new canonical"))
assertEquals("new canonical", directory.read("artifact.json"))
assertEquals("history one", directory.read("artifact (1).json"))
assertEquals("history two", directory.read("artifact (2).json"))
assertEquals(0, directory.writeCount("artifact (1).json"))
assertEquals(0, directory.writeCount("artifact (2).json"))
}
@Test fun `historical numbered copy alone does not become canonical target`() {
directory.seed("artifact (1).json", "history")
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
assertEquals(null, opened.snapshot.writeJson("artifact.json", "canonical"))
assertEquals("canonical", directory.read("artifact.json"))
assertEquals("history", directory.read("artifact (1).json"))
}
@Test fun `provider rename fails explicitly before writing conflict copy`() {
directory.createdNameOverride = "artifact (1).json"
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
assertEquals(
"DIRECT_STORAGE_CANONICAL_NAME_CONFLICT: attendu=artifact.json obtenu=artifact (1).json",
opened.snapshot.writeJson("artifact.json", "must not be written"),
)
assertEquals(0, directory.writeCount("artifact (1).json"))
assertEquals(0, directory.canonicalCount("artifact.json"))
}
@Test fun `raw exact lookup recovers canonical omitted from ordinary discovery`() {
directory.seedRawOnly("artifact.json", "old canonical")
directory.seed("artifact (1).json", "history")
repeat(10) { publication ->
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
assertEquals(null, opened.snapshot.writeJson("artifact.json", "export-$publication"))
}
assertEquals(0, directory.createCount("artifact.json"))
assertEquals(10, directory.writeCount("artifact.json"))
assertEquals("export-9", directory.read("artifact.json"))
assertEquals("history", directory.read("artifact (1).json"))
assertFalse(directory.names().contains("artifact (2).json"))
}
@Test fun `multiple raw exact canonical rows fail deterministically`() {
directory.seedRawOnly("artifact.json", "one")
directory.rawExactDuplicates = 2
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
assertEquals(
"DIRECT_STORAGE_DUPLICATE_CANONICAL: nom=artifact.json count=2",
opened.snapshot.writeJson("artifact.json", "new"),
)
assertEquals(0, directory.writeCount("artifact.json"))
}
private fun readCanonical(name: String): String = directory.read(name) private fun readCanonical(name: String): String = directory.read(name)
private class FakeSafDirectory : ExchangeSafDirectory { private class FakeSafDirectory : DirectExchangeDirectoryAccess {
private val contents = linkedMapOf<String, ByteArray>() private val contents = linkedMapOf<String, ByteArray>()
private val creates = mutableMapOf<String, Int>() private val creates = mutableMapOf<String, Int>()
val writeCounts = mutableMapOf<String, Int>() val writeCounts = mutableMapOf<String, Int>()
var enumerationCount = 0 var enumerationCount = 0
private set private set
var createdNameOverride: String? = null
var rawExactDuplicates = 1
private val discoveryHidden = mutableSetOf<String>()
fun seed(name: String, value: String) { contents[name] = value.toByteArray() } fun seed(name: String, value: String) { contents[name] = value.toByteArray() }
fun seedRawOnly(name: String, value: String) {
seed(name, value)
discoveryHidden += name
}
fun read(name: String): String = contents.getValue(name).toString(Charsets.UTF_8) fun read(name: String): String = contents.getValue(name).toString(Charsets.UTF_8)
fun canonicalCount(name: String): Int = if (contents.containsKey(name)) 1 else 0 fun canonicalCount(name: String): Int = if (contents.containsKey(name)) 1 else 0
fun createCount(name: String): Int = creates[name] ?: 0 fun createCount(name: String): Int = creates[name] ?: 0
fun writeCount(name: String): Int = writeCounts[name] ?: 0 fun writeCount(name: String): Int = writeCounts[name] ?: 0
fun names(): Set<String> = contents.keys
override fun listDirectChildren(): List<ExchangeSafDocument> { override fun listDirectChildren(): List<DirectExchangeDocument> {
enumerationCount++ enumerationCount++
return contents.keys.map(::document) return contents.keys.filterNot(discoveryHidden::contains).map(::document)
} }
override fun createJson(displayName: String): ExchangeSafDocument { override fun findExact(displayName: String): List<DirectExchangeDocument> =
check(!contents.containsKey(displayName)) if (contents.containsKey(displayName)) {
List(rawExactDuplicates) { document(displayName) }
} else {
emptyList()
}
override fun createJson(displayName: String): DirectExchangeDocument {
val actualName = createdNameOverride ?: displayName
check(!contents.containsKey(actualName))
creates[displayName] = createCount(displayName) + 1 creates[displayName] = createCount(displayName) + 1
contents[displayName] = byteArrayOf() contents[actualName] = byteArrayOf()
return document(displayName) return document(actualName)
} }
override fun openForRewrite(document: ExchangeSafDocument): OutputStream = override fun openForRewrite(document: DirectExchangeDocument): OutputStream =
object : ByteArrayOutputStream() { object : ByteArrayOutputStream() {
override fun close() { override fun close() {
super.close() super.close()
@ -221,8 +316,8 @@ class SyncBundlePublicationTest {
} }
} }
private fun document(name: String) = ExchangeSafDocument( private fun document(name: String) = DirectExchangeDocument(
Uri.parse("content://trainlog-test/tree/Download%2FTrainlog/document/$name"), File("/storage/emulated/0/Documents/Trainlog", name),
name, name,
) )
} }

View file

@ -25,23 +25,51 @@ historical occurrence values; later local edits and peer reconciliation advance
lineage explicitly inside the same repository transaction as the current-profile lineage explicitly inside the same repository transaction as the current-profile
change. The insert trigger owns only initial root creation. change. The insert trigger owns only initial root creation.
Android schema v17 adds the separate `trainlog-ai-session-drafts` V1 proposal
collection. It is not a second active workout: the existing
`active_session_draft(id=1)` remains the exactly-one durable capture draft.
The companion is imported only after definitions, aliases, profile state,
catalogue, sessions, and equipment associations have been reconciled. It
accepts at most 256 strict, idempotent `aid_<uuid-v4>` proposals; each has at
most 64 contiguous ordered SETS entries, target sets 1..99 and REPS targets
1..999 (or the profile-required duration target). Entries must match the
resolved exercise profile and known equipment.
Pending proposals can be explicitly started or deleted. Start is one local
transaction: it refuses when the singleton active draft exists, copies targets
only, marks the proposal `started`, and removes its proposal entries. Delete
is gated by a destructive confirmation naming the proposal; Cancel, Back and
outside dismissal perform no mutation. Only explicit confirmation invokes the
existing repository operation, which marks it `deleted` and removes entries.
Both retained state rows are tombstones,
so an unchanged companion replay is a skip and cannot resurrect a started or
deleted proposal. Neither action manufactures performed sets, completed history,
MAX, or feedback.
The manual synchronization action publishes one prepared Android→PC bundle The manual synchronization action publishes one prepared Android→PC bundle
before it exposes `trainlog-sync-request-v1.json`. The bundle contains mobile before it exposes `trainlog-sync-request-v1.json`. The bundle contains mobile
V3, mobile equipment definitions, equipment associations V2, exercise aliases V3, mobile equipment definitions, equipment associations V2, exercise aliases
V1, BODY ZONES V1, Training Feedback V2, and Exercise Profile State V1. The V1, BODY ZONES V1, Training Feedback V2, and Exercise Profile State V1. The
request is not written if any required publication fails. The persisted, request is not written if any required publication fails. After the user
user-authorized `Download/Trainlog` SAF tree is the canonical authority for enables `MANAGE_EXTERNAL_STORAGE` in Android settings, the fixed direct path
both inbound and outbound exchange: MediaStore ownership and app-scoped row `/storage/emulated/0/Documents/Trainlog` is the sole authority for inbound and
visibility are not consulted. Exact display names are rewritten through their outbound exchange. Trainlog creates it if absent and requires a readable,
SAF document URI; absence creates the exact name once, and multiple exact names writable directory. No tree URI, persisted URI permission, directory picker,
fail conservatively. Conflict copies such as `name (1).json` are historical `DocumentFile`, `DocumentsContract`, or MediaStore row participates.
objects, never aliases of `name.json`, and are neither selected nor deleted.
One synchronization enumerates the direct SAF children once, builds a bounded Each JSON payload is written to a uniquely named temporary file in that same
exact-name multimap, and shares that transaction-local snapshot across all directory, flushed and fsync'd, then moved over the exact canonical filename
outbound artifacts and the request. Newly created documents update that map. with atomic replacement where the filesystem supports it. This prevents SAF
The next synchronization enumerates again so PC/MTP changes are never hidden by provider suffixes. Existing `name (N).json` files are unrelated historical
a long-lived cache. Snapshot, create and stream operations run on the I/O objects and are never selected, overwritten, moved, or deleted. The next sync
dispatcher; Compose only launches the suspend coordinator and applies results. enumerates again so PC/MTP changes are not hidden by a long-lived cache.
When permission is absent, synchronization fails before publication and the UI
offers **Autoriser l'accès au dossier Trainlog**. This opens the package-scoped
all-files settings screen, falling back to the global all-files screen only if
the OEM exposes no package activity. Android itself grants the permission; the
application never does. Legacy SAF preferences are ignored and removed without
touching `Download/Trainlog` or its recovery backup.
V3 preserves ordinary plan metadata atomically with occurrence identity, V3 preserves ordinary plan metadata atomically with occurrence identity,
equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are
@ -475,7 +503,7 @@ bo_<uuid-v4>
Android maintains: Android maintains:
```text ```text
Download/Trainlog/trainlog-mobile-export-v3.json Documents/Trainlog/trainlog-mobile-export-v3.json
``` ```
The V3 snapshot is refreshed after relevant local changes, including exercise, The V3 snapshot is refreshed after relevant local changes, including exercise,
@ -511,16 +539,17 @@ exact transferred completed rows are exported after finalization.
## 10. PC catalog access ## 10. PC catalog access
PC-created files are accessed through a persistent Storage Access Framework PC-created files are accessed directly after the user enables Android's
grant. all-files access for Trainlog.
The selected folder must be: The selected folder must be:
```text ```text
Download/Trainlog Documents/Trainlog
``` ```
The Sync screen always permits changing the stored folder selection. The Sync screen always exposes the system authorization settings. There is no
stored folder selection.
No application-data reset is required to fix a wrong folder choice. No application-data reset is required to fix a wrong folder choice.
@ -538,10 +567,9 @@ Android writes:
trainlog-sync-request-v1.json trainlog-sync-request-v1.json
``` ```
The request is resolved and rewritten through the same persisted SAF tree as The request is replaced through the same direct `Documents/Trainlog` directory
the bundle. Android does not create a numbered request sibling merely because as the bundle. No document provider can manufacture a numbered request sibling.
MediaStore cannot expose an MTP- or peer-created canonical object. The stable The stable `request_id` remains the replay boundary.
`request_id` remains the replay boundary.
The application-start catalog import does not publish an outbound bundle. The application-start catalog import does not publish an outbound bundle.
Exactly one explicit `Synchroniser maintenant` action invokes one export and Exactly one explicit `Synchroniser maintenant` action invokes one export and

View file

@ -13,7 +13,7 @@ Android capture client
| |
v v
Android shared storage Android shared storage
Download/Trainlog Documents/Trainlog
| |
| direct MTP | direct MTP
v v
@ -168,7 +168,7 @@ silently rendered as unclassified.
### Desktop ### Desktop
Desktop SQLite schema v17 is canonical long-term history. Its v15 -> v16 Desktop SQLite schema v18 is canonical long-term history. Its v15 -> v16
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
one-time backfill copies the linked catalogue mode without changing child rows one-time backfill copies the linked catalogue mode without changing child rows
or stable identities. The additive v16 -> v17 migration adds deterministic or stable identities. The additive v16 -> v17 migration adds deterministic
@ -182,6 +182,19 @@ their strictly-positive contracts. `session_exercises`
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
occur more than once in one session without identity fusion. occur more than once in one session without identity fusion.
The additive v17 -> v18 migration adds a desktop-private AI proposal inbox:
imported headers, ordered target-only entries, an immutable `(aid_ draft_id,
payload digest)` import identity, and a nullable successful-publication cursor.
The cursor drains deterministic batches without deleting replay identity. It is
intentionally not the desktop TUI's
in-progress session state and it never creates actual sets. The separate
Android v17 migration adds a pending AI-proposal collection and permanent
`started`/`deleted` tombstones; the pre-existing
`active_session_draft(id=1)` remains the one capture draft. An explicit start
transaction copies a pending proposal's targets into that singleton and
tombstones the proposal; a delete transaction tombstones it. This keeps replay
idempotent without silently resurrecting user-dismissed work.
The additive v10 -> v11 migration creates direct exercise/body-zone relations The additive v10 -> v11 migration creates direct exercise/body-zone relations
and a private synchronization baseline, then seeds only stable-ID mappings and a private synchronization baseline, then seeds only stable-ID mappings
whose decision evidence is recorded in the manifest. It never rewrites an whose decision evidence is recorded in the manifest. It never rewrites an
@ -336,7 +349,7 @@ No GVFS/FUSE mount is required.
Canonical exchange directory: Canonical exchange directory:
```text ```text
Download/Trainlog Documents/Trainlog
``` ```
## 7. Shared synchronization engine ## 7. Shared synchronization engine

View file

@ -13,6 +13,9 @@ GATE_1_TRAINLOG_FORMAT_V1=PASS/FROZEN
GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
TRAINLOG_AI_EXPORT_V1=PASS
TRAINLOG_AI_GDRIVE_SYNC_V1=PASS
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
DESKTOP_SCHEMA_V11=PASS DESKTOP_SCHEMA_V11=PASS
ANDROID_LOCAL_DATABASE_V10=PASS ANDROID_LOCAL_DATABASE_V10=PASS
@ -81,7 +84,7 @@ Implemented:
F7 actions, and restored focus after overlays/routes); F7 actions, and restored focus after overlays/routes);
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20 - UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
minimum terminal; minimum terminal;
- SQLite schema v17, with stable ordered `session_exercises.entry_id`, an - SQLite schema v18, with stable ordered `session_exercises.entry_id`, an
immutable occurrence-owned `tracking_mode` snapshot, immutable occurrence-owned `tracking_mode` snapshot,
bounded durable current-profile revision ancestry, bounded durable current-profile revision ancestry,
occurrence-level equipment identity, and desktop-local custom-equipment occurrence-level equipment identity, and desktop-local custom-equipment
@ -110,6 +113,35 @@ Implemented:
- manual Android -> PC, PC -> Android, and bidirectional synchronization; - manual Android -> PC, PC -> Android, and bidirectional synchronization;
- structured synchronization history and detail. - structured synchronization history and detail.
`TRAINLOG_AI_SESSION_DRAFT_V1` is implemented but awaits one real external
Drive/Android-triggered bidirectional smoke test. The Drive source is the
single strict `TRAINLOG_AI_SESSION_DRAFT` V1 object at
`TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json`; after a
transactional, idempotent desktop import its exact fetched bytes are copied to
the archive by stable `aid_` ID. The inbox object remains available for logical
replay and archive/status convergence.
Desktop exports the separate `trainlog-ai-session-drafts-v1.json` companion to
Android in deterministic batches of at most 256 unpublished proposals. It
marks exactly a batch successfully published over MTP and retries an unchanged
batch after publication failure; source replay identities remain permanent.
The Android pending proposal collection is not the durable singleton
active capture draft: explicit start copies targets only and tombstones the
proposal in one transaction; explicit deletion tombstones it too. The 64-entry,
99-set, 999-rep and 256-proposal limits, profile compatibility and replay rules
are enforced. The always-visible **Brouillons** action reports the pending
proposal count, and a successful catalogue/companion import invalidates the
pending-screen query so newly imported proposals appear without restarting the
application. Inbound `NONE`, `IMPORTED`, `ALREADY_IMPORTED`, `REJECTED`,
`DRIVE_FAIL`, and `ARCHIVE_FAIL` outcomes remain visible in successful sync
history. A transport/archive failure never rolls back a committed import.
The September 2026 connected-device diagnosis found that the installed APK was
the older 2026-09-13 build: its copied local database was still schema v16 and
had no `ai_session_drafts` table. That APK therefore could not have imported the
Android companion. After a current APK is installed, one new synchronization is
required to import the still-pending companion. This diagnosis is not the real
Drive/Android acceptance smoke test, which remains pending.
Primary navigation is now shared with Android: Primary navigation is now shared with Android:
```text ```text
@ -130,7 +162,7 @@ status can advance.
Implemented: Implemented:
- native Kotlin/Compose application; - native Kotlin/Compose application;
- local SQLite database v11, with non-destructive v3 -> v11 migration; - local SQLite database v17, with non-destructive migrations through v17;
- one durable active-session draft, Home resume and raw-form restoration; - one durable active-session draft, Home resume and raw-form restoration;
- `BODY_FOCUS_HOME_V1`: a compact accessible Home front/back BODY ZONES map, - `BODY_FOCUS_HOME_V1`: a compact accessible Home front/back BODY ZONES map,
textual top-three exposure ranking and Catalogue/Statistics drill-through; textual top-three exposure ranking and Catalogue/Statistics drill-through;
@ -151,7 +183,8 @@ Implemented:
- local session history/detail; - local session history/detail;
- body measurements; - body measurements;
- automatic mobile snapshot maintenance; - automatic mobile snapshot maintenance;
- PC catalog application through a persistent SAF folder grant; - PC catalog application through user-enabled direct all-files access to the
fixed `Documents/Trainlog` directory;
- Android-triggered synchronization request; - Android-triggered synchronization request;
- synchronization receipt handling. - synchronization receipt handling.
@ -189,7 +222,7 @@ The implemented read-only training-knowledge layer loads shared versioned JSON
catalogs as the sole authored scientific source, generates the immutable C catalogs as the sole authored scientific source, generates the immutable C
catalog representation, and loads the same assets on Android. It has no catalog representation, and loads the same assets on Android. It has no
database migration, no auto-seeding, and no synchronization artifact. The database migration, no auto-seeding, and no synchronization artifact. The
desktop database is schema v17 and Android is schema v16. Schema v13 is additive: exercise desktop database is schema v18 and Android is schema v17. Schema v13 is additive: exercise
rows own machine-facing load semantics, optional legacy equipment provenance, rows own machine-facing load semantics, optional legacy equipment provenance,
an optional scientific profile, and an explicit resolved/unresolved science an optional scientific profile, and an explicit resolved/unresolved science
state. Legacy equipment tables and occurrence columns remain intact. state. Legacy equipment tables and occurrence columns remain intact.
@ -217,7 +250,7 @@ and a C role-only query mismatch (HIGH). One bounded repair chain resolved all
four findings; independent repair verification returned four findings; independent repair verification returned
`FINAL_REVIEW_REPAIR_VERIFICATION=PASS` and `FINAL_REVIEW_REPAIR_VERIFICATION=PASS` and
`TRAINING_KNOWLEDGE_V1_ENGINEERING_REVIEW=PASS`. Fresh final validation passed: `TRAINING_KNOWLEDGE_V1_ENGINEERING_REVIEW=PASS`. Fresh final validation passed:
strict build, 42 Meson tests, eight knowledge and four temporal Python tests, strict build, 56 Meson tests, eight knowledge and four temporal Python tests,
knowledge/JSON/import validators, three C17 headers, affected C knowledge and knowledge/JSON/import validators, three C17 headers, affected C knowledge and
context tests under ASan/UBSan plus Python timestamp validation, normal and context tests under ASan/UBSan plus Python timestamp validation, normal and
sanitized 12-form temporal probes, and Android 56 tests with zero failures or sanitized 12-form temporal probes, and Android 56 tests with zero failures or
@ -237,7 +270,7 @@ visual validation or manual MTP validation is claimed for this tranche.
Canonical exchange directory: Canonical exchange directory:
```text ```text
Download/Trainlog Documents/Trainlog
``` ```
Artifacts: Artifacts:
@ -268,6 +301,15 @@ The desktop TUI and `trainlog-syncd` share `trainlog_sync_run()`. Its explicit
inbound-then-outbound respectively. Each direction reconciles equipment inbound-then-outbound respectively. Each direction reconciles equipment
definitions V1 before V2 artifacts that reference them. definitions V1 before V2 artifacts that reference them.
After the shared engine has reached a definitive successful result (including
publication of an Android-request receipt when applicable), the desktop runs
`TRAINLOG_AI_EXPORT_V1` and then the external `rclone copyto` publication to
`TrainLog Gdrive:Trainlog/AI/trainlog_ai_export_v1.json`. The TUI summary and
`trainlog-syncd` log expose `SYNC`, `AI_EXPORT`, and `GDRIVE_UPLOAD` separately.
An export or Drive failure never retroactively changes the successful Trainlog
sync result. Android does not execute `rclone`; all rclone/OAuth configuration
remains external under the user's normal `~/.config/rclone/` directory.
Each structured local sync-history run persists its selected `a`, `p`, or `b` Each structured local sync-history run persists its selected `a`, `p`, or `b`
direction. `direction inconnue` is reserved for legacy history rows whose direction. `direction inconnue` is reserved for legacy history rows whose
direction was not recorded; it is never used for a current run. direction was not recorded; it is never used for a current run.
@ -428,13 +470,20 @@ unchanged. Live sync runs `sy_0b00dc46-d899-4865-8718-c95085a380b2` and
second run reported zero additions/reconciliations, both stores retained the second run reported zero additions/reconciliations, both stores retained the
same 32-relation stable-ID hash, and Android application tables plus companion same 32-relation stable-ID hash, and Android application tables plus companion
exercise states compared equal to the first pass. Android now resolves the exercise states compared equal to the first pass. Android now resolves the
exact request and every outbound artifact through the persisted authorized SAF exact request and every outbound artifact through direct filesystem access to
tree, independently of MediaStore ownership visibility. Historical numbered `Documents/Trainlog`. Historical numbered copies remain untouched. One
copies remain untouched. One transaction-local exact-name snapshot is shared transaction-local exact-name snapshot is shared
by the complete export and request on the I/O dispatcher; each later sync takes by the complete export and request on the I/O dispatcher; each later sync takes
a fresh snapshot. The redundant application-start export was removed, and a fresh snapshot. The redundant application-start export was removed, and
request-ID replay protection remains authoritative. request-ID replay protection remains authoritative.
`TRAINLOG_ANDROID_DIRECT_STORAGE_V1=PASS`. Android no longer uses SAF for
exchange artifacts. User-enabled all-files access gates the fixed
`/storage/emulated/0/Documents/Trainlog` directory; direct temporary-file
replacement preserves canonical names. A physical Samsung Android 16 one-sync
and its identical second run completed successfully without suffixes, and the
recovered `Dos + biceps` proposal remained visible after replay.
## Current implementation cursor ## Current implementation cursor
Body Zones V1, exercise reconciliation, definition-first V2 synchronization Body Zones V1, exercise reconciliation, definition-first V2 synchronization

View file

@ -16,8 +16,8 @@ is a later, separately versioned migration.
## 1. Status ## 1. Status
```text ```text
TRAINLOG_DATABASE_SCHEMA_VERSION=17 TRAINLOG_DATABASE_SCHEMA_VERSION=18
DATABASE_SCHEMA_V17=PASS DATABASE_SCHEMA_V18=IMPLEMENTED_VALIDATION_PENDING
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
``` ```
@ -36,7 +36,7 @@ PRAGMA user_version;
Current value: Current value:
```text ```text
12 18
``` ```
The independent actual-set loads documented in the current desktop, Android The independent actual-set loads documented in the current desktop, Android
@ -90,6 +90,25 @@ direct zones, and rejects profile or primary-zone conflicts before mutation.
A schema fixture must represent the real historical structure. Rewriting only A schema fixture must represent the real historical structure. Rewriting only
`user_version` is not an acceptable migration test. `user_version` is not an acceptable migration test.
Version 18 is additive and transactional. It creates `ai_session_drafts`,
`ai_session_draft_entries`, and `ai_session_draft_imports` for the strict
`TRAINLOG_AI_SESSION_DRAFT_V1` Drive inbox. A desktop row retains the source
`aid_<uuid-v4>` ID, canonical payload SHA-256 digest, source exercise identity,
archive status/error, and nullable successful-publication timestamp separately
from target-only plan values. Entry rows do not store notes. The
`(draft_id, digest)` identity makes an exact replay a skip and makes a changed
payload under the same identity an explicit conflict. A failed child insert
rolls back header, entries, and identity together. No completed session,
performed set, MAX, feedback, active draft, or frozen exchange artifact is
rewritten.
Android schema v17 is likewise additive but has different local ownership:
its `ai_session_drafts` pending collection and entries coexist with—not replace—
the exactly-one `active_session_draft`. `started` and `deleted` state rows are
durable tombstones. Starting or deleting a pending proposal atomically removes
its entries while retaining that state row, so companion replay cannot recreate
it.
Schema v14 adds `exercise_feedback` and `session_followups` without rewriting Schema v14 adds `exercise_feedback` and `session_followups` without rewriting
v13 facts. Both use stable immutable IDs, exact timestamps, non-empty bounded v13 facts. Both use stable immutable IDs, exact timestamps, non-empty bounded
text at API boundaries, and parent-owned cascading foreign keys to text at API boundaries, and parent-owned cascading foreign keys to
@ -447,7 +466,7 @@ distance km
The Android SQLite database is independent. The Android SQLite database is independent.
Current Android-local version: **10**. The explicit migration chain adds the Current Android-local version: **17**. The explicit migration chain adds the
durable draft in v4, equipment references in v5, per-set load in v6, occurrence durable draft in v4, equipment references in v5, per-set load in v6, occurrence
identity/multi-occurrence support in v7, and the widened custom-equipment identity/multi-occurrence support in v7, and the widened custom-equipment
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw definition graph in v8. Version 9 adds completed/draft explicit max rows, raw

View file

@ -1,5 +1,65 @@
# Trainlog Exchange Format v1 # Trainlog Exchange Format v1
## TRAINLOG_AI_SESSION_DRAFT_V1
`TRAINLOG_AI_SESSION_DRAFT_V1` is a separately versioned AI-proposal contract,
not an extension of frozen `TRAINLOG_FORMAT_V1`. Its one-object Drive source is
`TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json` with exact root
`{"format":"TRAINLOG_AI_SESSION_DRAFT","version":1,"draft":...}`.
The source `draft_id` is an `aid_<uuid-v4>` identity, `created_at` is canonical
UTC, `session_type` is exactly `training`, and all object keys are exact.
Text is optional only where stated and, when present, nonempty and trimmed:
title is limited to 120 characters and draft notes to 2,000. Entries have no
notes field; it is rejected as an unknown key. A source has 1..64 contiguous
ordered entries. Each entry resolves a
known exercise (or explicit alias) and is a target-only `SETS` plan: 1..99
sets, 1..999 reps for REPS tracking or a 1..86,400-second duration for DURATION
tracking, optional positive weight no greater than 10,000, and 0..86,400 rest.
Continuous exercises and a target metric inconsistent with the resolved
profile are rejected. This contract cannot encode performed sets.
Desktop records the source digest with its `aid_` identity. The exact same
payload is an idempotent skip; changed content with that identity is a conflict.
After a successful import commit only, the exact validated local snapshot is
copied to `TrainLog Gdrive:Trainlog/AI/archive/<aid>.json`. The mutable inbox
source is not moved or deleted; logical replay makes archive copy and local
status failures retryable without undoing the import. The outbound
`trainlog-ai-session-drafts` V1 companion is a distinct strict object for
Android, with at most 256 unpublished drafts per publication. Only after that
artifact is published successfully does desktop transactionally mark its exact
IDs published; a failed publication resends the same deterministic batch, and
later normal syncs deliver the remainder. The permanent source import ledger is
not pruned by publication. Android's durable `started` and `deleted`
tombstones prevent an unchanged companion replay from recreating a proposal.
The required real Drive/Android-triggered smoke validation is still pending.
## TRAINLOG_AI_EXPORT_V1
`TRAINLOG_AI_EXPORT_V1` is a local, user-controlled JSON export of raw desktop
history for manual external analysis. It is not a synchronization format and
contains no recommendation, progression, or inferred attribution.
Create `trainlog_ai_export_v1.json` in the current directory with:
```bash
python tools/export_ai_history.py --database ~/.local/share/trainlog/trainlog.db
```
An explicit destination may be supplied as the final argument. The exporter
opens SQLite with `mode=ro` and `PRAGMA query_only=ON`; it performs no migration
and no persistent write. Format `TRAINLOG_AI_EXPORT`, version `1`, uses
`generated_at` as an RFC 3339 UTC instant at whole-second precision, rendered
as `YYYY-MM-DDTHH:MM:SSZ`. All arrays have explicit deterministic ordering;
only `generated_at` varies between runs.
Immediate raw feedback is nested under the exact occurrence identified by
`session_id` (the owning session), `entry_id`, and `exercise_id`. Session
follow-ups remain in `session_followups` at session scope. The exporter never
infers that an occurrence caused a next-day observation. The current schema
stores revisioned free text, not structured soreness, intensity, body-zone, or
suspected-exercise fields, so those absent facts are not invented in V1.
## 1. Status ## 1. Status
```text ```text

View file

@ -17,6 +17,21 @@ Historical implementation detail belongs in Git history and `docs/reviews`.
The published Trainlog JSON v1 compatibility boundary remains frozen unless a The published Trainlog JSON v1 compatibility boundary remains frozen unless a
future feature explicitly introduces a new version. future feature explicitly introduces a new version.
## TRAINLOG_AI_SESSION_DRAFT_V1 — implementation validation pending
The implemented proposal path is deliberately separate from completed-session
exchange and the singleton capture draft. A strict single Drive source,
`TRAINLOG_AI_SESSION_DRAFT` V1, imports target-only SETS proposals into desktop
schema v18, archive-copies the exact fetched snapshot only after commit while
retaining the inbox object for logical replay, then produces the bounded
`trainlog-ai-session-drafts` V1 Android companion in deterministic batches of
at most 256 unpublished proposals. Only successful MTP publication advances
the desktop cursor; replay identities remain permanent. Android v17 retains a
pending collection, while explicit start/delete creates durable tombstones so
replay is idempotent and cannot resurrect proposals. The remaining gate is one
real Drive plus Android-triggered bidirectional synchronization smoke test;
therefore this item is `VALIDATION_PENDING` and is not PASS.
## Completed foundation ## Completed foundation
```text ```text

View file

@ -2,7 +2,7 @@
## Machine-exercise Phase 1 compatibility ## Machine-exercise Phase 1 compatibility
Desktop schema v17 and Android schema v16 retain mobile export V3, readable V1/V2 imports, Desktop schema v18 and Android schema v17 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
@ -43,6 +43,7 @@ EXPLICIT_MAX_RESULTS_V2=PASS
SESSION_GENERATOR_V1=PASS SESSION_GENERATOR_V1=PASS
BODY_ZONE_SYNC_V1=PASS BODY_ZONE_SYNC_V1=PASS
BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
``` ```
@ -63,13 +64,22 @@ v1 format.
Canonical Android shared-storage directory: Canonical Android shared-storage directory:
```text ```text
Download/Trainlog Documents/Trainlog
``` ```
Desktop accesses this directory through direct MTP. Desktop accesses this directory through direct MTP.
Android accesses PC-created artifacts through a persistent Storage Access Android accesses this exact public directory directly after the user enables
Framework folder grant. `MANAGE_EXTERNAL_STORAGE` in Android settings. It checks
`Environment.isExternalStorageManager()`, creates the directory when necessary,
and never uses a SAF tree URI, directory picker, `DocumentFile`, or
`DocumentsContract` for Trainlog artifacts.
The desktop resolves `Documents` and its direct `Trainlog` child for the whole
run. All outbound publications target that folder exclusively. A historical
`Download/Trainlog` tree may remain on the device for manual recovery and may
be inspected by explicitly bounded legacy tooling, but the active sync engine
does not publish to it or let it override an artifact in the new endpoint.
## 3. Artifact table ## 3. Artifact table
@ -87,6 +97,7 @@ Framework folder grant.
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 | | PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
| 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 companion |
| 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 |
@ -115,7 +126,8 @@ falls back to an older candidate when the newest one is malformed. Definitions
reconcile before the V2 snapshot, so a valid custom `equipment_id` is known reconcile before the V2 snapshot, so a valid custom `equipment_id` is known
before a session may reference it. This prevents an older canonical object from before a session may reference it. This prevents an older canonical object from
being mistaken for Android's current data while preserving every file in being mistaken for Android's current data while preserving every file in
`Download/Trainlog`. the historical `Download/Trainlog` tree without selecting it as the active
publication endpoint.
V2 is a separate format: every session entry has an `entry_id`, `position`, V2 is a separate format: every session entry has an `entry_id`, `position`,
metrics, actual loads and optional equipment identity. This permits two metrics, actual loads and optional equipment identity. This permits two
@ -451,6 +463,25 @@ PC -> Android catalog count
Android accepts a receipt only when its `request_id` matches the pending Android accepts a receipt only when its `request_id` matches the pending
request. request.
For `TRAINLOG_AI_SESSION_DRAFT_V1`, Drive—not `Documents/Trainlog`—is the
single inbound source: `TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json`.
The desktop fetches it shell-free, parses the strict one-draft source, and
commits it idempotently before copying the exact validated local snapshot with
shell-free `rclone copyto` to
`TrainLog Gdrive:Trainlog/AI/archive/<aid>.json`. The mutable inbox object is
never moved or deleted, so same-name replacement cannot alter the archived
bytes or be removed by archiving. A missing source, transport failure, or
archive failure is reported explicitly; logical inbox replay retains a
retryable desktop status and never undoes the committed import. The desktop
outbound companion selects at most 256 not-yet-published proposals in stable
creation/identity order. Successful MTP publication transactionally marks only
that exact batch; failure leaves it unchanged for retry, while later normal
syncs drain further batches. The permanent Drive import ledger is independent.
The inbound outcome (`NONE`, `IMPORTED`, `ALREADY_IMPORTED`, `REJECTED`,
`DRIVE_FAIL`, or `ARCHIVE_FAIL`) is retained in the successful sync report and
history; fetched-invalid, Drive, and archive outcomes do not fail an otherwise
successful Android/PC synchronization.
## 10. Shared desktop engine ## 10. Shared desktop engine
Canonical implementation: Canonical implementation:
@ -472,16 +503,37 @@ Android-triggered path:
Android request Android request
-> trainlog-syncd -> trainlog-syncd
-> shared engine -> shared engine
-> Drive AI draft fetch/import/archive attempt
-> PC -> Android AI draft companion publication
-> receipt -> receipt
``` ```
Only after either path has fully succeeded, the same desktop engine runs:
```text
SYNC=PASS
-> tools/export_ai_history.py
-> AI_EXPORT=PASS
-> rclone copyto trainlog_ai_export_v1.json
"TrainLog Gdrive:Trainlog/AI/trainlog_ai_export_v1.json"
-> GDRIVE_UPLOAD=PASS|FAIL
```
`AI_EXPORT=FAIL` skips Drive publication so an older artifact cannot be
uploaded as the result of the new synchronization. Export and Drive are
best-effort post-sync operations: either may fail visibly while the completed
Trainlog synchronization remains successful. Android never executes rclone,
and Trainlog stores neither its configuration nor Google OAuth secrets.
The engine has three explicit modes: The engine has three explicit modes:
```text ```text
a Android -> PC: definition V1 -> mobile V3 -> body zones V1 -> association V2; no publish a Android -> PC: definition V1 -> mobile V3 -> body zones V1 -> association V2
p PC -> Android: definition V1 -> catalog V1 -> body zones V1 -> mobile V3 -> Drive AI draft midpoint; no Android publish
(including bodies) -> association V2; no receive p PC -> Android: Drive AI draft midpoint -> AI-drafts companion -> profile
b bidirectional: complete inbound sequence, then complete outbound sequence state/aliases/feedback -> definition V1 -> catalog V1 -> body zones V1 ->
mobile V3 (including bodies) -> association V2; no Android receive
b bidirectional: complete inbound sequence -> Drive AI draft midpoint -> complete outbound sequence
``` ```
In both directions, definitions are reconciled before V2 artifacts that may In both directions, definitions are reconciled before V2 artifacts that may
@ -731,3 +783,12 @@ the importer prepares `UPDATE exercises`; the persisted guarded profile trigger
therefore remains valid even when the authoritative incoming tip makes its body therefore remains valid even when the authoritative incoming tip makes its body
a no-op. Replaying the same artifact neither changes the current revision nor a no-op. Replaying the same artifact neither changes the current revision nor
adds history rows. adds history rows.
Desktop Python companions, including the alias importer, receive the explicit
XDG-resolved database path. Their scripts resolve from the Meson-configured
repository tools directory rather than from the installed executable's parent
layout; `TRAINLOG_TOOLS_DIR` may explicitly override that directory for a
packaged user-session deployment. Before every invocation the bounded combined
stdout/stderr result is truncated. Resolution, launch, argparse, exception,
signal, and non-zero-exit diagnostics therefore describe only the current run
and are retained in the synchronization error/history when the run fails.

View file

@ -1,5 +1,11 @@
# Tests and validation # Tests and validation
The `ai_history_export` test covers an empty database, a simple session,
distinct multi-occurrences of one exercise, ordered sets, exact occurrence
feedback, session-owned follow-up feedback, JSON escaping/UTF-8, MAX history,
body measurements, deterministic content, and byte-for-byte database
immutability.
## 1. Principle ## 1. Principle
A Trainlog feature is not complete without relevant validation. A Trainlog feature is not complete without relevant validation.
@ -115,7 +121,7 @@ app_shell
``` ```
The current desktop suite, including APP_SHELL_V1 production-transition The current desktop suite, including APP_SHELL_V1 production-transition
coverage, is 48/48. The following generator-specific checkpoint counts remain coverage and the AI-draft exchange regressions, is 56/56. The following generator-specific checkpoint counts remain
historical evidence: historical evidence:
```text ```text
@ -388,6 +394,43 @@ reprocessing one request as a new one.
The TUI also invokes the same engine manually and exposes structured run The TUI also invokes the same engine manually and exposes structured run
details. details.
`post_sync_ai_drive` validates strict export-before-upload ordering, skips
upload after export failure, and reports unavailable/failing rclone. The
production sync wiring test exercises successful TUI and Android triggers
through `trainlog_sync_run()`, uses an isolated fake rclone, and proves that
failed sync runs do not invoke Drive publication. Post-sync failures are
non-fatal to the already successful Trainlog sync by contract.
`ai_session_draft` validates the strict desktop source parser and schema v18
transaction: exact/unknown-key rejection, `aid_` identity, alias resolution,
target-only SETS compatibility, 64-entry/99-set/999-rep bounds, exact replay
skip, changed-payload conflict, deterministic 256-at-a-time publication across
257 drafts, failed-publication retry, permanent import ledger, and full rollback on
an injected child failure. Its fake-rclone seam also verifies that the exact
fetched bytes are archive-copied only after import, same-name inbox replacement
is preserved, stale temporary content cannot import, and archive/status failure
converges through idempotent logical replay. Production orchestration
tests retain `DRIVE_FAIL`, fetched-invalid `REJECTED`, and `ARCHIVE_FAIL` in a
successful report/history. Android repository tests cover the 256-proposal
companion limit, full validation before replay skip, strict optional-text
trimming, replay idempotence, start/delete
tombstones, target-only copy to the unchanged singleton active draft, and
catalog/profile/equipment prerequisite ordering. Focused Android UI wiring tests
also cover pending-only deterministic listing, started/deleted exclusion, the
always-visible **Brouillons** count without losing manual/history actions,
proposal title/target details, inert display with no automatic active draft,
external-revision refresh after companion import, and the existing transactional
start path. These are automated fake
transport tests; no real Drive automated test exists. The final real Drive plus
Android-triggered bidirectional smoke test remains manual and
`TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING`.
A September 2026 connected-device inspection established that the previously
installed 2026-09-13 APK still owned a schema-v16 database without
`ai_session_drafts`; it could not import the companion. Installing the current
APK and synchronizing once is therefore a prerequisite for the remaining real
acceptance test, not evidence that the acceptance test has passed.
## 9. Sanitizers ## 9. Sanitizers
For meaningful C checkpoints: For meaningful C checkpoints:
@ -462,7 +505,7 @@ Coverage proves:
and footer F6/F7 single-occurrence output at 120x35, 100x30, 80x24, and and footer F6/F7 single-occurrence output at 120x35, 100x30, 80x24, and
72x20. 72x20.
Historical validation checkpoint (the current desktop suite is 48/48): Historical validation checkpoint (the current desktop suite is 56/56):
```text ```text
39/39 Meson tests PASS 39/39 Meson tests PASS
@ -496,12 +539,17 @@ Historical validation checkpoint (the current desktop suite is 48/48):
## 13. Android session draft v1 ## 13. Android session draft v1
Android schema v4 introduced one durable active draft; the current additive Android schema v4 introduced one durable active draft; the current additive
chain reaches schema v13 without clearing completed history or the draft. The chain reaches schema v17 without clearing completed history or the draft. The
explicit v10 -> v11 migration adds optional planning metadata while preserving explicit v10 -> v11 migration adds optional planning metadata while preserving
existing rows with `load_mode=none`, zero rest and NULL targets. The existing rows with `load_mode=none`, zero rest and NULL targets. The
v11 -> v12 migration adds the durable flattened exercise-alias table. Its exact v11 -> v12 migration adds the durable flattened exercise-alias table. Its exact
physical-v11 fixture compares every pre-existing table cell before and after physical-v11 fixture compares every pre-existing table cell before and after
migration, requires the new alias table to be empty, and checks foreign keys. migration, requires the new alias table to be empty, and checks foreign keys.
The dedicated physical v16 -> v17 fixture preserves representative completed
history and the active draft, adds empty AI proposal tables, and checks SQLite
integrity and foreign keys. AI companion parser coverage accepts title/notes
at their 120/2,000 Unicode-code-point limits (including non-BMP characters)
and rejects 121/2,001 code points atomically.
Host coverage includes exercise shapes and raw partial text, fresh repository Host coverage includes exercise shapes and raw partial text, fresh repository
restore, remove/discard, atomic restore, remove/discard, atomic
finalization and repeated-finalize rejection, rollback, catalog reconciliation, finalization and repeated-finalize rejection, rollback, catalog reconciliation,
@ -728,23 +776,48 @@ import with equal and evolved tracking/recording/data-field profiles, strict
snapshot-local payload rejection, unknown identity rejection, unchanged snapshot-local payload rejection, unknown identity rejection, unchanged
historical values, unchanged current profile history, and idempotent replay. historical values, unchanged current profile history, and idempotent replay.
`SyncBundlePublicationTest` exercises the production request coordinator and a `SyncBundlePublicationTest` exercises the production request coordinator and a
synthetic user-authorized SAF directory whose exact BODY ZONES canonical is synthetic direct exchange directory. The exact BODY ZONES file is rewritten
deliberately unavailable through MediaStore. The exact SAF object is rewritten beside 31 preserved historical copies, no `(32)` copy occurs, all seven
beside 31 preserved historical copies, no MediaStore insert/pending publication Android-origin companions precede the request signal, and replay reuses
or `(32)` copy occurs, all seven Android-origin companions precede the request canonical names without stable-feedback churn.
signal, and replay reuses canonical objects without stable-feedback churn.
It also requires one direct-child enumeration for the entire export-plus-request It also requires one direct-child enumeration for the entire export-plus-request
transaction, one additional enumeration for each later synchronization, index transaction, one additional enumeration for each later synchronization, index
update after a create without rescan, one write per canonical artifact, and update after a create without rescan, one write per canonical artifact, and
non-main dispatcher ownership for snapshot and stream operations. non-main dispatcher ownership for snapshot and stream operations. Focused
publisher cases additionally cover an empty directory, exact rewrite,
ten successive publications without numbered copies, historical numbered
copies alone and beside a canonical object, exact final content, and an
explicit canonical-name conflict without writing a wrongly named target.
`DirectExchangeStorageTest` verifies the manifest permission and settings
action, refusal without an authorized backend, directory creation, real
temporary-file replacement and truncation, ten publications without suffixes,
and preservation of legacy Download and recovery fixtures.
`AiSessionDraftUiWiringTest` additionally requires the delete action to expose
the titled confirmation before mutation, verifies Cancel and Back dismissal,
then proves that only confirmation removes the pending proposal while retaining
its replay-blocking deleted tombstone. Its existing Start coverage remains.
Native `sync_body_zone_wiring` verifies the Native `sync_body_zone_wiring` verifies the
profile pre-pass before mobile import, strict post-pass gating, replay, legacy profile pre-pass before mobile import, strict post-pass gating, replay, legacy
peer compatibility, and the explicit missing-companion diagnostic even when a peer compatibility, and the explicit missing-companion diagnostic even when a
stale local temporary profile artifact exists. stale local temporary profile artifact exists. It also receives a valid alias
Every Android outbound artifact and the request use the shared exact-name SAF V1 companion through the production MTP orchestration, imports it into an
resolver; app-scoped MediaStore visibility is not part of canonical selection. isolated database through the production Python argv path, and proves that the
sync continues. Negative cases cover an unresolved helper, argparse failure,
Python exception/stderr propagation, sync-history visibility, and rejection of
a stale result-file `PASS`; the successful flow exercises the other shared
Python helper callers as well.
Every Android outbound artifact and the request use the shared direct-storage
exact-name resolver; app-scoped MediaStore visibility is irrelevant.
`profile_state_sync` includes the production persisted update-trigger dependency `profile_state_sync` includes the production persisted update-trigger dependency
on `trainlog_profile_revision`, imports through the same Python connection path on `trainlog_profile_revision`, imports through the same Python connection path
used by the PC sync worker, and requires first import plus identical replay to used by the PC sync worker, and requires first import plus identical replay to
pass without revision-state or history-count churn. It also checks that the pass without revision-state or history-count churn. It also checks that the
central factory installs both profile-revision schema functions. central factory installs both profile-revision schema functions.
`TRAINLOG_ANDROID_DIRECT_STORAGE_V1` coverage fixes Android storage at
`/storage/emulated/0/Documents/Trainlog`, preserves canonical rewrite/no-suffix
behavior, ignores legacy Download trees, and gives the MTP fixture both
Documents and Download roots while failing on any legacy access.
Android unit tests and any instrumented tests run on an emulator; the primary
personal phone is reserved for manual, non-destructive install-and-sync smoke
validation and must not run `connectedDebugAndroidTest`.

View file

@ -0,0 +1,44 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "trainlog-ai-session-draft-v1.schema.json",
"title": "TRAINLOG_AI_SESSION_DRAFT_V1 source inbox",
"type": "object",
"additionalProperties": false,
"required": ["format", "version", "draft"],
"properties": {
"format": {"const": "TRAINLOG_AI_SESSION_DRAFT"},
"version": {"const": 1},
"draft": {"$ref": "#/$defs/draft"}
},
"$defs": {
"nullableText": {"type": ["string", "null"], "minLength": 1},
"entry": {
"type": "object",
"additionalProperties": false,
"required": ["position", "exercise_id", "target_sets", "target_reps", "target_duration_seconds", "target_weight_kg", "rest_seconds"],
"properties": {
"position": {"type": "integer", "minimum": 0, "maximum": 63},
"exercise_id": {"type": "string", "pattern": "^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},
"target_sets": {"type": "integer", "minimum": 1, "maximum": 99},
"target_reps": {"type": ["integer", "null"], "minimum": 1, "maximum": 999},
"target_duration_seconds": {"type": ["integer", "null"], "minimum": 1, "maximum": 86400},
"target_weight_kg": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 10000},
"rest_seconds": {"type": "integer", "minimum": 0, "maximum": 86400}
}
},
"draft": {
"type": "object",
"additionalProperties": false,
"required": ["draft_id", "created_at", "planned_for", "session_type", "title", "notes", "entries"],
"properties": {
"draft_id": {"type": "string", "pattern": "^aid_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},
"created_at": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,6})?Z$"},
"planned_for": {"type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"},
"session_type": {"const": "training"},
"title": {"type": ["string", "null"], "minLength": 1, "maxLength": 120},
"notes": {"type": ["string", "null"], "minLength": 1, "maxLength": 2000},
"entries": {"type": "array", "minItems": 1, "maxItems": 64, "items": {"$ref": "#/$defs/entry"}}
}
}
}
}

View file

@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "trainlog-ai-session-drafts-v1.schema.json",
"title": "PC to Android AI session drafts companion",
"type": "object",
"additionalProperties": false,
"required": ["format", "version", "generated_at", "drafts"],
"properties": {
"format": {"const": "trainlog-ai-session-drafts"},
"version": {"const": 1},
"generated_at": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"},
"drafts": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/draft"}}
},
"$defs": {
"target": {
"type": "object", "additionalProperties": false,
"required": ["sets", "reps", "duration_seconds", "weight_kg"],
"properties": {
"sets": {"type": "integer", "minimum": 1, "maximum": 99},
"reps": {"type": ["integer", "null"], "minimum": 1, "maximum": 999},
"duration_seconds": {"type": ["integer", "null"], "minimum": 1, "maximum": 86400},
"weight_kg": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 10000}
}
},
"entry": {
"type": "object", "additionalProperties": false,
"required": ["entry_id", "position", "exercise_id", "recording_mode", "tracking_mode", "data_fields", "equipment_id", "load_mode", "rest_seconds", "target"],
"properties": {
"entry_id": {"type": "string", "pattern": "^sxe_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},
"position": {"type": "integer", "minimum": 0, "maximum": 63},
"exercise_id": {"type": "string", "pattern": "^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},
"recording_mode": {"const": "sets"},
"tracking_mode": {"enum": ["reps", "duration"]},
"data_fields": {"type": "integer", "minimum": 0, "maximum": 3},
"equipment_id": {"type": ["string", "null"]},
"load_mode": {"enum": ["none", "external"]},
"rest_seconds": {"type": "integer", "minimum": 0, "maximum": 86400},
"target": {"$ref": "#/$defs/target"}
}
},
"draft": {
"type": "object", "additionalProperties": false,
"required": ["draft_id", "created_at", "planned_for", "session_type", "title", "notes", "entries"],
"properties": {
"draft_id": {"type": "string", "pattern": "^aid_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},
"created_at": {"type": "string"},
"planned_for": {"type": ["string", "null"]},
"session_type": {"const": "training"},
"title": {"type": ["string", "null"], "maxLength": 120},
"notes": {"type": ["string", "null"], "maxLength": 2000},
"entries": {"type": "array", "minItems": 1, "maxItems": 64, "items": {"$ref": "#/$defs/entry"}}
}
}
}
}

View file

@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Regression tests for the read-only TRAINLOG_AI_EXPORT_V1 artifact."""
import hashlib
import json
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TOOL = ROOT / "tools" / "export_ai_history.py"
SPECIAL_TEXT = 'Très dur "aujourd\'hui".\nDos OK.\n\\ test UTF-8 : éàùç'
SCHEMA = """
PRAGMA user_version=17;
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT,name TEXT,
normalized_name TEXT,tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER);
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT,started_at TEXT,
ended_at TEXT,session_type TEXT,notes TEXT);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT,
session_row_id INTEGER,exercise_row_id INTEGER,recording_mode TEXT,
data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER,
target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,
target_weight_kg REAL,equipment_id TEXT,notes TEXT,tracking_mode TEXT);
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,
position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,
duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL);
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT,
observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,
shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,
right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,
right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);
CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT,
session_exercise_row_id INTEGER,observed_at TEXT,raw_text TEXT);
CREATE TABLE session_followups(id INTEGER PRIMARY KEY,followup_id TEXT,
session_row_id INTEGER,observed_at TEXT,raw_text TEXT);
CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,
feedback_id TEXT,created_at TEXT,raw_text TEXT);
CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,
followup_id TEXT,created_at TEXT,raw_text TEXT);
"""
class AiHistoryExportTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.directory = Path(self.temporary.name)
self.database = self.directory / "trainlog.db"
with sqlite3.connect(self.database) as connection:
connection.executescript(SCHEMA)
def tearDown(self):
self.temporary.cleanup()
def export(self, name="export.json"):
output = self.directory / name
subprocess.run(
[sys.executable, str(TOOL), "--database", str(self.database),
str(output)], check=True, capture_output=True, text=True,
)
return output, json.loads(output.read_text(encoding="utf-8"))
def populate(self):
with sqlite3.connect(self.database) as connection:
connection.execute(
"INSERT INTO exercises VALUES(1,'ex_same','Tirage dos','tirage dos',"
"'reps','sets',0)"
)
connection.execute(
"INSERT INTO sessions VALUES(1,'se_one','2026-09-12T10:00:00Z',"
"'2026-09-12T11:00:00Z','training','Séance dos')"
)
for row_id, entry_id, position in ((1, "sxe_a", 0), (2, "sxe_b", 1)):
connection.execute(
"INSERT INTO session_exercises VALUES(?,?,1,1,'sets',0,?,"
"'none',0,NULL,NULL,NULL,NULL,NULL,NULL,'reps')",
(row_id, entry_id, position),
)
connection.executemany(
"INSERT INTO performed_sets VALUES(?,?,?,?,NULL,?)",
((1, 1, 0, 8, 48.0), (2, 1, 1, 7, 48.0),
(3, 2, 0, 10, 42.5)),
)
connection.execute(
"INSERT INTO exercise_feedback VALUES(1,'fb_one',2,"
"'2026-09-12T10:45:00Z',?)", (SPECIAL_TEXT,)
)
connection.execute(
"INSERT INTO exercise_feedback_revisions VALUES("
"'fr_one','fb_one','2026-09-12T10:45:00Z',?)", (SPECIAL_TEXT,)
)
connection.execute(
"INSERT INTO session_followups VALUES(1,'fu_one',1,"
"'2026-09-13T09:00:00Z','Courbatures dos')"
)
connection.execute(
"INSERT INTO session_followup_revisions VALUES("
"'fur_one','fu_one','2026-09-13T09:00:00Z','Courbatures dos')"
)
def test_empty_export_is_valid_and_has_required_root(self):
_, payload = self.export()
self.assertEqual("TRAINLOG_AI_EXPORT", payload["format"])
self.assertEqual(1, payload["version"])
self.assertRegex(payload["generated_at"], r"Z$")
self.assertEqual([], payload["sessions"])
self.assertEqual([], payload["max_history"])
self.assertEqual([], payload["measurements"])
def test_session_multi_occurrence_feedback_text_and_followup_ownership(self):
self.populate()
output, payload = self.export()
# Parsing the actual file proves quotes, backslashes, newlines and UTF-8
# are encoded as JSON rather than interpolated into it.
json.loads(output.read_text(encoding="utf-8"))
session = payload["sessions"][0]
self.assertEqual(["sxe_a", "sxe_b"],
[entry["entry_id"] for entry in session["entries"]])
self.assertEqual([8, 7],
[item["reps"] for item in session["entries"][0]["sets"]])
self.assertEqual([], session["entries"][0]["immediate_feedback"])
feedback = session["entries"][1]["immediate_feedback"][0]
self.assertEqual(SPECIAL_TEXT, feedback["revisions"][0]["raw_text"])
self.assertNotIn("session_followups", session["entries"][1])
self.assertEqual("fu_one", session["session_followups"][0]["followup_id"])
def test_max_measurements_determinism_and_read_only_database(self):
self.populate()
with sqlite3.connect(self.database) as connection:
connection.execute("INSERT INTO max_results VALUES(1,62.5)")
connection.execute(
"INSERT INTO body_observations(id,observation_id,observed_at,"
"body_weight_kg,waist_cm,notes) VALUES("
"1,'bo_one','2026-09-13T08:00:00Z',75.2,82.0,'Matin')"
)
before = hashlib.sha256(self.database.read_bytes()).digest()
_, first = self.export("first.json")
after = hashlib.sha256(self.database.read_bytes()).digest()
_, second = self.export("second.json")
first.pop("generated_at")
second.pop("generated_at")
self.assertEqual(before, after)
self.assertEqual(first, second)
self.assertEqual("sxe_a", first["max_history"][0]["entry_id"])
self.assertEqual(62.5, first["max_history"][0]["max_weight_kg"])
self.assertEqual(75.2, first["measurements"][0]["body_weight_kg"])
self.assertEqual(82.0, first["measurements"][0]["waist_cm"])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""Focused contract tests for the desktop AI session-draft inbox."""
import copy
import contextlib
import importlib.util
import io
import json
import stat
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "tools"))
from import_ai_session_draft import ImportFailure, import_payload, load # noqa: E402
from export_ai_session_drafts import build_export, mark_published # noqa: E402
_drive_spec = importlib.util.spec_from_file_location(
"sync_ai_session_draft_drive", ROOT / "tools/sync_ai_session_draft_drive.py"
)
drive = importlib.util.module_from_spec(_drive_spec)
_drive_spec.loader.exec_module(drive)
EX_REPS = "ex_11111111-1111-4111-8111-111111111111"
EX_DURATION = "ex_22222222-2222-4222-8222-222222222222"
EX_CONTINUOUS = "ex_33333333-3333-4333-8333-333333333333"
EX_ALIAS = "ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
DRAFT_ID = "aid_44444444-4444-4444-8444-444444444444"
SCHEMA = """
PRAGMA foreign_keys=ON;
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER NOT NULL DEFAULT 0);
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT);
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY);
CREATE TABLE ai_session_drafts(id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL,planned_for TEXT,session_type TEXT NOT NULL,title TEXT,notes TEXT,archive_status TEXT NOT NULL DEFAULT 'pending',archived_at TEXT,archive_error TEXT,published_at TEXT);
CREATE TABLE ai_session_draft_entries(id INTEGER PRIMARY KEY,draft_row_id INTEGER NOT NULL REFERENCES ai_session_drafts(id) ON DELETE CASCADE,entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),source_exercise_id TEXT NOT NULL,recording_mode TEXT NOT NULL,tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,equipment_id TEXT,load_mode TEXT NOT NULL,target_sets INTEGER NOT NULL,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,rest_seconds INTEGER NOT NULL,UNIQUE(draft_row_id,position));
CREATE TABLE ai_session_draft_imports(draft_row_id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,payload_sha256 TEXT NOT NULL UNIQUE,imported_at TEXT NOT NULL);
CREATE TRIGGER ai_session_draft_import_identity_guard BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;
PRAGMA user_version=18;
"""
def artifact():
return {"format": "TRAINLOG_AI_SESSION_DRAFT", "version": 1, "draft": {
"draft_id": DRAFT_ID, "created_at": "2026-09-14T08:30:00Z",
"planned_for": "2026-09-15", "session_type": "training",
"title": "Séance IA", "notes": None, "entries": [
{"position": 0, "exercise_id": EX_ALIAS, "target_sets": 4,
"target_reps": 8, "target_duration_seconds": None,
"target_weight_kg": 42.5, "rest_seconds": 90},
{"position": 1, "exercise_id": EX_DURATION, "target_sets": 3,
"target_reps": None, "target_duration_seconds": 45,
"target_weight_kg": None, "rest_seconds": 30},
]}}
class DraftTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.database = Path(self.temp.name) / "trainlog.db"
con = sqlite3.connect(self.database)
con.executescript(SCHEMA)
con.executemany("INSERT INTO exercises(exercise_id,recording_mode,tracking_mode,data_fields) VALUES(?,?,?,?)", [
(EX_REPS, "sets", "reps", 3), (EX_DURATION, "sets", "duration", 0),
(EX_CONTINUOUS, "continuous", "duration", 0)])
con.execute("INSERT INTO exercise_aliases VALUES(?,?)", (EX_ALIAS, EX_REPS))
con.commit()
con.close()
def tearDown(self):
self.temp.cleanup()
def connect(self):
con = sqlite3.connect(self.database)
con.execute("PRAGMA foreign_keys=ON")
return con
def test_import_alias_targets_only_idempotence_conflict_and_export_shape(self):
con = self.connect()
self.assertEqual("imported", import_payload(con, artifact(), "2026-09-14T09:00:00Z"))
self.assertEqual("skipped", import_payload(con, artifact(), "2026-09-14T09:01:00Z"))
self.assertEqual(0, con.execute("SELECT count(*) FROM performed_sets").fetchone()[0])
stored = con.execute(
"SELECT de.entry_id,de.source_exercise_id,e.exercise_id,de.recording_mode,de.tracking_mode,de.load_mode,de.target_reps,de.target_duration_seconds "
"FROM ai_session_draft_entries de JOIN exercises e ON e.id=de.exercise_row_id WHERE position=0"
).fetchone()
self.assertRegex(stored[0], r"^sxe_[0-9a-f-]{36}$")
self.assertEqual((EX_ALIAS, EX_REPS, "sets", "reps", "external", 8, None), stored[1:])
changed = artifact(); changed["draft"]["title"] = "Autre"
with self.assertRaisesRegex(ImportFailure, "conflit"):
import_payload(con, changed)
exported = build_export(con, "2026-09-14T09:02:00Z")
self.assertEqual({"format", "version", "generated_at", "drafts"}, set(exported))
self.assertEqual("trainlog-ai-session-drafts", exported["format"])
outbound = exported["drafts"][0]["entries"][0]
self.assertEqual(EX_REPS, outbound["exercise_id"])
self.assertEqual(3, outbound["data_fields"])
self.assertEqual({"entry_id", "position", "exercise_id", "recording_mode", "tracking_mode", "data_fields", "equipment_id", "load_mode", "rest_seconds", "target"}, set(outbound))
self.assertEqual({"sets": 4, "reps": 8, "duration_seconds": None, "weight_kg": 42.5}, outbound["target"])
con.close()
def test_existing_v18_database_gets_publication_cursor(self):
con = self.connect()
con.execute("ALTER TABLE ai_session_drafts DROP COLUMN published_at")
con.commit()
self.assertEqual([], build_export(con, "2026-09-14T09:02:00Z")["drafts"])
self.assertIn("published_at", {row[1] for row in con.execute(
"PRAGMA table_info(ai_session_drafts)")})
con.close()
def test_target_sets_99_and_bounded_publication_lifecycle(self):
value = artifact()
value["draft"]["entries"][0]["target_sets"] = 99
con = self.connect()
self.assertEqual("imported", import_payload(con, value))
self.assertEqual(99, con.execute(
"SELECT target_sets FROM ai_session_draft_entries WHERE position=0"
).fetchone()[0])
base = con.execute(
"SELECT created_at,planned_for,session_type,title,notes FROM ai_session_drafts WHERE draft_id=?",
(DRAFT_ID,),
).fetchone()
for index in range(1, 256):
con.execute(
"INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes) VALUES(?,?,?,?,?,?)",
(f"aid_00000000-0000-4000-8000-{index:012d}",) + base,
)
con.execute(
"INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes) VALUES(?,?,?,?,?,?)",
("aid_00000000-0000-4000-8000-000000000256",) + base,
)
con.commit()
first = build_export(con, "2026-09-14T09:02:00Z")
self.assertEqual(256, len(first["drafts"]))
# Failed publication performs no mark: deterministic retry is identical.
self.assertEqual([d["draft_id"] for d in first["drafts"]],
[d["draft_id"] for d in build_export(con, "2026-09-14T09:03:00Z")["drafts"]])
self.assertEqual(256, mark_published(con, first, "2026-09-14T09:04:00Z"))
second = build_export(con, "2026-09-14T09:05:00Z")
self.assertEqual(1, len(second["drafts"]))
self.assertEqual(DRAFT_ID, second["drafts"][0]["draft_id"])
self.assertEqual(1, mark_published(con, second, "2026-09-14T09:06:00Z"))
self.assertEqual([], build_export(con, "2026-09-14T09:07:00Z")["drafts"])
self.assertEqual(1, con.execute("SELECT count(*) FROM ai_session_draft_imports").fetchone()[0])
con.close()
def test_strict_failures(self):
mutations = []
value = artifact(); value["extra"] = 1; mutations.append(value)
value = artifact(); value["draft"]["created_at"] = "2026-09-14T08:30:00+00:00"; mutations.append(value)
value = artifact(); value["draft"]["entries"][1]["position"] = 2; mutations.append(value)
value = artifact(); value["draft"]["entries"][0]["target_duration_seconds"] = 10; mutations.append(value)
value = artifact(); value["draft"]["entries"][0]["target_weight_kg"] = float("inf"); mutations.append(value)
value = artifact(); value["draft"]["entries"][0]["notes"] = None; mutations.append(value)
value = artifact(); value["draft"]["entries"][0]["exercise_id"] = EX_CONTINUOUS; value["draft"]["entries"][0]["target_reps"] = None; value["draft"]["entries"][0]["target_duration_seconds"] = 20; mutations.append(value)
con = self.connect()
for value in mutations:
with self.assertRaises(ImportFailure):
import_payload(con, value)
self.assertEqual(0, con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0])
con.close()
duplicate = Path(self.temp.name) / "duplicate.json"
duplicate.write_text('{"format":"TRAINLOG_AI_SESSION_DRAFT","format":"x","version":1,"draft":{}}', encoding="utf-8")
with self.assertRaisesRegex(ImportFailure, "dupliquée"):
load(duplicate)
def test_forced_child_failure_rolls_back_every_row(self):
con = self.connect()
con.execute("CREATE TRIGGER reject_second BEFORE INSERT ON ai_session_draft_entries WHEN NEW.position=1 BEGIN SELECT RAISE(ABORT,'forced'); END")
with self.assertRaises(sqlite3.Error):
import_payload(con, artifact())
self.assertEqual((0, 0, 0), (
con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0],
con.execute("SELECT count(*) FROM ai_session_draft_entries").fetchone()[0],
con.execute("SELECT count(*) FROM ai_session_draft_imports").fetchone()[0]))
con.close()
def test_fake_rclone_listing_failure_and_archive_failure_are_nonfatal(self):
missing_calls = []
def missing_runner(argv, **kwargs):
missing_calls.append(argv)
return subprocess.CompletedProcess(argv, 4, "", "not found")
self.assertEqual(0, drive.synchronize(self.database, missing_runner))
self.assertEqual("rclone", missing_calls[0][0])
source = artifact(); calls = []
def archive_fails(argv, **kwargs):
calls.append(argv)
if argv[1] == "lsf":
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
if argv[1] == "copyto" and argv[2] == drive.SOURCE:
Path(argv[3]).write_text(json.dumps(source), encoding="utf-8")
return subprocess.CompletedProcess(argv, 0, "", "")
return subprocess.CompletedProcess(argv, 9, "", "offline")
self.assertEqual(0, drive.synchronize(self.database, archive_fails))
con = self.connect()
self.assertEqual(("failed", "offline"), con.execute(
"SELECT archive_status,archive_error FROM ai_session_drafts WHERE draft_id=?", (DRAFT_ID,)).fetchone())
self.assertEqual(1, con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0])
con.close()
self.assertEqual(["rclone", "lsf"], calls[0][:2])
self.assertEqual(["rclone", "copyto"], calls[1][:2])
self.assertEqual(["rclone", "copyto"], calls[2][:2])
self.assertNotEqual(drive.SOURCE, calls[2][2])
self.assertTrue(calls[2][2].endswith("/draft.json"))
self.assertEqual(f"{drive.ARCHIVE_ROOT}/{DRAFT_ID}.json", calls[2][3])
def test_inbound_destination_is_private_during_and_after_copy(self):
source = artifact()
observed = []
def successful_runner(argv, **kwargs):
if argv[1] == "lsf":
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
if argv[2] == drive.SOURCE:
destination = Path(argv[3])
self.assertTrue(destination.exists())
self.assertEqual(0o600, stat.S_IMODE(destination.stat().st_mode))
destination.write_text(json.dumps(source), encoding="utf-8")
observed.append(stat.S_IMODE(destination.stat().st_mode))
return subprocess.CompletedProcess(argv, 0, "", "")
self.assertEqual(0, drive.synchronize(self.database, successful_runner))
self.assertEqual([0o600], observed)
def test_source_replacement_after_fetch_archives_imported_bytes_without_removing_replacement(self):
original = artifact()
replacement = artifact()
replacement["draft"]["draft_id"] = "aid_55555555-5555-4555-8555-555555555555"
remote = {drive.SOURCE: json.dumps(original)}
archived = {}
def replacing_runner(argv, **kwargs):
if argv[1] == "lsf":
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
if argv[2] == drive.SOURCE:
Path(argv[3]).write_text(remote[drive.SOURCE], encoding="utf-8")
remote[drive.SOURCE] = json.dumps(replacement)
else:
archived[argv[3]] = Path(argv[2]).read_text(encoding="utf-8")
return subprocess.CompletedProcess(argv, 0, "", "")
self.assertEqual(0, drive.synchronize(self.database, replacing_runner))
target = f"{drive.ARCHIVE_ROOT}/{DRAFT_ID}.json"
self.assertEqual(original, json.loads(archived[target]))
self.assertEqual(replacement, json.loads(remote[drive.SOURCE]))
def test_archive_status_update_failure_converges_on_replay(self):
source = artifact()
archive_copies = []
def successful_runner(argv, **kwargs):
if argv[1] == "lsf":
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
if argv[2] == drive.SOURCE:
Path(argv[3]).write_text(json.dumps(source), encoding="utf-8")
else:
archive_copies.append(Path(argv[2]).read_text(encoding="utf-8"))
return subprocess.CompletedProcess(argv, 0, "", "")
original_update = drive.try_update_archive
updates = 0
def fail_once(*args, **kwargs):
nonlocal updates
updates += 1
if updates == 1:
return "synthetic update failure"
return original_update(*args, **kwargs)
drive.try_update_archive = fail_once
try:
first = io.StringIO()
with contextlib.redirect_stdout(first):
self.assertEqual(0, drive.synchronize(self.database, successful_runner))
self.assertIn("AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL", first.getvalue())
second = io.StringIO()
with contextlib.redirect_stdout(second):
self.assertEqual(0, drive.synchronize(self.database, successful_runner))
self.assertIn("AI_SESSION_DRAFT_INBOUND=ALREADY_IMPORTED", second.getvalue())
finally:
drive.try_update_archive = original_update
self.assertEqual(2, len(archive_copies))
con = self.connect()
self.assertEqual("archived", con.execute(
"SELECT archive_status FROM ai_session_drafts WHERE draft_id=?", (DRAFT_ID,)
).fetchone()[0])
con.close()
def test_helper_reports_imported_then_already_imported(self):
source = artifact()
def successful_runner(argv, **kwargs):
if argv[1] == "lsf":
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
if argv[1] == "copyto" and argv[2] == drive.SOURCE:
Path(argv[3]).write_text(json.dumps(source), encoding="utf-8")
return subprocess.CompletedProcess(argv, 0, "", "")
first = io.StringIO()
with contextlib.redirect_stdout(first):
self.assertEqual(0, drive.synchronize(self.database, successful_runner))
self.assertIn("AI_SESSION_DRAFT_INBOUND=IMPORTED", first.getvalue())
second = io.StringIO()
with contextlib.redirect_stdout(second):
self.assertEqual(0, drive.synchronize(self.database, successful_runner))
self.assertIn("AI_SESSION_DRAFT_INBOUND=ALREADY_IMPORTED", second.getvalue())
def test_listing_distinguishes_empty_and_transport_failure(self):
def empty_runner(argv, **kwargs):
self.assertEqual("lsf", argv[1])
self.assertEqual(drive.INBOX_ROOT, argv[2])
self.assertEqual(["--files-only", "--max-depth", "1", "--include",
drive.ARTIFACT_NAME], argv[3:])
return subprocess.CompletedProcess(argv, 0, "other.json\n", "")
out = io.StringIO()
with contextlib.redirect_stdout(out):
self.assertEqual(0, drive.synchronize(self.database, empty_runner))
self.assertIn("AI_SESSION_DRAFT_INBOUND=NONE", out.getvalue())
def unavailable_runner(argv, **kwargs):
raise OSError("authentication unavailable")
out = io.StringIO()
with contextlib.redirect_stdout(out):
self.assertEqual(0, drive.synchronize(self.database, unavailable_runner))
self.assertIn("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", out.getvalue())
def test_present_file_copy_failure_or_timeout_is_drive_failure(self):
def runner(argv, **kwargs):
if argv[1] == "lsf":
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
raise subprocess.TimeoutExpired(argv, drive.RCLONE_TIMEOUT_SECONDS)
out = io.StringIO()
with contextlib.redirect_stdout(out):
self.assertEqual(0, drive.synchronize(self.database, runner))
self.assertIn("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", out.getvalue())
self.assertNotIn("AI_SESSION_DRAFT_INBOUND=NONE", out.getvalue())
def test_disappearing_source_and_unwritten_temp_are_drive_failures(self):
stale = Path(self.temp.name) / "draft.json"
stale.write_text(json.dumps(artifact()), encoding="utf-8")
for returncode in (7, 0):
def runner(argv, **kwargs):
if argv[1] == "lsf":
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
return subprocess.CompletedProcess(argv, returncode, "", "source disappeared")
out = io.StringIO()
with contextlib.redirect_stdout(out):
self.assertEqual(0, drive.synchronize(self.database, runner))
self.assertIn("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", out.getvalue())
con = self.connect()
self.assertEqual(0, con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0])
con.close()
def test_present_invalid_json_is_rejected(self):
def runner(argv, **kwargs):
if argv[1] == "lsf":
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
if argv[1] == "copyto":
Path(argv[3]).write_text("{invalid", encoding="utf-8")
return subprocess.CompletedProcess(argv, 0, "", "")
out = io.StringIO()
with contextlib.redirect_stdout(out):
self.assertEqual(0, drive.synchronize(self.database, runner))
self.assertIn("AI_SESSION_DRAFT_INBOUND=REJECTED", out.getvalue())
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Contract tests for ordered, non-secret post-sync Drive publication."""
from __future__ import annotations
import contextlib
import importlib.util
import io
import subprocess
import tempfile
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
TOOL = ROOT / "tools" / "post_sync_ai_drive.py"
def load_tool():
spec = importlib.util.spec_from_file_location("post_sync_ai_drive", TOOL)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def run_case(results):
module = load_tool()
output = io.StringIO()
with tempfile.TemporaryDirectory() as directory:
database = Path(directory) / "trainlog.db"
destination = Path(directory) / "export.json"
with mock.patch.object(module.subprocess, "run", side_effect=results) as run:
with contextlib.redirect_stdout(output):
status = module.publish(database, destination)
return module, status, output.getvalue(), run.call_args_list
def completed(argv, returncode, stdout="", stderr=""):
return subprocess.CompletedProcess(argv, returncode, stdout, stderr)
def main() -> None:
module, status, text, calls = run_case([
completed(["export"], 0, "TRAINLOG_AI_EXPORT_V1=PASS\n"),
completed(["rclone"], 0),
])
assert status == 0
assert text == "AI_EXPORT=PASS\nGDRIVE_UPLOAD=PASS\n"
assert len(calls) == 2
assert calls[0].args[0][1].endswith("tools/export_ai_history.py")
assert calls[1].args[0][0:2] == ["rclone", "copyto"]
assert calls[1].args[0][-1] == module.DRIVE_TARGET
_, status, text, calls = run_case([
completed(["export"], 1, "TRAINLOG_AI_EXPORT_V1=FAIL database locked\n"),
])
assert status == 1
assert "AI_EXPORT=FAIL" in text
assert "GDRIVE_UPLOAD=SKIPPED" in text
assert len(calls) == 1
_, status, text, calls = run_case([
completed(["export"], 0, "TRAINLOG_AI_EXPORT_V1=PASS\n"),
FileNotFoundError("rclone unavailable"),
])
assert status == 1
assert "AI_EXPORT=PASS" in text
assert "GDRIVE_UPLOAD=FAIL rclone unavailable" in text
assert len(calls) == 2
_, status, text, _ = run_case([
completed(["export"], 0, "TRAINLOG_AI_EXPORT_V1=PASS\n"),
completed(["rclone"], 9, stderr="remote upload failed\n"),
])
assert status == 1
assert "GDRIVE_UPLOAD=FAIL remote upload failed" in text
print("PASS post-sync AI export and Drive ordering")
if __name__ == "__main__":
main()

253
tools/export_ai_history.py Normal file
View file

@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Export raw desktop history as the read-only TRAINLOG_AI_EXPORT_V1 artifact."""
import argparse
import datetime
import json
import os
import sqlite3
import tempfile
from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
"right_forearm_cm", "left_thigh_cm", "right_thigh_cm", "left_calf_cm",
"right_calf_cm",
)
def default_database():
return Path(os.environ.get(
"XDG_DATA_HOME", str(Path.home() / ".local/share")
)) / "trainlog" / "trainlog.db"
def _readonly_connection(database):
"""Open SQLite without any path capable of changing persistent state.
CONTRACT: mode=ro rejects writes at SQLite's file-opening boundary and
query_only is a second guard against accidental write SQL added later.
INVARIANT: exporting cannot migrate, create, replace, or update DB content.
"""
uri = database.resolve().as_uri() + "?mode=ro"
connection = configure_connection(sqlite3.connect(uri, uri=True))
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA query_only=ON")
return connection
def _revisions(connection, table, owner_column, owner_id):
rows = connection.execute(
f"SELECT revision_id,created_at,raw_text FROM {table} "
f"WHERE {owner_column}=? "
"ORDER BY created_at COLLATE BINARY,revision_id COLLATE BINARY",
(owner_id,),
)
return [dict(row) for row in rows]
def _feedback(connection, session_row_id):
result = {}
rows = connection.execute(
"SELECT f.feedback_id,se.entry_id,f.observed_at "
"FROM exercise_feedback f "
"JOIN session_exercises se ON se.id=f.session_exercise_row_id "
"WHERE se.session_row_id=? "
"ORDER BY se.position,f.observed_at COLLATE BINARY,"
"f.feedback_id COLLATE BINARY",
(session_row_id,),
)
for row in rows:
item = {
"feedback_id": row["feedback_id"],
"observed_at": row["observed_at"],
"revisions": _revisions(
connection, "exercise_feedback_revisions", "feedback_id",
row["feedback_id"],
),
}
result.setdefault(row["entry_id"], []).append(item)
return result
def _followups(connection, session_row_id):
rows = connection.execute(
"SELECT followup_id,observed_at FROM session_followups "
"WHERE session_row_id=? "
"ORDER BY observed_at COLLATE BINARY,followup_id COLLATE BINARY",
(session_row_id,),
)
return [{
"followup_id": row["followup_id"],
"observed_at": row["observed_at"],
"revisions": _revisions(
connection, "session_followup_revisions", "followup_id",
row["followup_id"],
),
} for row in rows]
def _entry_result(connection, entry):
maximum = connection.execute(
"SELECT max_weight_kg FROM max_results "
"WHERE session_exercise_row_id=?", (entry["row_id"],),
).fetchone()
if maximum is not None:
return "max_weight_kg", maximum["max_weight_kg"]
if entry["recording_mode"] == "continuous":
activity = connection.execute(
"SELECT duration_seconds,speed_kmh,distance_km "
"FROM continuous_activity WHERE session_exercise_row_id=?",
(entry["row_id"],),
).fetchone()
return "continuous_activity", (
None if activity is None else
{key: activity[key] for key in activity.keys()
if activity[key] is not None}
)
sets = connection.execute(
"SELECT position,reps,duration_seconds,weight_kg FROM performed_sets "
"WHERE session_exercise_row_id=? "
"ORDER BY position,id", (entry["row_id"],),
)
return "sets", [
{key: row[key] for key in row.keys() if row[key] is not None}
for row in sets
]
def build_export(connection, generated_at):
"""Build the raw export using only explicitly ordered SELECT statements."""
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError("schema desktop v15-v18 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",
"version": 1,
"generated_at": generated_at,
"sessions": [],
"max_history": [],
"measurements": [],
}
tracking = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode"
sessions = connection.execute(
"SELECT id,session_id,started_at,ended_at,session_type,notes "
"FROM sessions "
"ORDER BY started_at COLLATE BINARY,session_id COLLATE BINARY"
)
for session in sessions:
immediate = _feedback(connection, session["id"])
exported_session = {
key: session[key] for key in (
"session_id", "started_at", "ended_at", "session_type", "notes"
)
}
exported_session["entries"] = []
entries = connection.execute(
"SELECT se.id AS row_id,se.entry_id,se.position,e.exercise_id,"
"e.name AS exercise_name,se.recording_mode," + tracking +
" AS tracking_mode,se.data_fields,se.equipment_id,se.notes "
"FROM session_exercises se "
"JOIN exercises e ON e.id=se.exercise_row_id "
"WHERE se.session_row_id=? "
"ORDER BY se.position,se.entry_id COLLATE BINARY",
(session["id"],),
)
for entry in entries:
item = {key: entry[key] for key in (
"entry_id", "exercise_id", "exercise_name", "position",
"recording_mode", "tracking_mode", "data_fields",
"equipment_id", "notes",
)}
result_name, result = _entry_result(connection, entry)
item[result_name] = result
item["immediate_feedback"] = immediate.get(entry["entry_id"], [])
exported_session["entries"].append(item)
# CONTRACT: follow-ups belong to the session only. The exporter never
# infers an exercise cause from time, text, or occurrence proximity.
exported_session["session_followups"] = _followups(
connection, session["id"]
)
root["sessions"].append(exported_session)
maxima = connection.execute(
"SELECT s.session_id,s.started_at,se.entry_id,e.exercise_id,"
"e.name AS exercise_name,m.max_weight_kg "
"FROM max_results m "
"JOIN session_exercises se ON se.id=m.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"ORDER BY s.started_at COLLATE BINARY,s.session_id COLLATE BINARY,"
"se.position,se.entry_id COLLATE BINARY"
)
root["max_history"] = [dict(row) for row in maxima]
columns = ",".join(("b.observation_id", "b.observed_at", "s.session_id") +
tuple("b." + name for name in MEASUREMENT_FIELDS) +
("b.notes",))
measurements = connection.execute(
f"SELECT {columns} FROM body_observations b "
"LEFT JOIN sessions s ON s.id=b.session_row_id "
"ORDER BY b.observed_at COLLATE BINARY,b.observation_id COLLATE BINARY"
)
root["measurements"] = [
{key: row[key] for key in row.keys() if row[key] is not None}
for row in measurements
]
return root
def _write_atomic(output, payload):
output.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(
prefix=output.name + ".", suffix=".tmp", dir=output.parent
)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
json.dump(payload, stream, ensure_ascii=False, separators=(",", ":"))
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, output)
except Exception:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
raise
def main():
parser = argparse.ArgumentParser(
description="Export local Trainlog history for external analysis."
)
parser.add_argument(
"output", type=Path, nargs="?", default=Path("trainlog_ai_export_v1.json")
)
parser.add_argument("--database", type=Path, default=default_database())
args = parser.parse_args()
generated_at = datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
).replace("+00:00", "Z")
connection = _readonly_connection(args.database)
try:
payload = build_export(connection, generated_at)
finally:
connection.close()
_write_atomic(args.output, payload)
print("TRAINLOG_AI_EXPORT_V1=PASS")
print("output=" + str(args.output.resolve()))
if __name__ == "__main__":
try:
main()
except Exception as error:
print("TRAINLOG_AI_EXPORT_V1=FAIL " + str(error))
raise SystemExit(1)

View file

@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Export the PC→Android trainlog-ai-session-drafts v1 companion."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import sqlite3
import tempfile
from pathlib import Path
from trainlog_sqlite import connect_database
MAX_DRAFTS = 256
ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
if connection.execute("PRAGMA user_version").fetchone()[0] != 18:
raise ValueError("schema desktop v18 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:
connection.execute(
"ALTER TABLE ai_session_drafts ADD COLUMN published_at TEXT")
# INVARIANT: publication never weakens the permanent Drive replay ledger,
# including on an older v18 database whose FK was declared cascading.
connection.execute(
"CREATE TRIGGER IF NOT EXISTS ai_session_draft_import_identity_guard "
"BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM "
"ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN "
"SELECT RAISE(ABORT,'AI draft import identity is permanent');END;"
)
if owned_transaction:
connection.commit()
def build_export(connection, generated_at):
ensure_publication_state(connection)
drafts = []
rows = connection.execute(
"SELECT id,draft_id,created_at,planned_for,session_type,title,notes "
"FROM ai_session_drafts WHERE published_at IS NULL "
"ORDER BY created_at COLLATE BINARY,draft_id COLLATE BINARY LIMIT ?",
(MAX_DRAFTS,),
).fetchall()
for row in rows:
entries = connection.execute(
"SELECT de.entry_id,de.position,e.exercise_id,de.recording_mode,de.tracking_mode,"
"de.data_fields,de.equipment_id,de.load_mode,de.rest_seconds,de.target_sets,"
"de.target_reps,de.target_duration_seconds,de.target_weight_kg "
"FROM ai_session_draft_entries de JOIN exercises e ON e.id=de.exercise_row_id "
"WHERE de.draft_row_id=? ORDER BY de.position",
(row[0],),
).fetchall()
drafts.append({
"draft_id": row[1], "created_at": row[2], "planned_for": row[3],
"session_type": row[4], "title": row[5], "notes": row[6],
"entries": [{
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
"recording_mode": entry[3], "tracking_mode": entry[4],
"data_fields": entry[5], "equipment_id": entry[6],
"load_mode": entry[7], "rest_seconds": entry[8],
"target": {"sets": entry[9], "reps": entry[10],
"duration_seconds": entry[11], "weight_kg": entry[12]},
} for entry in entries],
})
# CONTRACT: this companion is separate from every mobile-export version.
# INVARIANT: archive bookkeeping is desktop-private and is never published.
return {"format": "trainlog-ai-session-drafts", "version": 1,
"generated_at": generated_at, "drafts": drafts}
def mark_published(connection, payload, published_at):
"""Mark exactly one successfully transported outbound batch.
CONTRACT: callers invoke this only after the existing MTP publication
operation succeeds. INVARIANT: a failed or stale batch marks no row, so a
retry exports the same deterministic IDs and the import ledger is retained.
"""
ensure_publication_state(connection)
if (not isinstance(payload, dict) or set(payload) != {"format", "version", "generated_at", "drafts"}
or payload["format"] != "trainlog-ai-session-drafts" or payload["version"] != 1
or not isinstance(payload["drafts"], list) or len(payload["drafts"]) > MAX_DRAFTS):
raise ValueError("lot de brouillons IA publié invalide")
draft_ids = []
for draft in payload["drafts"]:
draft_id = draft.get("draft_id") if isinstance(draft, dict) else None
if not isinstance(draft_id, str) or not draft_id.startswith(ID_PREFIX) or draft_id in draft_ids:
raise ValueError("identité de brouillon IA publiée invalide")
draft_ids.append(draft_id)
connection.execute("BEGIN IMMEDIATE")
try:
for draft_id in draft_ids:
cursor = connection.execute(
"UPDATE ai_session_drafts SET published_at=? WHERE draft_id=? AND published_at IS NULL",
(published_at, draft_id),
)
if cursor.rowcount != 1:
raise ValueError(f"lot publié obsolète ou inconnu: {draft_id}")
connection.commit()
except Exception:
connection.rollback()
raise
return len(draft_ids)
def write_atomic(output, payload):
output.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=output.name + ".", dir=output.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(payload, stream, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, output)
except Exception:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
raise
def main():
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
try:
connection = connect_database(args.database)
try:
generated_at = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
payload = build_export(connection, generated_at)
finally:
connection.close()
write_atomic(args.output, payload)
print(f"AI_SESSION_DRAFT_EXPORT=PASS drafts={len(payload['drafts'])}")
return 0
except (OSError, sqlite3.Error, ValueError) as error:
print(f"AI_SESSION_DRAFT_EXPORT=FAIL {error}")
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -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:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17): if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
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)
known_equipment.update(row[0] for row in connection.execute( known_equipment.update(row[0] for row in connection.execute(

View file

@ -24,7 +24,7 @@ def main():
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17): if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
raise ValueError("schema desktop v8 à v16 requis") raise ValueError("schema desktop v8 à v16 requis")
equipment = [dict(row) for row in connection.execute( equipment = [dict(row) for row in connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "

View file

@ -16,7 +16,7 @@ def main():
args = parser.parse_args() args = parser.parse_args()
con = connect_database(args.database) con = connect_database(args.database)
try: try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17): if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18):
raise ValueError("schema desktop v12-v16 requis") raise ValueError("schema desktop v12-v16 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 "

View file

@ -41,7 +41,7 @@ def main():
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17): if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18):
raise ValueError("schema desktop v11-v16 requis") raise ValueError("schema desktop v11-v16 requis")
exercises = [] exercises = []
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"): for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):

View file

@ -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 != 17: if version not in (17, 18):
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,

View file

@ -68,7 +68,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 (8, 9, 10, 11, 12, 13, 14, 15, 16, 17): if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
raise SystemExit( raise SystemExit(
"PC_CATALOG_EXPORT=FAIL " "PC_CATALOG_EXPORT=FAIL "
f"schema={version}" f"schema={version}"

View file

@ -69,7 +69,7 @@ def main():
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 schema_version not in (11, 12, 13, 14, 15, 16, 17) and not (args.version == 2 and schema_version == 10): if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18) and not (args.version == 2 and schema_version == 10):
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)")
known_equipment = supplied_equipment_ids() known_equipment = supplied_equipment_ids()
canonical_names = load_exercise_names() canonical_names = load_exercise_names()

View file

@ -10,7 +10,7 @@ def main():
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:
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17): raise ValueError("schema desktop v15/v16/v17 requis") if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18): raise ValueError("schema desktop v15-v18 requis")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations") if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations")

View file

@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Strict transactional importer for TRAINLOG_AI_SESSION_DRAFT_V1."""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import math
import re
import sqlite3
import sys
from pathlib import Path
from trainlog_sqlite import connect_database
FORMAT = "TRAINLOG_AI_SESSION_DRAFT"
MAX_ENTRIES = 64
MAX_TITLE = 120
MAX_NOTES = 2000
ID_RE = re.compile(
r"^aid_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
EXERCISE_ID_RE = re.compile(
r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
UTC_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$")
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
class ImportFailure(ValueError):
"""A stable, user-actionable contract failure."""
def reject_constant(value: str):
raise ImportFailure(f"nombre non fini interdit: {value}")
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ImportFailure(f"clé JSON dupliquée: {key}")
result[key] = value
return result
def exact(value, keys, label):
if not isinstance(value, dict) or set(value) != keys:
raise ImportFailure(f"clés {label} invalides")
def integer(value, minimum, maximum, label):
if type(value) is not int or value < minimum or value > maximum:
raise ImportFailure(f"{label} hors limites")
return value
def optional_text(value, maximum, label):
if value is None:
return None
if not isinstance(value, str) or not value or value != value.strip() or len(value) > maximum:
raise ImportFailure(f"{label} invalide")
return value
def utc_timestamp(value, label):
if not isinstance(value, str) or UTC_RE.fullmatch(value) is None:
raise ImportFailure(f"{label} doit être un instant UTC canonique")
try:
parsed = dt.datetime.fromisoformat(value[:-1] + "+00:00")
except ValueError as error:
raise ImportFailure(f"{label} invalide") from error
if parsed.utcoffset() != dt.timedelta(0):
raise ImportFailure(f"{label} non UTC")
return value
def optional_date(value):
if value is None:
return None
if not isinstance(value, str) or DATE_RE.fullmatch(value) is None:
raise ImportFailure("planned_for invalide")
try:
dt.date.fromisoformat(value)
except ValueError as error:
raise ImportFailure("planned_for invalide") from error
return value
def optional_weight(value):
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ImportFailure("target_weight_kg invalide")
result = float(value)
if not math.isfinite(result) or result <= 0.0 or result > 10000.0:
raise ImportFailure("target_weight_kg hors limites")
return result
def resolve_exercise(connection, source_id):
direct = connection.execute(
"SELECT id,recording_mode,tracking_mode,data_fields FROM exercises WHERE exercise_id=?",
(source_id,),
).fetchone()
if direct is not None:
return direct
alias = connection.execute(
"SELECT e.id,e.recording_mode,e.tracking_mode,e.data_fields FROM exercise_aliases a "
"JOIN exercises e ON e.exercise_id=a.canonical_exercise_id "
"WHERE a.source_exercise_id=?",
(source_id,),
).fetchone()
if alias is None:
raise ImportFailure(f"exercise_id inconnu ou sans alias explicite: {source_id}")
return alias
def validate(payload, connection):
exact(payload, {"format", "version", "draft"}, "racine")
if payload["format"] != FORMAT or type(payload["version"]) is not int or payload["version"] != 1:
raise ImportFailure("format/version AI draft non pris en charge")
draft = payload["draft"]
exact(draft, {"draft_id", "created_at", "planned_for", "session_type", "title", "notes", "entries"}, "draft")
if not isinstance(draft["draft_id"], str) or ID_RE.fullmatch(draft["draft_id"]) is None:
raise ImportFailure("draft_id aid_<uuid-v4> invalide")
utc_timestamp(draft["created_at"], "created_at")
optional_date(draft["planned_for"])
if draft["session_type"] != "training":
raise ImportFailure("session_type doit valoir training")
optional_text(draft["title"], MAX_TITLE, "title")
optional_text(draft["notes"], MAX_NOTES, "notes")
entries = draft["entries"]
if not isinstance(entries, list) or not 1 <= len(entries) <= MAX_ENTRIES:
raise ImportFailure("entries doit contenir 1 à 64 éléments")
validated = []
for expected_position, entry in enumerate(entries):
exact(entry, {"position", "exercise_id", "target_sets", "target_reps", "target_duration_seconds", "target_weight_kg", "rest_seconds"}, f"entries[{expected_position}]")
position = integer(entry["position"], 0, MAX_ENTRIES - 1, "position")
if position != expected_position:
raise ImportFailure("positions entries non contiguës ou hors ordre")
source_id = entry["exercise_id"]
if not isinstance(source_id, str) or EXERCISE_ID_RE.fullmatch(source_id) is None:
raise ImportFailure("exercise_id ex_<uuid-v4> invalide")
exercise_row_id, recording_mode, tracking_mode, data_fields = resolve_exercise(connection, source_id)
if recording_mode != "sets":
raise ImportFailure(f"exercice CONTINUOUS interdit dans un plan SETS: {source_id}")
target_sets = integer(entry["target_sets"], 1, 99, "target_sets")
target_reps = entry["target_reps"]
target_duration = entry["target_duration_seconds"]
if tracking_mode == "reps":
target_reps = integer(target_reps, 1, 999, "target_reps")
if target_duration is not None:
raise ImportFailure("target_duration_seconds interdit pour tracking reps")
elif tracking_mode == "duration":
target_duration = integer(target_duration, 1, 86400, "target_duration_seconds")
if target_reps is not None:
raise ImportFailure("target_reps interdit pour tracking duration")
else:
raise ImportFailure("profil exercice corrompu")
weight = optional_weight(entry["target_weight_kg"])
validated.append((
position, exercise_row_id, source_id, recording_mode, tracking_mode,
data_fields, None, "external" if weight is not None else "none", target_sets,
target_reps, target_duration, weight,
integer(entry["rest_seconds"], 0, 86400, "rest_seconds"),
))
return draft, validated
def canonical_digest(payload):
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def entry_id(draft_id, position):
"""Return a stable UUIDv4-shaped creator ID for one immutable source slot."""
raw = bytearray(hashlib.sha256(f"{draft_id}\n{position}".encode("ascii")).digest()[:16])
raw[6] = (raw[6] & 0x0F) | 0x40
raw[8] = (raw[8] & 0x3F) | 0x80
value = raw.hex()
return f"sxe_{value[:8]}-{value[8:12]}-{value[12:16]}-{value[16:20]}-{value[20:]}"
def import_payload(connection, payload, imported_at=None):
"""Validate fully, then atomically insert or return ``skipped``.
CONTRACT: successful rows represent target-only pending SETS plans.
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
if connection.execute("PRAGMA user_version").fetchone()[0] != 18:
raise ImportFailure("schema desktop v18 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
connection.execute("BEGIN IMMEDIATE")
try:
existing = connection.execute(
"SELECT payload_sha256 FROM ai_session_draft_imports WHERE draft_id=?",
(draft["draft_id"],),
).fetchone()
if existing is not None:
if existing[0] != digest:
raise ImportFailure("conflit: draft_id déjà importé avec un contenu différent")
connection.commit()
return "skipped"
cursor = connection.execute(
"INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes) VALUES(?,?,?,?,?,?)",
(draft["draft_id"], draft["created_at"], draft["planned_for"], draft["session_type"], draft["title"], draft["notes"]),
)
draft_row_id = cursor.lastrowid
connection.executemany(
"INSERT INTO ai_session_draft_entries(draft_row_id,entry_id,position,exercise_row_id,source_exercise_id,recording_mode,tracking_mode,data_fields,equipment_id,load_mode,target_sets,target_reps,target_duration_seconds,target_weight_kg,rest_seconds) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[(draft_row_id, entry_id(draft["draft_id"], row[0])) + row for row in entries],
)
connection.execute(
"INSERT INTO ai_session_draft_imports(draft_row_id,draft_id,payload_sha256,imported_at) VALUES(?,?,?,?)",
(draft_row_id, draft["draft_id"], digest, imported_at),
)
connection.commit()
except Exception:
connection.rollback()
raise
return "imported"
def load(path):
return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object, parse_constant=reject_constant)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
parser.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
try:
payload = load(args.artifact)
connection = connect_database(args.database)
try:
result = import_payload(connection, payload)
finally:
connection.close()
print(f"AI_SESSION_DRAFT_IMPORT=PASS result={result} draft_id={payload['draft']['draft_id']}")
return 0
except (OSError, json.JSONDecodeError, sqlite3.Error, ImportFailure) as error:
print(f"AI_SESSION_DRAFT_IMPORT=FAIL {error}")
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -173,7 +173,7 @@ def main():
fail("clés extension équipement invalides") fail("clés extension équipement invalides")
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17): if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
fail("schema desktop v8 à v16 requis") fail("schema desktop v8 à v16 requis")
known = load_catalog(args.catalog) known = load_catalog(args.catalog)
known.update(row[0] for row in connection.execute( known.update(row[0] for row in connection.execute(

View file

@ -61,7 +61,7 @@ def main():
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog)) definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17): if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
fail("schema desktop v8 à v16 requis") fail("schema desktop v8 à v16 requis")
imported = skipped = 0 imported = skipped = 0
# Validate every same-ID row before inserting any definition. # Validate every same-ID row before inserting any definition.

View file

@ -63,7 +63,7 @@ def main():
con = connect_database(args.database) con = connect_database(args.database)
try: try:
con.execute("PRAGMA foreign_keys=ON") con.execute("PRAGMA foreign_keys=ON")
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17): if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18):
fail("schema desktop v12-v16 requis") fail("schema desktop v12-v16 requis")
con.execute("BEGIN IMMEDIATE") con.execute("BEGIN IMMEDIATE")
for source, canonical in aliases: for source, canonical in aliases:

View file

@ -205,7 +205,7 @@ def main():
connection = connect_database(args.database) connection = connect_database(args.database)
updated = skipped = kept_local = 0 updated = skipped = kept_local = 0
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17): if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18):
raise ImportFailure("schema desktop v11-v16 requis") raise ImportFailure("schema desktop v11-v16 requis")
connection.execute("PRAGMA foreign_keys=ON") connection.execute("PRAGMA foreign_keys=ON")
connection.execute("BEGIN IMMEDIATE") connection.execute("BEGIN IMMEDIATE")

View file

@ -53,7 +53,7 @@ def main():
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") parse_timestamp(root["generated_at"], "generated_at")
con=connect_database(a.database);con.row_factory=sqlite3.Row con=connect_database(a.database);con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0]!=17: fail("schema desktop v17 requis") if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18): fail("schema desktop v17/v18 requis")
applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE") applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE")
for item in root["exercises"]: for item in root["exercises"]:
validate(item);eid=item["exercise_id"] validate(item);eid=item["exercise_id"]

View file

@ -813,9 +813,9 @@ 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 (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17): if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
raise ImportFailure( raise ImportFailure(
f"base desktop schema v5 à v17 attendue, version trouvée: {version}" f"base desktop schema v5 à v18 attendue, version trouvée: {version}"
) )

View file

@ -51,7 +51,7 @@ def main():
roots_added=roots_skipped=revisions_added=revisions_skipped=0 roots_added=roots_skipped=revisions_added=revisions_skipped=0
try: try:
db.execute("PRAGMA foreign_keys=ON") db.execute("PRAGMA foreign_keys=ON")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17):fail("schema desktop v15/v16/v17 requis") if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18):fail("schema desktop v15-v18 requis")
db.execute("BEGIN IMMEDIATE");seen=set() db.execute("BEGIN IMMEDIATE");seen=set()
for index,item in enumerate(root["exercise_feedback"]): for index,item in enumerate(root["exercise_feedback"]):
keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"} keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"}

View file

@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Commit the lifecycle cursor for one already-published AI draft batch."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import sqlite3
from pathlib import Path
from export_ai_session_drafts import mark_published
from trainlog_sqlite import connect_database
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
parser.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
try:
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
published_at = dt.datetime.now(dt.timezone.utc).isoformat(
timespec="seconds").replace("+00:00", "Z")
count = mark_published(connection, payload, published_at)
finally:
connection.close()
print(f"AI_SESSION_DRAFT_PUBLISH_MARK=PASS drafts={count}")
return 0
except (OSError, sqlite3.Error, ValueError, json.JSONDecodeError) as error:
print(f"AI_SESSION_DRAFT_PUBLISH_MARK=FAIL {error}")
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Publish the post-sync TRAINLOG_AI_EXPORT_V1 artifact to Google Drive."""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
DRIVE_TARGET = "TrainLog Gdrive:Trainlog/AI/trainlog_ai_export_v1.json"
def _detail(result: subprocess.CompletedProcess[str]) -> str:
"""Return one bounded, useful external-command diagnostic."""
text = (result.stderr or result.stdout or "").strip()
if not text:
return f"exit={result.returncode}"
return text.splitlines()[-1][:1000]
def publish(database: Path, output: Path) -> int:
"""Export first, then upload only the successfully replaced artifact.
CONTRACT: the exporter remains the sole owner of TRAINLOG_AI_EXPORT_V1
generation. rclone receives only file and remote arguments; Trainlog never
reads, stores, or forwards rclone/OAuth configuration. INVARIANT: an export
failure cannot upload an older file left at the destination path.
"""
project_root = Path(__file__).resolve().parent.parent
resolved_output = output if output.is_absolute() else project_root / output
exporter = project_root / "tools" / "export_ai_history.py"
try:
export = subprocess.run(
[
sys.executable,
str(exporter),
str(resolved_output),
"--database",
str(database),
],
capture_output=True,
text=True,
check=False,
)
except OSError as error:
print(f"AI_EXPORT=FAIL {error}")
print("GDRIVE_UPLOAD=SKIPPED")
return 1
if export.returncode != 0 or "TRAINLOG_AI_EXPORT_V1=PASS" not in export.stdout:
print(f"AI_EXPORT=FAIL {_detail(export)}")
print("GDRIVE_UPLOAD=SKIPPED")
return 1
print("AI_EXPORT=PASS")
try:
upload = subprocess.run(
["rclone", "copyto", str(resolved_output), DRIVE_TARGET],
capture_output=True,
text=True,
check=False,
)
except OSError as error:
print(f"GDRIVE_UPLOAD=FAIL {error}")
return 1
if upload.returncode != 0:
print(f"GDRIVE_UPLOAD=FAIL {_detail(upload)}")
return 1
print("GDRIVE_UPLOAD=PASS")
return 0
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"output",
type=Path,
nargs="?",
default=Path("trainlog_ai_export_v1.json"),
)
parser.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
return publish(args.database, args.output)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Fetch, import, and archive-copy the single AI session-draft inbox artifact."""
from __future__ import annotations
import argparse
import datetime as dt
import os
import sqlite3
import stat
import subprocess
import tempfile
from pathlib import Path
from import_ai_session_draft import ImportFailure, import_payload, load
from trainlog_sqlite import connect_database
INBOX_ROOT = "TrainLog Gdrive:Trainlog/AI/inbox"
ARTIFACT_NAME = "trainlog_ai_session_draft_v1.json"
SOURCE = f"{INBOX_ROOT}/{ARTIFACT_NAME}"
ARCHIVE_ROOT = "TrainLog Gdrive:Trainlog/AI/archive"
RCLONE_TIMEOUT_SECONDS = 15
DIAGNOSTIC_LIMIT = 1000
def detail(result):
value = ((result.stderr or "")[:DIAGNOSTIC_LIMIT] or
(result.stdout or "")[:DIAGNOSTIC_LIMIT]).strip()
return (value.splitlines()[-1] if value else f"exit={result.returncode}")[:DIAGNOSTIC_LIMIT]
def run_rclone(runner, argv):
"""Execute a bounded, shell-free rclone command."""
return runner(argv, capture_output=True, text=True, check=False,
timeout=RCLONE_TIMEOUT_SECONDS)
def inbox_contains_artifact(runner):
"""Return whether the exact canonical filename is present in the inbox."""
try:
listed = run_rclone(runner, ["rclone", "lsf", INBOX_ROOT,
"--files-only", "--max-depth", "1",
"--include", ARTIFACT_NAME])
except (OSError, subprocess.TimeoutExpired) as error:
return None, str(error)[:DIAGNOSTIC_LIMIT]
if listed.returncode != 0:
return None, detail(listed)
names = {(line.strip().rstrip("/")) for line in
(listed.stdout or "")[:DIAGNOSTIC_LIMIT].splitlines()}
return ARTIFACT_NAME in names, None
def update_archive(database, draft_id, status, error=None):
connection = connect_database(database)
try:
archived_at = (dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
if status == "archived" else None)
connection.execute(
"UPDATE ai_session_drafts SET archive_status=?,archived_at=?,archive_error=? WHERE draft_id=?",
(status, archived_at, error, draft_id),
)
connection.commit()
finally:
connection.close()
def try_update_archive(database, draft_id, status, error=None):
try:
update_archive(database, draft_id, status, error)
return None
except (OSError, sqlite3.Error) as update_error:
return str(update_error)[:1000]
def secure_local_draft(directory):
"""Create the inbound destination and verify its private permissions."""
directory_path = Path(directory)
os.chmod(directory_path, 0o700)
if stat.S_IMODE(directory_path.stat().st_mode) != 0o700:
raise OSError("le répertoire temporaire n'est pas privé")
local = directory_path / "draft.json"
descriptor = os.open(local, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
os.close(descriptor)
if stat.S_IMODE(local.stat().st_mode) != 0o600:
raise OSError("le brouillon temporaire n'est pas privé")
return local
def enforce_private_local_draft(local):
"""Restore and verify the inbound destination's private permissions."""
os.chmod(local, 0o600)
if stat.S_IMODE(local.stat().st_mode) != 0o600:
raise OSError("le brouillon temporaire n'est pas privé")
def synchronize(database, runner=subprocess.run):
"""Run shell-free rclone operations; transport failures are nonfatal.
WHY: a fixed /tmp filename could re-import stale content after a failed
download. CONTRACT: copyto writes only a fresh mode-0600 tempfile and argv
is never interpreted by a shell. INVARIANT: the exact validated tempfile is
archive-copied only after the importer commits; the mutable inbox object is
never moved or deleted, so every failure remains retryable.
"""
with tempfile.TemporaryDirectory(prefix="trainlog-ai-draft-") as directory:
present, listing_error = inbox_contains_artifact(runner)
if present is None:
print(f"AI_SESSION_DRAFT_FETCH=FAIL {listing_error}")
print("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL")
return 0
if not present:
print("AI_SESSION_DRAFT_FETCH=NONE")
print("AI_SESSION_DRAFT_INBOUND=NONE")
return 0
try:
local = secure_local_draft(directory)
except OSError as error:
print(f"AI_SESSION_DRAFT_FETCH=FAIL {error}")
print("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL")
return 0
try:
fetched = run_rclone(runner, ["rclone", "copyto", SOURCE, str(local)])
except (OSError, subprocess.TimeoutExpired) as error:
print(f"AI_SESSION_DRAFT_FETCH=FAIL {error}")
print("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL")
return 0
if fetched.returncode != 0:
print(f"AI_SESSION_DRAFT_FETCH=FAIL {detail(fetched)}")
print("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL")
return 0
try:
if not local.is_file() or local.stat().st_size == 0:
raise OSError("rclone n'a produit aucun fichier")
enforce_private_local_draft(local)
except OSError as error:
print(f"AI_SESSION_DRAFT_FETCH=FAIL {error}")
print("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL")
return 0
try:
payload = load(local)
connection = connect_database(database)
try:
result = import_payload(connection, payload)
finally:
connection.close()
except (OSError, sqlite3.Error, ImportFailure, ValueError) as error:
print(f"AI_SESSION_DRAFT_IMPORT=FAIL {error}")
print("AI_SESSION_DRAFT_INBOUND=REJECTED")
return 0
draft_id = payload["draft"]["draft_id"]
print(f"AI_SESSION_DRAFT_IMPORT=PASS result={result} draft_id={draft_id}")
target = f"{ARCHIVE_ROOT}/{draft_id}.json"
try:
archived = run_rclone(runner, ["rclone", "copyto", str(local), target])
except (OSError, subprocess.TimeoutExpired) as error:
update_error = try_update_archive(database, draft_id, "failed", str(error)[:1000])
print(f"AI_SESSION_DRAFT_ARCHIVE=FAIL {error}")
if update_error is not None:
print(f"AI_SESSION_DRAFT_ARCHIVE_STATUS=FAIL {update_error}")
print("AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL")
return 0
if archived.returncode != 0:
error = detail(archived)
update_error = try_update_archive(database, draft_id, "failed", error)
print(f"AI_SESSION_DRAFT_ARCHIVE=FAIL {error}")
if update_error is not None:
print(f"AI_SESSION_DRAFT_ARCHIVE_STATUS=FAIL {update_error}")
print("AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL")
return 0
update_error = try_update_archive(database, draft_id, "archived")
if update_error is not None:
print(f"AI_SESSION_DRAFT_ARCHIVE_STATUS=FAIL {update_error}")
print("AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL")
return 0
print("AI_SESSION_DRAFT_ARCHIVE=PASS")
print("AI_SESSION_DRAFT_INBOUND=" +
("IMPORTED" if result == "imported" else "ALREADY_IMPORTED"))
return 0
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", help="reserved sync-tool positional argument")
parser.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
if args.artifact != ARTIFACT_NAME:
parser.error(f"artifact must be {ARTIFACT_NAME}")
return synchronize(args.database)
if __name__ == "__main__":
raise SystemExit(main())

View file

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

View file

@ -41,6 +41,16 @@ typedef struct TrainlogSyncDeviceInfo {
TrainlogMtpStorage storage; TrainlogMtpStorage storage;
} TrainlogSyncDeviceInfo; } TrainlogSyncDeviceInfo;
typedef enum TrainlogAiInboundStatus {
TRAINLOG_AI_INBOUND_NOT_RUN = 0,
TRAINLOG_AI_INBOUND_NONE,
TRAINLOG_AI_INBOUND_IMPORTED,
TRAINLOG_AI_INBOUND_ALREADY_IMPORTED,
TRAINLOG_AI_INBOUND_REJECTED,
TRAINLOG_AI_INBOUND_DRIVE_FAIL,
TRAINLOG_AI_INBOUND_ARCHIVE_FAIL
} TrainlogAiInboundStatus;
typedef struct TrainlogSyncReport { typedef struct TrainlogSyncReport {
bool success; bool success;
bool request_present; bool request_present;
@ -72,6 +82,7 @@ typedef struct TrainlogSyncReport {
size_t equipment_definitions_skipped; size_t equipment_definitions_skipped;
size_t catalog_published; size_t catalog_published;
TrainlogAiInboundStatus ai_inbound_status;
char summary[ char summary[
TRAINLOG_SYNC_SUMMARY_MAX + 1U TRAINLOG_SYNC_SUMMARY_MAX + 1U

View file

@ -79,6 +79,7 @@ session_generation_policy_generated = custom_target(
strict_c_args = [ strict_c_args = [
'-D_POSIX_C_SOURCE=200809L', '-D_POSIX_C_SOURCE=200809L',
'-DTRAINLOG_TOOLS_DIR="@0@"'.format(meson.project_source_root() / 'tools'),
'-Wconversion', '-Wconversion',
'-Wformat=2', '-Wformat=2',
'-Wshadow', '-Wshadow',
@ -674,6 +675,24 @@ test_feedback_correction = executable(
) )
test('feedback_correction', test_feedback_correction) test('feedback_correction', test_feedback_correction)
test(
'ai_history_export',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_ai_history_export.py'],
)
test(
'post_sync_ai_drive',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_post_sync_ai_drive.py'],
)
test(
'ai_session_draft',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_ai_session_draft.py'],
)
test_schema_v16_migration = executable( test_schema_v16_migration = executable(
'test_schema_v16_migration', 'test_schema_v16_migration',
'tests/test_schema_v16_migration.c', 'tests/test_schema_v16_migration.c',

View file

@ -772,6 +772,77 @@ static const char *const MIGRATE_V16_TO_V17_SQL =
"INSERT INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;" "INSERT INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;"
"PRAGMA user_version=17;COMMIT;"; "PRAGMA user_version=17;COMMIT;";
/* WHY: generated plans are untrusted inbox data and must never masquerade as
* completed occurrence history. CONTRACT: v18 adds an isolated durable inbox,
* target-only SETS children, canonical import identity, bounded publication
* state, and post-commit Drive archive state. INVARIANT: no existing domain
* table or frozen exchange column is changed. */
static const char *const MIGRATE_V17_TO_V18_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE ai_session_drafts("
"id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL,"
"planned_for TEXT,session_type TEXT NOT NULL CHECK(session_type='training'),"
"title TEXT,notes TEXT,archive_status TEXT NOT NULL DEFAULT 'pending' "
"CHECK(archive_status IN('pending','archived','failed')),"
"archived_at TEXT,archive_error TEXT,published_at TEXT);"
"CREATE TABLE ai_session_draft_entries("
"id INTEGER PRIMARY KEY,draft_row_id INTEGER NOT NULL REFERENCES ai_session_drafts(id) ON DELETE CASCADE,"
"entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL CHECK(position>=0),exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,"
"source_exercise_id TEXT NOT NULL,recording_mode TEXT NOT NULL CHECK(recording_mode='sets'),"
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),data_fields INTEGER NOT NULL,"
"equipment_id TEXT,load_mode TEXT NOT NULL CHECK(load_mode IN('none','external')),"
"target_sets INTEGER NOT NULL CHECK(target_sets BETWEEN 1 AND 99),"
"target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,"
"rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400),"
"UNIQUE(draft_row_id,position));"
"CREATE INDEX ai_session_draft_entries_exercise ON ai_session_draft_entries(exercise_row_id);"
"CREATE TABLE ai_session_draft_imports("
"draft_row_id INTEGER PRIMARY KEY,"
"draft_id TEXT NOT NULL UNIQUE,payload_sha256 TEXT NOT NULL UNIQUE,imported_at TEXT NOT NULL);"
"CREATE TRIGGER ai_session_draft_import_identity_guard BEFORE DELETE ON ai_session_drafts "
"WHEN EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) "
"BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;"
"PRAGMA user_version=18;COMMIT;";
/* CONTRACT: v18 was already deployed before bounded draft publication was
* added. Keep its version number and add only the nullable lifecycle cursor;
* NULL deliberately means retryable/not yet published. */
static TrainlogStatus ensure_v18_ai_draft_publication_state(
TrainlogDatabase *database
)
{
sqlite3_stmt *statement = NULL;
int rc;
bool found = false;
rc = sqlite3_prepare_v2(database->connection,
"PRAGMA table_info(ai_session_drafts);", -1, &statement, NULL);
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
const unsigned char *name = sqlite3_column_text(statement, 1);
if (name != NULL && strcmp((const char *)name, "published_at") == 0) {
found = true;
break;
}
}
if (rc != SQLITE_ROW && rc != SQLITE_DONE) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (sqlite3_finalize(statement) != SQLITE_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
if (!found && execute_sql(database,
"ALTER TABLE ai_session_drafts ADD COLUMN published_at TEXT;") !=
TRAINLOG_STATUS_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
/* INVARIANT: older v18 tables may still declare a cascading FK. This
* guard runs before it and makes replay identity durable for both shapes. */
return execute_sql(database,
"CREATE TRIGGER IF NOT EXISTS ai_session_draft_import_identity_guard "
"BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM "
"ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN "
"SELECT RAISE(ABORT,'AI draft import identity is permanent');END;");
}
/* WHY: the published-unreleased v17 prototype may already exist locally. /* WHY: the published-unreleased v17 prototype may already exist locally.
* CONTRACT: opening it adds the bounded durable lineage table without changing * CONTRACT: opening it adds the bounded durable lineage table without changing
* user_version or domain rows. INVARIANT: the guarded trigger records local * user_version or domain rows. INVARIANT: the guarded trigger records local
@ -1425,7 +1496,7 @@ static TrainlogStatus initialize_or_validate_schema(
} else if (version == 10) { } else if (version == 10) {
status = TRAINLOG_STATUS_OK; status = TRAINLOG_STATUS_OK;
} else if (version == 11 || version == 12 || version == 13 || version == 14 || } else if (version == 11 || version == 12 || version == 13 || version == 14 ||
version == 15 || version == 16 || version == 17) { version == 15 || version == 16 || version == 17 || version == 18) {
status = TRAINLOG_STATUS_OK; status = TRAINLOG_STATUS_OK;
} else { } else {
if (version == 1) { if (version == 1) {
@ -1586,6 +1657,12 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK && version < 17) { if (status == TRAINLOG_STATUS_OK && version < 17) {
status = execute_sql(database, MIGRATE_V16_TO_V17_SQL); status = execute_sql(database, MIGRATE_V16_TO_V17_SQL);
} }
if (status == TRAINLOG_STATUS_OK && version < 18) {
status = execute_sql(database, MIGRATE_V17_TO_V18_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, "BEGIN IMMEDIATE;"); if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, "BEGIN IMMEDIATE;");
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, ENSURE_V17_PROFILE_REVISIONS_SQL); if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, ENSURE_V17_PROFILE_REVISIONS_SQL);
if (status == TRAINLOG_STATUS_OK) status = normalize_v17_prototype_profiles(database); if (status == TRAINLOG_STATUS_OK) status = normalize_v17_prototype_profiles(database);
@ -1598,7 +1675,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic( set_open_diagnostic(
output_diagnostic, output_diagnostic,
output_diagnostic_capacity, output_diagnostic_capacity,
version == 0 ? "create schema v17" : "migrate database to schema v17", version == 0 ? "create schema v18" : "migrate database to schema v18",
database->connection, database->connection,
SQLITE_ERROR SQLITE_ERROR
); );

View file

@ -63,6 +63,8 @@ static const char *const TRAINING_FEEDBACK_V1_NAME =
"trainlog-training-feedback-v1.json"; "trainlog-training-feedback-v1.json";
static const char *const EXERCISE_PROFILE_STATE_NAME = static const char *const EXERCISE_PROFILE_STATE_NAME =
"trainlog-exercise-profile-state-v1.json"; "trainlog-exercise-profile-state-v1.json";
static const char *const AI_SESSION_DRAFTS_NAME =
"trainlog-ai-session-drafts-v1.json";
static const char *const SYNC_REQUEST_NAME = static const char *const SYNC_REQUEST_NAME =
"trainlog-sync-request-v1.json"; "trainlog-sync-request-v1.json";
@ -100,6 +102,8 @@ static const char *const TRAINING_FEEDBACK_LOCAL =
"/tmp/trainlog-training-feedback-v2.json"; "/tmp/trainlog-training-feedback-v2.json";
static const char *const EXERCISE_PROFILE_STATE_LOCAL = static const char *const EXERCISE_PROFILE_STATE_LOCAL =
"/tmp/trainlog-exercise-profile-state-v1.json"; "/tmp/trainlog-exercise-profile-state-v1.json";
static const char *const AI_SESSION_DRAFTS_LOCAL =
"/tmp/trainlog-ai-session-drafts-v1.json";
static const char *const EXERCISE_BODY_ZONES_RESULT = static const char *const EXERCISE_BODY_ZONES_RESULT =
"/tmp/trainlog-exercise-body-zones-result.txt"; "/tmp/trainlog-exercise-body-zones-result.txt";
@ -126,6 +130,11 @@ static const char *const PC_CATALOG_RESULT =
static const char *const EQUIPMENT_ASSOCIATIONS_RESULT = static const char *const EQUIPMENT_ASSOCIATIONS_RESULT =
"/tmp/trainlog-equipment-associations-result.txt"; "/tmp/trainlog-equipment-associations-result.txt";
static const char *const AI_POST_SYNC_RESULT =
"/tmp/trainlog-ai-post-sync-result.txt";
static const char *const AI_SESSION_DRAFT_RESULT =
"/tmp/trainlog-ai-session-draft-result.txt";
typedef struct SyncSilence { typedef struct SyncSilence {
int saved_stdout; int saved_stdout;
int saved_stderr; int saved_stderr;
@ -165,6 +174,60 @@ static void sync_compose_diagnostic(
output[written] = '\0'; output[written] = '\0';
} }
static const char *sync_ai_inbound_status_text(TrainlogAiInboundStatus status)
{
switch (status) {
case TRAINLOG_AI_INBOUND_NONE: return "NONE";
case TRAINLOG_AI_INBOUND_IMPORTED: return "IMPORTED";
case TRAINLOG_AI_INBOUND_ALREADY_IMPORTED: return "ALREADY_IMPORTED";
case TRAINLOG_AI_INBOUND_REJECTED: return "REJECTED";
case TRAINLOG_AI_INBOUND_DRIVE_FAIL: return "DRIVE_FAIL";
case TRAINLOG_AI_INBOUND_ARCHIVE_FAIL: return "ARCHIVE_FAIL";
case TRAINLOG_AI_INBOUND_NOT_RUN: default: return "NOT_RUN";
}
}
static TrainlogAiInboundStatus sync_parse_ai_inbound_status(const char *text)
{
static const struct {
const char *marker;
TrainlogAiInboundStatus status;
} values[] = {
{"AI_SESSION_DRAFT_INBOUND=ALREADY_IMPORTED", TRAINLOG_AI_INBOUND_ALREADY_IMPORTED},
{"AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL", TRAINLOG_AI_INBOUND_ARCHIVE_FAIL},
{"AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", TRAINLOG_AI_INBOUND_DRIVE_FAIL},
{"AI_SESSION_DRAFT_INBOUND=IMPORTED", TRAINLOG_AI_INBOUND_IMPORTED},
{"AI_SESSION_DRAFT_INBOUND=REJECTED", TRAINLOG_AI_INBOUND_REJECTED},
{"AI_SESSION_DRAFT_INBOUND=NONE", TRAINLOG_AI_INBOUND_NONE},
};
TrainlogAiInboundStatus result = TRAINLOG_AI_INBOUND_NOT_RUN;
const char *line;
size_t index;
if (text == NULL) return TRAINLOG_AI_INBOUND_NOT_RUN;
/* WHY: helper diagnostics are untrusted text and may quote a status marker.
* CONTRACT: only a complete marker line is a helper result; CRLF is accepted.
* INVARIANT: scanning through all lines makes the last valid result authoritative. */
line = text;
while (*line != '\0') {
const char *newline = strchr(line, '\n');
size_t length = newline != NULL ? (size_t)(newline - line) : strlen(line);
if (length > 0U && line[length - 1U] == '\r') length -= 1U;
for (index = 0U; index < sizeof(values) / sizeof(values[0]); ++index) {
size_t marker_length = strlen(values[index].marker);
if (length == marker_length &&
memcmp(line, values[index].marker, marker_length) == 0) {
result = values[index].status;
}
}
if (newline == NULL) break;
line = newline + 1;
}
return result;
}
TrainlogSyncDirectionPlan trainlog_sync_direction_plan( TrainlogSyncDirectionPlan trainlog_sync_direction_plan(
TrainlogSyncDirection direction TrainlogSyncDirection direction
) )
@ -909,7 +972,7 @@ static TrainlogStatus sync_find_exchange_folder(
uint32_t *output_folder_id uint32_t *output_folder_id
) )
{ {
uint32_t download_id = 0U; uint32_t documents_id = 0U;
uint64_t ignored_size = 0U; uint64_t ignored_size = 0U;
TrainlogStatus status; TrainlogStatus status;
@ -920,13 +983,16 @@ static TrainlogStatus sync_find_exchange_folder(
TRAINLOG_STATUS_INVALID_ARGUMENT; TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
/* CONTRACT: Documents/Trainlog is the only writable exchange endpoint.
* The historical Download/Trainlog tree may remain on the device, but no
* publication path may resolve or mutate it. */
status = status =
sync_find_child( sync_find_child(
device, device,
UINT32_MAX, UINT32_MAX,
"Download", "Documents",
true, true,
&download_id, &documents_id,
&ignored_size &ignored_size
); );
@ -940,7 +1006,7 @@ static TrainlogStatus sync_find_exchange_folder(
return return
sync_find_child( sync_find_child(
device, device,
download_id, documents_id,
"Trainlog", "Trainlog",
true, true,
output_folder_id, output_folder_id,
@ -1117,6 +1183,7 @@ static bool sync_resolve_repo_tool(
size_t output_size size_t output_size
) )
{ {
const char *configured_directory;
char executable[ char executable[
PATH_MAX + 1U PATH_MAX + 1U
]; ];
@ -1134,6 +1201,29 @@ static bool sync_resolve_repo_tool(
return false; return false;
} }
/* WHY: installed TUI binaries are not necessarily nested under the source
* tree, whereas the Python sync helpers remain repository-owned assets.
* CONTRACT: an explicit process-local override is accepted for packaged
* deployments and tests; otherwise Meson's absolute source tools path is
* authoritative. INVARIANT: resolution never invokes a shell and accepts
* only the caller-supplied fixed helper filename. */
configured_directory = getenv("TRAINLOG_TOOLS_DIR");
if (configured_directory != NULL && configured_directory[0] != '\0') {
written = snprintf(output, output_size, "%s/%s",
configured_directory, tool_name);
return written >= 0 && (size_t)written < output_size &&
access(output, R_OK) == 0;
}
configured_directory = TRAINLOG_TOOLS_DIR;
written = snprintf(output, output_size, "%s/%s",
configured_directory, tool_name);
if (written >= 0 && (size_t)written < output_size &&
access(output, R_OK) == 0) {
return true;
}
/* Compatibility for binaries from older or relocated build trees. */
length = length =
readlink( readlink(
"/proc/self/exe", "/proc/self/exe",
@ -1273,17 +1363,6 @@ static TrainlogStatus sync_run_python_tool(
output[0] = '\0'; output[0] = '\0';
if (
!sync_resolve_repo_tool(
tool_name,
tool,
sizeof(tool)
)
) {
return
TRAINLOG_STATUS_NOT_FOUND;
}
result_fd = result_fd =
open( open(
result_path, result_path,
@ -1295,10 +1374,21 @@ static TrainlogStatus sync_run_python_tool(
); );
if (result_fd < 0) { if (result_fd < 0) {
(void)snprintf(output, output_size,
"résultat outil Python inaccessible (errno=%d)", errno);
return return
TRAINLOG_STATUS_SYSTEM_ERROR; TRAINLOG_STATUS_SYSTEM_ERROR;
} }
/* INVARIANT: a prior run can never satisfy the current run's PASS check,
* including when helper resolution fails before a child is started. */
if (!sync_resolve_repo_tool(tool_name, tool, sizeof(tool))) {
(void)close(result_fd);
(void)snprintf(output, output_size,
"outil Python introuvable: %s", tool_name);
return TRAINLOG_STATUS_NOT_FOUND;
}
child = fork(); child = fork();
if (child < (pid_t)0) { if (child < (pid_t)0) {
@ -1306,8 +1396,9 @@ static TrainlogStatus sync_run_python_tool(
result_fd result_fd
); );
return (void)snprintf(output, output_size,
TRAINLOG_STATUS_SYSTEM_ERROR; "lancement outil Python échoué (errno=%d)", errno);
return TRAINLOG_STATUS_SYSTEM_ERROR;
} }
if (child == (pid_t)0) { if (child == (pid_t)0) {
@ -1366,6 +1457,8 @@ static TrainlogStatus sync_run_python_tool(
); );
} }
(void)dprintf(STDERR_FILENO,
"python3: exécution impossible (errno=%d)\n", errno);
_exit(127); _exit(127);
} }
@ -1380,15 +1473,16 @@ static TrainlogStatus sync_run_python_tool(
0 0
) < (pid_t)0 ) < (pid_t)0
) { ) {
return (void)snprintf(output, output_size,
TRAINLOG_STATUS_SYSTEM_ERROR; "attente outil Python échouée (errno=%d)", errno);
return TRAINLOG_STATUS_SYSTEM_ERROR;
} }
(void)sync_read_text( if (!sync_read_text(result_path, output, output_size)) {
result_path, (void)snprintf(output, output_size,
output, "lecture diagnostic outil Python échouée");
output_size return TRAINLOG_STATUS_SYSTEM_ERROR;
); }
if ( if (
!WIFEXITED( !WIFEXITED(
@ -1398,6 +1492,18 @@ static TrainlogStatus sync_run_python_tool(
child_status child_status
) != 0 ) != 0
) { ) {
if (output[0] == '\0') {
if (WIFSIGNALED(child_status)) {
(void)snprintf(output, output_size,
"outil Python interrompu par signal %d",
WTERMSIG(child_status));
} else {
(void)snprintf(output, output_size,
"outil Python terminé avec code %d",
WIFEXITED(child_status)
? WEXITSTATUS(child_status) : -1);
}
}
return return
TRAINLOG_STATUS_DATABASE_ERROR; TRAINLOG_STATUS_DATABASE_ERROR;
} }
@ -2354,6 +2460,13 @@ bool trainlog_sync_record_local_run(
return false; return false;
} }
(void)fputs(",\n \"ai_inbound_status\":", json_file);
if (!sync_json_write_escaped(json_file,
sync_ai_inbound_status_text(report->ai_inbound_status))) {
(void)fclose(json_file);
return false;
}
(void)fprintf( (void)fprintf(
json_file, json_file,
",\n \"android_to_pc\":{" ",\n \"android_to_pc\":{"
@ -2424,6 +2537,11 @@ bool trainlog_sync_record_local_run(
); );
} }
if (report->ai_inbound_status != TRAINLOG_AI_INBOUND_NOT_RUN) {
(void)fprintf(detail_file, "AI entrant : %s\n",
sync_ai_inbound_status_text(report->ai_inbound_status));
}
(void)fprintf( (void)fprintf(
detail_file, detail_file,
"\nANDROID → PC\n" "\nANDROID → PC\n"
@ -2545,6 +2663,58 @@ void trainlog_sync_build_summary(
: "Synchronisation échouée." : "Synchronisation échouée."
); );
} }
if (report->ai_inbound_status != TRAINLOG_AI_INBOUND_NOT_RUN) {
size_t used = strlen(report->summary);
if (used < sizeof(report->summary)) {
(void)snprintf(report->summary + used,
sizeof(report->summary) - used, " · AI_INBOUND=%s",
sync_ai_inbound_status_text(report->ai_inbound_status));
}
}
}
static void sync_append_post_sync_summary(
TrainlogSyncReport *report,
const char *tool_output
)
{
char export_status[96] = "AI_EXPORT=FAIL";
char upload_status[96] = "GDRIVE_UPLOAD=SKIPPED";
const char *line;
size_t used;
if (report == NULL) {
return;
}
line = tool_output != NULL ? strstr(tool_output, "AI_EXPORT=") : NULL;
if (line != NULL) {
size_t length = strcspn(line, "\r\n");
(void)snprintf(export_status, sizeof(export_status), "%.*s",
(int)(length < sizeof(export_status) - 1U
? length : sizeof(export_status) - 1U), line);
}
line = tool_output != NULL ? strstr(tool_output, "GDRIVE_UPLOAD=") : NULL;
if (line != NULL) {
size_t length = strcspn(line, "\r\n");
(void)snprintf(upload_status, sizeof(upload_status), "%.*s",
(int)(length < sizeof(upload_status) - 1U
? length : sizeof(upload_status) - 1U), line);
}
used = strlen(report->summary);
if (used < sizeof(report->summary)) {
/* WHY: post-sync publication is operational follow-up, not part of
* the already committed Trainlog synchronization. CONTRACT: expose
* each outcome while preserving report.success and the sync return
* status. INVARIANT: the fixed report buffer remains terminated. */
(void)snprintf(
report->summary + used,
sizeof(report->summary) - used,
" · SYNC=PASS %s %s",
export_status,
upload_status
);
}
} }
static TrainlogStatus sync_prepare_run_identity( static TrainlogStatus sync_prepare_run_identity(
@ -2750,7 +2920,7 @@ TrainlogStatus trainlog_sync_run(
(void)snprintf( (void)snprintf(
output->error, output->error,
sizeof(output->error), sizeof(output->error),
"Résolution de Download/Trainlog échouée (status=%d).", "Résolution de Documents/Trainlog échouée (status=%d).",
(int)status (int)status
); );
@ -2882,7 +3052,7 @@ TrainlogStatus trainlog_sync_run(
} }
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) { if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
goto outbound; goto ai_draft_midpoint;
} }
/* WHY: aliases must exist before the frozen session/catalog snapshot is /* WHY: aliases must exist before the frozen session/catalog snapshot is
@ -3196,13 +3366,67 @@ TrainlogStatus trainlog_sync_run(
goto finalize; goto finalize;
} }
ai_draft_midpoint:
/* WHY: an Android-triggered bidirectional run must publish a newly fetched
* AI draft immediately, not one synchronization later. CONTRACT: the
* shell-free helper owns Drive fetch, strict transactional import, and
* post-commit archive; transport/archive failures are explicitly nonfatal.
* INVARIANT: this midpoint precedes every PC→Android artifact and receipt. */
status = sync_run_python_tool("sync_ai_session_draft_drive.py",
"trainlog_ai_session_draft_v1.json", database_path, NULL,
AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output));
output->ai_inbound_status = sync_parse_ai_inbound_status(tool_output);
if (status != TRAINLOG_STATUS_OK) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(output->error, sizeof(output->error),
"AI→PC : brouillon séance : ",
useful[0] != '\0' ? useful : "import échoué");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
if (output->ai_inbound_status == TRAINLOG_AI_INBOUND_NOT_RUN) {
(void)snprintf(output->error, sizeof(output->error),
"AI→PC : statut du brouillon séance absent.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) { if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
output->success = true; output->success = true;
final_status = TRAINLOG_STATUS_OK; final_status = TRAINLOG_STATUS_OK;
goto finalize; goto finalize;
} }
outbound: status = sync_run_python_tool("export_ai_session_drafts.py",
AI_SESSION_DRAFTS_LOCAL, database_path, NULL,
AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "AI_SESSION_DRAFT_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : export brouillons IA échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id,
AI_SESSION_DRAFTS_LOCAL, AI_SESSION_DRAFTS_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication brouillons IA échouée.");
final_status = status;
goto finalize;
}
status = sync_run_python_tool("mark_ai_session_drafts_published.py",
AI_SESSION_DRAFTS_LOCAL, database_path, NULL,
AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "AI_SESSION_DRAFT_PUBLISH_MARK=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : enregistrement publication brouillons IA échoué.");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
status = sync_run_python_tool("export_exercise_profile_state.py", status = sync_run_python_tool("export_exercise_profile_state.py",
EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL, EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL,
EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output)); EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output));
@ -3468,11 +3692,6 @@ finalize:
} }
} }
(void)trainlog_sync_record_local_run(
trigger,
output
);
if ( if (
output->request_present && output->request_present &&
receipt_published receipt_published
@ -3482,6 +3701,32 @@ finalize:
); );
} }
if (final_status == TRAINLOG_STATUS_OK && output->success) {
TrainlogStatus post_status = sync_run_python_tool(
"post_sync_ai_drive.py",
"trainlog_ai_export_v1.json",
database_path,
NULL,
AI_POST_SYNC_RESULT,
tool_output,
sizeof(tool_output)
);
/* A launcher failure has no script output, but it must still remain a
* visible post-sync failure rather than changing Trainlog sync state. */
if (post_status != TRAINLOG_STATUS_OK && tool_output[0] == '\0') {
(void)snprintf(tool_output, sizeof(tool_output),
"AI_EXPORT=FAIL launcher status=%d\n"
"GDRIVE_UPLOAD=SKIPPED\n", (int)post_status);
}
sync_append_post_sync_summary(output, tool_output);
}
(void)trainlog_sync_record_local_run(
trigger,
output
);
done: done:
if (silence_active) { if (silence_active) {
sync_silence_end( sync_silence_end(

View file

@ -199,7 +199,7 @@ static int migration_preserves_identity_and_history(void)
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL; raw = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 17); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 18);
CHECK(trainlog_database_list_exercise_body_zones(database, CHECK(trainlog_database_list_exercise_body_zones(database,
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK); "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 2U); CHECK(count == 2U);

View file

@ -84,7 +84,7 @@ static bool run_test(void)
(void)fprintf(stderr, "open diagnostic: %s\n", diagnostic); (void)fprintf(stderr, "open diagnostic: %s\n", diagnostic);
CHECK(false); CHECK(false);
} }
CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && version == 17); CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && version == 18);
trainlog_database_close(db); db = NULL; trainlog_database_close(db); db = NULL;
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK); CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK);
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE s.revision_id IS NULL OR s.revision_id<>'pr_legacy_v1' OR s.legacy_seed<>1", 0)); CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE s.revision_id IS NULL OR s.revision_id<>'pr_legacy_v1' OR s.legacy_seed<>1", 0));
@ -97,6 +97,10 @@ static bool run_test(void)
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_feedback_revisions WHERE revision_id='fr_old'", 1)); CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_feedback_revisions WHERE revision_id='fr_old'", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followups WHERE id=23", 1)); CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followups WHERE id=23", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followup_revisions WHERE revision_id='ur_old'", 1)); CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followup_revisions WHERE revision_id='ur_old'", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('ai_session_drafts') WHERE name='archive_status'", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('ai_session_draft_entries') WHERE name='target_sets'", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('ai_session_draft_imports') WHERE name='payload_sha256'", 1));
CHECK(scalar(raw, "SELECT COUNT(*) FROM ai_session_drafts", 0));
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_update_exercise_profiled(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", "Planche face sol", CHECK(trainlog_database_update_exercise_profiled(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", "Planche face sol",

View file

@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema v17: SQLite rc=", CHECK(strncmp(diagnostic, "migrate database to schema v18: SQLite rc=",
strlen("migrate database to schema v17: SQLite rc=")) == 0); strlen("migrate database to schema v18: 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);
@ -154,12 +154,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=18;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_exec(raw, "PRAGMA user_version=19;", 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, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 18 is newer") != NULL); CHECK(strstr(diagnostic, "schema version 19 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;

View file

@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v17") != NULL); CHECK(strstr(diagnostic, "migrate database to schema v18") != 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);

View file

@ -29,6 +29,75 @@ static const char *remote_mobile_name = "trainlog-mobile-export-v2.json";
static unsigned int remote_mobile_version = 2U; static unsigned int remote_mobile_version = 2U;
static const char *remote_tracking_mode = "reps"; static const char *remote_tracking_mode = "reps";
static bool remote_profile_available = true; static bool remote_profile_available = true;
static bool remote_alias_available = false;
static char rclone_log[4096];
static bool install_fake_rclone(const char *root)
{
char bin[4096];
char executable[4096];
char path[8192];
const char *old_path = getenv("PATH");
FILE *file;
CHECK(snprintf(bin, sizeof(bin), "%s/bin", root) > 0);
CHECK(mkdir(bin, 0700) == 0);
CHECK(snprintf(executable, sizeof(executable), "%s/rclone", bin) > 0);
file = fopen(executable, "wb");
CHECK(file != NULL);
CHECK(fputs("#!/bin/sh\n"
"if test \"$1\" = lsf; then\n"
" test \"$2\" = 'TrainLog Gdrive:Trainlog/AI/inbox' || exit 65\n"
" case \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" in\n"
" invalid|archive_fail|imported|already) printf 'trainlog_ai_session_draft_v1.json\\n'; exit 0 ;;\n"
" marker_prefix) printf 'diagnostic AI_SESSION_DRAFT_INBOUND=IMPORTED\\n' >&2; exit 65 ;;\n"
" marker_suffix) printf 'AI_SESSION_DRAFT_INBOUND=IMPORTED diagnostic\\n' >&2; exit 65 ;;\n"
" none) exit 0 ;;\n"
" *) exit 65 ;;\n"
" esac\n"
"fi\n"
"test \"$1\" = copyto || exit 64\n"
"case \"$2\" in\n"
" 'TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json')\n"
" case \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" in\n"
" invalid) printf '{}\\n' > \"$3\"; exit 0 ;;\n"
" archive_fail|imported|already) cp \"$TRAINLOG_TEST_AI_SOURCE\" \"$3\"; exit 0 ;;\n"
" *) exit 65 ;;\n"
" esac ;;\n"
"esac\n"
"test -f \"$2\" || exit 65\n"
"case \"$3\" in\n"
" 'TrainLog Gdrive:Trainlog/AI/archive/'*)\n"
" test \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" != archive_fail || exit 9 ;;\n"
"esac\n"
"printf '%s\\n' \"$3\" >> \"$TRAINLOG_TEST_RCLONE_LOG\"\n",
file) >= 0);
CHECK(fclose(file) == 0);
CHECK(chmod(executable, 0700) == 0);
CHECK(snprintf(rclone_log, sizeof(rclone_log), "%s/rclone.log", root) > 0);
CHECK(setenv("TRAINLOG_TEST_RCLONE_LOG", rclone_log, 1) == 0);
CHECK(snprintf(path, sizeof(path), "%s:%s", bin,
old_path != NULL ? old_path : "") > 0);
CHECK(setenv("PATH", path, 1) == 0);
return true;
}
static size_t rclone_upload_count(void)
{
FILE *file = fopen(rclone_log, "rb");
size_t count = 0U;
int byte;
if (file == NULL) {
return 0U;
}
while ((byte = fgetc(file)) != EOF) {
if (byte == '\n') {
count += 1U;
}
}
CHECK(fclose(file) == 0);
return count;
}
static bool copy_file(const char *source, const char *target) static bool copy_file(const char *source, const char *target)
{ {
@ -134,13 +203,20 @@ TrainlogStatus trainlog_mtp_list_folder(
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
if (parent_folder_id == UINT32_MAX) { if (parent_folder_id == UINT32_MAX) {
if (capacity < 1U) { if (capacity < 2U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
set_entry(&output[0], 1U, UINT32_MAX, "Download", true); set_entry(&output[0], 1U, UINT32_MAX, "Documents", true);
*output_count = 1U; /* CONTRACT fixture: the historical exchange tree remains present but
* must never be selected for reads or publications after migration. */
set_entry(&output[1], 90U, UINT32_MAX, "Download", true);
*output_count = 2U;
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
if (parent_folder_id == 90U) {
fprintf(stderr, "legacy Download tree was accessed\n");
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (parent_folder_id == 1U) { if (parent_folder_id == 1U) {
if (capacity < 1U) { if (capacity < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
@ -150,7 +226,9 @@ TrainlogStatus trainlog_mtp_list_folder(
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
if (parent_folder_id == 2U) { if (parent_folder_id == 2U) {
size_t count = remote_profile_available ? 4U : 3U; size_t count = 3U + (remote_profile_available ? 1U : 0U) +
(remote_alias_available ? 1U : 0U);
size_t next = 3U;
if (capacity < count) { if (capacity < count) {
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
@ -160,8 +238,13 @@ TrainlogStatus trainlog_mtp_list_folder(
set_entry(&output[2], 12U, 2U, set_entry(&output[2], 12U, 2U,
"trainlog-equipment-associations-v2.json", false); "trainlog-equipment-associations-v2.json", false);
if (remote_profile_available) { if (remote_profile_available) {
set_entry(&output[3], 13U, 2U, set_entry(&output[next], 13U, 2U,
"trainlog-exercise-profile-state-v1.json", false); "trainlog-exercise-profile-state-v1.json", false);
next += 1U;
}
if (remote_alias_available) {
set_entry(&output[next], 14U, 2U,
"trainlog-exercise-aliases-v1.json", false);
} }
*output_count = count; *output_count = count;
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
@ -186,7 +269,8 @@ TrainlogStatus trainlog_mtp_receive_file(
name = item_id == 10U ? remote_mobile_name : name = item_id == 10U ? remote_mobile_name :
item_id == 11U ? "trainlog-exercise-body-zones-v1.json" : item_id == 11U ? "trainlog-exercise-body-zones-v1.json" :
item_id == 12U ? "trainlog-equipment-associations-v2.json" : item_id == 12U ? "trainlog-equipment-associations-v2.json" :
item_id == 13U ? "trainlog-exercise-profile-state-v1.json" : NULL; item_id == 13U ? "trainlog-exercise-profile-state-v1.json" :
item_id == 14U ? "trainlog-exercise-aliases-v1.json" : NULL;
if (name == NULL || local_path == NULL) { if (name == NULL || local_path == NULL) {
return TRAINLOG_STATUS_NOT_FOUND; return TRAINLOG_STATUS_NOT_FOUND;
} }
@ -289,6 +373,18 @@ static bool write_artifacts(
} }
CHECK(fclose(file) == 0); CHECK(fclose(file) == 0);
written = snprintf(path, sizeof(path),
"%s/trainlog-exercise-aliases-v1.json", remote_root);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "wb");
CHECK(file != NULL);
CHECK(fprintf(file,
"{\"format\":\"trainlog-exercise-aliases\",\"version\":1,"
"\"aliases\":[{\"source_exercise_id\":\"%s\","
"\"canonical_exercise_id\":\"%s\"}]}",
SOURCE_ID, CANONICAL_ID) > 0);
CHECK(fclose(file) == 0);
written = snprintf(path, sizeof(path), written = snprintf(path, sizeof(path),
"%s/trainlog-exercise-profile-state-v1.json", remote_root); "%s/trainlog-exercise-profile-state-v1.json", remote_root);
CHECK(written >= 0 && (size_t)written < sizeof(path)); CHECK(written >= 0 && (size_t)written < sizeof(path));
@ -382,6 +478,18 @@ static bool prepare_database(
return true; return true;
} }
static bool add_live_source_exercise(const char *database_path)
{
TrainlogDatabase *database = NULL;
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(
database, SOURCE_ID, "Retired sync wiring exercise",
"retired sync wiring exercise", TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS, UINT32_C(0)) == TRAINLOG_STATUS_OK);
trainlog_database_close(database);
return true;
}
static bool database_state( static bool database_state(
const char *database_path, const char *database_path,
bool source_should_resolve, bool source_should_resolve,
@ -466,6 +574,24 @@ static bool successful_journal(const char *case_root, const char *sync_id)
return true; return true;
} }
static bool history_contains(const char *case_root, const char *needle)
{
char path[4096];
char content[65536];
FILE *file;
size_t count;
CHECK(snprintf(path, sizeof(path), "%s/trainlog/sync_history.log",
case_root) > 0);
file = fopen(path, "rb");
CHECK(file != NULL);
count = fread(content, 1U, sizeof(content) - 1U, file);
CHECK(ferror(file) == 0);
CHECK(fclose(file) == 0);
content[count] = '\0';
CHECK(strstr(content, needle) != NULL);
return true;
}
static bool file_contains(const char *name, const char *needle, bool expected) static bool file_contains(const char *name, const char *needle, bool expected)
{ {
char path[4096]; char path[4096];
@ -521,7 +647,9 @@ static bool run_success_case(
database_path, sizeof(database_path))); database_path, sizeof(database_path)));
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, persistent_alias)); CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, persistent_alias));
{ {
TrainlogStatus status = trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false, TrainlogSyncTrigger trigger = persistent_alias
? TRAINLOG_SYNC_TRIGGER_ANDROID : TRAINLOG_SYNC_TRIGGER_TUI;
TrainlogStatus status = trainlog_sync_run(trigger, false,
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL : persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
TRAINLOG_SYNC_ANDROID_TO_PC, &report); TRAINLOG_SYNC_ANDROID_TO_PC, &report);
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
@ -530,6 +658,9 @@ static bool run_success_case(
CHECK(status == TRAINLOG_STATUS_OK); CHECK(status == TRAINLOG_STATUS_OK);
} }
CHECK(report.success); CHECK(report.success);
CHECK(strstr(report.summary, "AI_INBOUND=DRIVE_FAIL") != NULL);
CHECK(strstr(report.summary,
"SYNC=PASS AI_EXPORT=PASS GDRIVE_UPLOAD=PASS") != NULL);
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U)); CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
CHECK(successful_journal(case_root, report.sync_id)); CHECK(successful_journal(case_root, report.sync_id));
if (persistent_alias) { if (persistent_alias) {
@ -551,10 +682,13 @@ static bool run_success_case(
} }
/* Replay exercises the same production argv path and sync baseline. */ /* Replay exercises the same production argv path and sync baseline. */
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false, CHECK(trainlog_sync_run(persistent_alias ? TRAINLOG_SYNC_TRIGGER_ANDROID :
TRAINLOG_SYNC_TRIGGER_TUI, false,
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL : persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK); TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
CHECK(report.success); CHECK(report.success);
CHECK(strstr(report.summary,
"SYNC=PASS AI_EXPORT=PASS GDRIVE_UPLOAD=PASS") != NULL);
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U)); CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
CHECK(successful_journal(case_root, report.sync_id)); CHECK(successful_journal(case_root, report.sync_id));
if (persistent_alias) { if (persistent_alias) {
@ -628,7 +762,7 @@ static bool run_missing_profile_companion_case(const char *case_root)
remote_profile_available = false; remote_profile_available = false;
CHECK(prepare_database(case_root, false, false, CHECK(prepare_database(case_root, false, false,
database_path, sizeof(database_path))); database_path, sizeof(database_path)));
CHECK(write_artifacts(CANONICAL_ID, CANONICAL_ID, false)); CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, false));
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false, CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
TRAINLOG_STATUS_DATABASE_ERROR); TRAINLOG_STATUS_DATABASE_ERROR);
@ -642,6 +776,139 @@ static bool run_missing_profile_companion_case(const char *case_root)
return true; return true;
} }
static bool run_incoming_alias_case(const char *case_root)
{
TrainlogSyncReport report;
char database_path[4096];
remote_alias_available = true;
remote_mobile_name = "trainlog-mobile-export-v3.json";
remote_mobile_version = 3U;
CHECK(prepare_database(case_root, false, false,
database_path, sizeof(database_path)));
CHECK(add_live_source_exercise(database_path));
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, true));
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, false,
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
CHECK(report.success);
CHECK(database_state(database_path, true, "back", "arms", 2U));
CHECK(successful_journal(case_root, report.sync_id));
remote_alias_available = false;
remote_mobile_name = "trainlog-mobile-export-v2.json";
remote_mobile_version = 2U;
return true;
}
static bool write_fake_alias_tool(const char *directory, const char *source)
{
char path[4096];
FILE *file;
CHECK(snprintf(path, sizeof(path), "%s/import_exercise_aliases.py",
directory) > 0);
file = fopen(path, "wb");
CHECK(file != NULL);
CHECK(fputs(source, file) >= 0);
CHECK(fclose(file) == 0);
return true;
}
static bool run_alias_diagnostic_case(
const char *case_root,
const char *tools_directory,
const char *script,
const char *expected
)
{
static const char *const result_path =
"/tmp/trainlog-exercise-aliases-result.txt";
TrainlogSyncReport report;
char database_path[4096];
char result[256];
FILE *file;
size_t count;
CHECK(prepare_database(case_root, false, false,
database_path, sizeof(database_path)));
CHECK(write_artifacts(CANONICAL_ID, CANONICAL_ID, false));
if (script != NULL) {
CHECK(write_fake_alias_tool(tools_directory, script));
}
file = fopen(result_path, "wb");
CHECK(file != NULL);
CHECK(fputs("EXERCISE_ALIAS_EXPORT=PASS aliases=99\n", file) >= 0);
CHECK(fclose(file) == 0);
CHECK(setenv("TRAINLOG_TOOLS_DIR", tools_directory, 1) == 0);
remote_alias_available = true;
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(!report.success);
CHECK(strstr(report.error, expected) != NULL);
CHECK(history_contains(case_root, expected));
file = fopen(result_path, "rb");
CHECK(file != NULL);
count = fread(result, 1U, sizeof(result) - 1U, file);
CHECK(ferror(file) == 0);
CHECK(fclose(file) == 0);
result[count] = '\0';
CHECK(strstr(result, "EXERCISE_ALIAS_EXPORT=PASS") == NULL);
CHECK(unsetenv("TRAINLOG_TOOLS_DIR") == 0);
remote_alias_available = false;
return true;
}
static bool run_ai_inbound_diagnostic_case(
const char *case_root,
const char *mode,
const char *expected,
bool repeat
)
{
TrainlogSyncReport report;
char database_path[4096];
char source_path[4096];
FILE *file;
CHECK(prepare_database(case_root, false, false,
database_path, sizeof(database_path)));
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, false));
CHECK(snprintf(source_path, sizeof(source_path), "%s/ai-source.json",
case_root) > 0);
file = fopen(source_path, "wb");
CHECK(file != NULL);
CHECK(fprintf(file,
"{\"format\":\"TRAINLOG_AI_SESSION_DRAFT\",\"version\":1,\"draft\":{"
"\"draft_id\":\"aid_12345678-1234-4abc-8abc-123456789abc\","
"\"created_at\":\"2032-01-01T02:00:00Z\",\"planned_for\":null,"
"\"session_type\":\"training\",\"title\":null,\"notes\":null,"
"\"entries\":[{\"position\":0,\"exercise_id\":\"%s\","
"\"target_sets\":3,\"target_reps\":8,"
"\"target_duration_seconds\":null,\"target_weight_kg\":null,"
"\"rest_seconds\":60}]}}", CANONICAL_ID) > 0);
CHECK(fclose(file) == 0);
CHECK(setenv("TRAINLOG_TEST_AI_SOURCE", source_path, 1) == 0);
CHECK(setenv("TRAINLOG_TEST_AI_INBOUND_MODE", mode, 1) == 0);
{
TrainlogStatus status = trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI,
false, TRAINLOG_SYNC_ANDROID_TO_PC, &report);
if (status != TRAINLOG_STATUS_OK)
(void)fprintf(stderr, "AI diagnostic sync failure: %s\n", report.error);
CHECK(status == TRAINLOG_STATUS_OK);
}
CHECK(report.success);
CHECK(strstr(report.summary, expected) != NULL);
CHECK(history_contains(case_root, expected));
if (repeat) {
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
CHECK(report.success);
CHECK(strstr(report.summary, "AI_INBOUND=ALREADY_IMPORTED") != NULL);
CHECK(history_contains(case_root, "AI_INBOUND=ALREADY_IMPORTED"));
}
CHECK(unsetenv("TRAINLOG_TEST_AI_INBOUND_MODE") == 0);
CHECK(unsetenv("TRAINLOG_TEST_AI_SOURCE") == 0);
return true;
}
static bool run_all(void) static bool run_all(void)
{ {
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX"; char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
@ -650,9 +917,21 @@ static bool run_all(void)
char case_c[4096]; char case_c[4096];
char case_d[4096]; char case_d[4096];
char case_e[4096]; char case_e[4096];
char case_f[4096];
char case_g[4096];
char case_h[4096];
char case_i[4096];
char case_j[4096];
char case_k[4096];
char case_l[4096];
char case_m[4096];
char case_n[4096];
char case_o[4096];
char fake_tools[4096];
char *root = mkdtemp(temporary); char *root = mkdtemp(temporary);
CHECK(root != NULL); CHECK(root != NULL);
CHECK(install_fake_rclone(root));
CHECK(snprintf(remote_root, sizeof(remote_root), "%s/remote", root) > 0); CHECK(snprintf(remote_root, sizeof(remote_root), "%s/remote", root) > 0);
CHECK(mkdir(remote_root, 0700) == 0); CHECK(mkdir(remote_root, 0700) == 0);
CHECK(snprintf(case_a, sizeof(case_a), "%s/case-a", root) > 0); CHECK(snprintf(case_a, sizeof(case_a), "%s/case-a", root) > 0);
@ -660,12 +939,51 @@ static bool run_all(void)
CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", root) > 0); CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", root) > 0);
CHECK(snprintf(case_d, sizeof(case_d), "%s/case-d", root) > 0); CHECK(snprintf(case_d, sizeof(case_d), "%s/case-d", root) > 0);
CHECK(snprintf(case_e, sizeof(case_e), "%s/case-e", root) > 0); CHECK(snprintf(case_e, sizeof(case_e), "%s/case-e", root) > 0);
CHECK(snprintf(case_f, sizeof(case_f), "%s/case-f", root) > 0);
CHECK(snprintf(case_g, sizeof(case_g), "%s/case-g", root) > 0);
CHECK(snprintf(case_h, sizeof(case_h), "%s/case-h", root) > 0);
CHECK(snprintf(case_i, sizeof(case_i), "%s/case-i", root) > 0);
CHECK(snprintf(case_j, sizeof(case_j), "%s/case-j", root) > 0);
CHECK(snprintf(case_k, sizeof(case_k), "%s/case-k", root) > 0);
CHECK(snprintf(case_l, sizeof(case_l), "%s/case-l", root) > 0);
CHECK(snprintf(case_m, sizeof(case_m), "%s/case-m", root) > 0);
CHECK(snprintf(case_n, sizeof(case_n), "%s/case-n", root) > 0);
CHECK(snprintf(case_o, sizeof(case_o), "%s/case-o", root) > 0);
CHECK(snprintf(fake_tools, sizeof(fake_tools), "%s/fake-tools", root) > 0);
CHECK(mkdir(fake_tools, 0700) == 0);
CHECK(run_success_case(case_a, false)); CHECK(run_success_case(case_a, false));
CHECK(run_success_case(case_b, true)); CHECK(run_success_case(case_b, true));
CHECK(rclone_upload_count() == 4U);
CHECK(run_ai_inbound_diagnostic_case(case_j, "invalid",
"AI_INBOUND=REJECTED", false));
CHECK(run_ai_inbound_diagnostic_case(case_k, "archive_fail",
"AI_INBOUND=ARCHIVE_FAIL", false));
CHECK(run_ai_inbound_diagnostic_case(case_l, "none",
"AI_INBOUND=NONE", false));
CHECK(run_ai_inbound_diagnostic_case(case_m, "already",
"AI_INBOUND=IMPORTED", true));
/* Marker-like diagnostic text is not a result line; the final exact
* helper marker, including a later failure, remains authoritative. */
CHECK(run_ai_inbound_diagnostic_case(case_n, "marker_prefix",
"AI_INBOUND=DRIVE_FAIL", false));
CHECK(run_ai_inbound_diagnostic_case(case_o, "marker_suffix",
"AI_INBOUND=DRIVE_FAIL", false));
CHECK(run_unknown_without_proof_case(case_c)); CHECK(run_unknown_without_proof_case(case_c));
CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d)); CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d));
CHECK(run_missing_profile_companion_case(case_e)); CHECK(run_missing_profile_companion_case(case_e));
CHECK(run_incoming_alias_case(case_f));
CHECK(run_alias_diagnostic_case(case_g, fake_tools, NULL,
"outil Python introuvable"));
CHECK(run_alias_diagnostic_case(case_h, fake_tools,
"import argparse\nargparse.ArgumentParser().parse_args()\n",
"unrecognized arguments"));
CHECK(run_alias_diagnostic_case(case_i, fake_tools,
"raise RuntimeError('alias importer synthetic exception')\n",
"RuntimeError: alias importer synthetic exception"));
/* INVARIANT: none of the three failed synchronization paths may start a
* post-sync Drive upload, even when an older export remains on disk. */
CHECK(rclone_upload_count() == 14U);
(void)puts("PASS production sync body-zone V2 proof wiring"); (void)puts("PASS production sync body-zone V2 proof wiring");
return true; return true;
} }

View file

@ -189,7 +189,7 @@ int main(void)
size_t device_count = 0U; size_t device_count = 0U;
size_t storage_count = 0U; size_t storage_count = 0U;
uint32_t download_id = 0U; uint32_t documents_id = 0U;
uint32_t trainlog_id = 0U; uint32_t trainlog_id = 0U;
uint32_t export_id = 0U; uint32_t export_id = 0U;
uint64_t export_size = 0U; uint64_t export_size = 0U;
@ -240,14 +240,14 @@ int main(void)
&devices[0], &devices[0],
storages[0].storage_id, storages[0].storage_id,
UINT32_MAX, UINT32_MAX,
"Download", "Documents",
&download_id &documents_id
); );
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
(void)fprintf( (void)fprintf(
stderr, stderr,
"Download folder not found\n" "Documents folder not found\n"
); );
return 1; return 1;
@ -257,7 +257,7 @@ int main(void)
find_child_folder( find_child_folder(
&devices[0], &devices[0],
storages[0].storage_id, storages[0].storage_id,
download_id, documents_id,
"Trainlog", "Trainlog",
&trainlog_id &trainlog_id
); );
@ -265,7 +265,7 @@ int main(void)
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
(void)fprintf( (void)fprintf(
stderr, stderr,
"Download/Trainlog not found\n" "Documents/Trainlog not found\n"
); );
return 1; return 1;
@ -329,7 +329,7 @@ int main(void)
); );
(void)printf( (void)printf(
"remote=Download/Trainlog/" "remote=Documents/Trainlog/"
"trainlog-mobile-export-v1.json\n" "trainlog-mobile-export-v1.json\n"
); );