From 48d163b0a6e860ed25540290207638423db566b6 Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 14 Sep 2026 19:21:32 +0200 Subject: [PATCH] feat: add AI session draft sync workflow --- .gitignore | 1 + AGENTS.md | 2 +- CHANGELOG.md | 49 +++ README.md | 59 ++- android/app/build.gradle.kts | 2 - android/app/src/main/AndroidManifest.xml | 3 + .../trainlog/data/DirectStoragePermission.kt | 21 + .../trainlog/data/ExchangeSafTree.kt | 252 +++++++----- .../trainlog/data/SyncCatalogInbox.kt | 261 +++++------- .../labfytools/trainlog/data/SyncExporter.kt | 27 +- .../trainlog/data/SyncRequestOutbox.kt | 16 +- .../trainlog/data/TrainlogRepository.kt | 321 ++++++++++++++- .../trainlog/model/SessionModels.kt | 14 + .../labfytools/trainlog/ui/AppNavigation.kt | 2 + .../labfytools/trainlog/ui/SectionScreens.kt | 104 ++++- .../com/labfytools/trainlog/ui/SyncScreen.kt | 108 ++--- .../com/labfytools/trainlog/ui/TrainlogApp.kt | 12 +- .../trainlog/ui/TrainlogComponents.kt | 15 +- .../data/AiSessionDraftRepositoryTest.kt | 283 +++++++++++++ .../data/AndroidV10PlanningMigrationTest.kt | 2 +- .../AndroidV11ExerciseAliasMigrationTest.kt | 2 +- .../AndroidV16AiSessionDraftMigrationTest.kt | 98 +++++ .../labfytools/trainlog/data/BodyZonesTest.kt | 2 +- .../data/DirectExchangeStorageTest.kt | 162 ++++++++ ...lAndroidV12MachineExerciseMigrationTest.kt | 2 +- ...AndroidV14FeedbackRevisionMigrationTest.kt | 2 +- .../data/TrainlogRepositoryDraftTest.kt | 10 +- .../trainlog/ui/AiSessionDraftUiWiringTest.kt | 272 +++++++++++++ .../trainlog/ui/SyncBundlePublicationTest.kt | 155 ++++++-- docs/android.md | 72 ++-- docs/architecture.md | 19 +- docs/current_state.md | 67 +++- docs/database.md | 27 +- docs/exchange_format.md | 60 +++ docs/roadmap.md | 15 + docs/sync_exchange.md | 79 +++- docs/tests.md | 97 ++++- .../trainlog-ai-session-draft-v1.schema.json | 44 +++ .../trainlog-ai-session-drafts-v1.schema.json | 55 +++ tests/test_ai_history_export.py | 158 ++++++++ tests/test_ai_session_draft.py | 371 ++++++++++++++++++ tests/test_post_sync_ai_drive.py | 82 ++++ tools/export_ai_history.py | 253 ++++++++++++ tools/export_ai_session_drafts.py | 150 +++++++ tools/export_equipment_associations.py | 2 +- tools/export_equipment_definitions.py | 2 +- tools/export_exercise_aliases.py | 2 +- tools/export_exercise_body_zones.py | 2 +- tools/export_exercise_profile_state.py | 2 +- tools/export_pc_catalog.py | 2 +- tools/export_pc_mobile.py | 2 +- tools/export_training_feedback.py | 2 +- tools/import_ai_session_draft.py | 256 ++++++++++++ tools/import_equipment_associations.py | 2 +- tools/import_equipment_definitions.py | 2 +- tools/import_exercise_aliases.py | 2 +- tools/import_exercise_body_zones.py | 2 +- tools/import_exercise_profile_state.py | 2 +- tools/import_mobile_export.py | 4 +- tools/import_training_feedback.py | 2 +- tools/mark_ai_session_drafts_published.py | 38 ++ tools/post_sync_ai_drive.py | 93 +++++ tools/sync_ai_session_draft_drive.py | 193 +++++++++ tui/include/trainlog/database.h | 2 +- tui/include/trainlog/sync.h | 11 + tui/meson.build | 19 + tui/src/database.c | 81 +++- tui/src/sync.c | 309 +++++++++++++-- tui/tests/test_body_zones.c | 2 +- tui/tests/test_schema_v16_migration.c | 6 +- tui/tests/test_schema_v7_migration.c | 8 +- tui/tests/test_schema_v9_migration.c | 2 +- tui/tests/test_sync_body_zone_wiring.c | 336 +++++++++++++++- tui/tools/mtp_mobile_export_probe.c | 14 +- 74 files changed, 4646 insertions(+), 564 deletions(-) create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/DirectStoragePermission.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/AiSessionDraftRepositoryTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/DirectExchangeStorageTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt create mode 100644 format/trainlog-ai-session-draft-v1.schema.json create mode 100644 format/trainlog-ai-session-drafts-v1.schema.json create mode 100644 tests/test_ai_history_export.py create mode 100644 tests/test_ai_session_draft.py create mode 100644 tests/test_post_sync_ai_drive.py create mode 100644 tools/export_ai_history.py create mode 100644 tools/export_ai_session_drafts.py create mode 100644 tools/import_ai_session_draft.py create mode 100644 tools/mark_ai_session_drafts_published.py create mode 100644 tools/post_sync_ai_drive.py create mode 100644 tools/sync_ai_session_draft_drive.py diff --git a/.gitignore b/.gitignore index 8ec0f7d..a5fa1f3 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ local.properties /data/ exports/ .local-backups/ +/trainlog_ai_export_v1.json # Python helpers __pycache__/ diff --git a/AGENTS.md b/AGENTS.md index d5c5c88..b89d804 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,7 +192,7 @@ Do not introduce a mandatory GVFS/FUSE mount. Canonical exchange folder: ```text -Download/Trainlog +Documents/Trainlog ``` The shared desktop synchronization engine is: diff --git a/CHANGELOG.md b/CHANGELOG.md index 610338d..b99571f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # 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 companion bundle is published before the request signal, including Training 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, trashed, unrelated, and conflict-numbered MediaStore rows no longer enter 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 diff --git a/README.md b/README.md index c3fad2e..446f5ec 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ desktop. ```text TRAINLOG_FORMAT_V1=FROZEN +TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING DESKTOP_SCHEMA_V11=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 returned PASS with no findings. The initial full-tranche audit's four findings 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 with one known fixture skip, and Java 17 debug assembly. 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 | v - Download/Trainlog on Android storage + Documents/Trainlog on Android storage | | direct MTP / libmtp v @@ -192,6 +193,58 @@ python tools/validate_import_contract.py 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_` +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 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 [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 identities, custom-equipment definition support, explicit MAX results and stable-source Test max resumption, then direct primary/secondary body-zone diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2f93981..78ec77d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -78,8 +78,6 @@ dependencies { "androidx.compose.ui:ui-tooling-preview" ) - implementation("androidx.documentfile:documentfile:1.1.0") - debugImplementation( "androidx.compose.ui:ui-tooling" ) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d95d6be..14905c6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + = 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) { "trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES" @@ -22,141 +43,164 @@ internal fun exchangeArtifactIdentity(displayName: String): String = when (displ else -> displayName } -/* WHY: document providers may throw the same message from different SAF - * 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) { +internal fun logDirectExchange(displayName: String, phase: String, detail: String? = null) { 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) { - Log.e( - EXCHANGE_MEDIASTORE_LOG_TAG, - "${exchangeArtifactIdentity(displayName)} error phase=$phase " + - "exception=${error.javaClass.name}:${error.message}", - ) +internal fun logDirectExchangeError(displayName: String, phase: String, error: Throwable) { + Log.e(DIRECT_EXCHANGE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} error phase=$phase " + + "exception=${error.javaClass.name}:${error.message}") } -internal data class ExchangeSafDocument( - val uri: Uri, - val displayName: String, -) +internal data class DirectExchangeDocument(val file: File, val displayName: String) -internal interface ExchangeSafDirectory { - fun listDirectChildren(): List - fun createJson(displayName: String): ExchangeSafDocument? - fun openForRewrite(document: ExchangeSafDocument): OutputStream? +internal interface DirectExchangeDirectoryAccess { + fun listDirectChildren(): List + fun findExact(displayName: String): List + fun createJson(displayName: String): DirectExchangeDocument? + fun openForRewrite(document: DirectExchangeDocument): OutputStream? } -private class DocumentFileExchangeSafDirectory( - private val context: Context, - private val directory: DocumentFile, -) : ExchangeSafDirectory { - override fun listDirectChildren(): List = - directory.listFiles().mapNotNull { child -> - child.name?.let { ExchangeSafDocument(child.uri, it) } +/* WHY: provider writes allowed Samsung to manufacture numbered conflict + * copies. Direct files make the exact canonical name authoritative. + * INVARIANT: bytes are fully written and fsync'd in this directory before a + * same-directory move replaces the canonical path. */ +private class DirectExchangeDirectory(private val directory: File) : DirectExchangeDirectoryAccess { + override fun listDirectChildren(): List = + directory.listFiles().orEmpty().filter(File::isFile).map { DirectExchangeDocument(it, it.name) } + + override fun findExact(displayName: String): List { + requireCanonicalLeaf(displayName) + val candidate = File(directory, displayName) + return if (candidate.isFile) listOf(DirectExchangeDocument(candidate, displayName)) else emptyList() + } + + override fun createJson(displayName: String): DirectExchangeDocument { + requireCanonicalLeaf(displayName) + return DirectExchangeDocument(File(directory, displayName), displayName) + } + + override fun openForRewrite(document: DirectExchangeDocument): OutputStream { + requireCanonicalLeaf(document.displayName) + require(document.file.parentFile?.canonicalFile == directory.canonicalFile) { + "Chemin d'artefact hors du dossier Trainlog." } - - override fun createJson(displayName: String): ExchangeSafDocument? = - directory.createFile("application/json", displayName)?.let { created -> - val actualName = created.name - require(actualName == displayName) { - "Le fournisseur SAF a créé $actualName au lieu de $displayName." + 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 + } + } + + private inline fun attempt(operation: () -> Unit) { + try { + operation() + } catch (error: Exception) { + failed = true + throw error + } } - ExchangeSafDocument(created.uri, displayName) } + } - override fun openForRewrite(document: ExchangeSafDocument): OutputStream? = - context.contentResolver.openOutputStream(document.uri, "wt") + private fun requireCanonicalLeaf(displayName: String) { + require(displayName.isNotEmpty() && displayName != "." && displayName != "..") + require('/' !in displayName && '\\' !in displayName) { "Nom d'artefact invalide." } + } } -internal fun persistedExchangeSafDirectory(context: Context): ExchangeSafDirectory? { - val appContext = context.applicationContext - val treeUri = appContext.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE) - .getString(SYNC_TREE_URI_KEY, null)?.let(Uri::parse) ?: return null - val directory = DocumentFile.fromTreeUri(appContext, treeUri) ?: return null - if (!directory.isDirectory || !directory.canWrite()) return null - return DocumentFileExchangeSafDirectory(appContext, directory) +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 class ExchangeSafPublisher( - private val directoryProvider: () -> ExchangeSafDirectory?, -) { - fun snapshot(): ExchangeSafSnapshotResult { +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() - ?: return ExchangeSafSnapshotResult.Error( - "Dossier Trainlog non autorisé ou inaccessible.", - ) + ?: return DirectExchangeSnapshotResult.Error("Accès fichiers requis pour Documents/Trainlog.") val started = android.os.SystemClock.elapsedRealtime() 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 byName = children.groupByTo(linkedMapOf()) { it.displayName } - val elapsed = android.os.SystemClock.elapsedRealtime() - started - logExchangeMediaStore( - "SAF_TREE", - "snapshot.success", - "children=${children.size} duration_ms=$elapsed thread=${Thread.currentThread().name}", - ) - ExchangeSafSnapshotResult.Ready(ExchangeSafSnapshot(directory, byName)) + logDirectExchange("DIRECT_STORAGE", "snapshot.success", + "children=${children.size} duration_ms=${android.os.SystemClock.elapsedRealtime() - started} " + + "thread=${Thread.currentThread().name}") + DirectExchangeSnapshotResult.Ready(DirectExchangeSnapshot(directory, byName)) } catch (error: Exception) { - logExchangeMediaStoreError("SAF_TREE", "snapshot", error) - ExchangeSafSnapshotResult.Error(error.message ?: "Lecture du dossier SAF impossible.") + logDirectExchangeError("DIRECT_STORAGE", "snapshot", error) + DirectExchangeSnapshotResult.Error(error.message ?: "Lecture de Documents/Trainlog impossible.") } } } -internal sealed interface ExchangeSafSnapshotResult { - data class Ready(val snapshot: ExchangeSafSnapshot) : ExchangeSafSnapshotResult - data class Error(val message: String) : ExchangeSafSnapshotResult +internal sealed interface DirectExchangeSnapshotResult { + data class Ready(val snapshot: DirectExchangeSnapshot) : DirectExchangeSnapshotResult + data class Error(val message: String) : DirectExchangeSnapshotResult } -internal class ExchangeSafSnapshot( - private val directory: ExchangeSafDirectory, - private val byName: MutableMap>, +internal class DirectExchangeSnapshot( + private val directory: DirectExchangeDirectoryAccess, + private val byName: MutableMap>, ) { fun writeJson(displayName: String, json: String): String? { - var phase = "saf.resolve" + var phase = "direct.resolve" return try { - logExchangeMediaStore(displayName, "saf.resolve.begin") val exact = byName[displayName].orEmpty() - require(exact.size <= 1) { - "Plusieurs documents SAF canoniques exacts pour $displayName (${exact.size})." - } - val existing = exact.singleOrNull() - val document = if (existing != null) { - logExchangeMediaStore(displayName, "saf.resolve.existing", "uri=${existing.uri}") - existing - } else { - phase = "saf.create" - 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}") + require(exact.size <= 1) { "DIRECT_STORAGE_DUPLICATE_CANONICAL: nom=$displayName count=${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}" + } + byName.getOrPut(displayName) { mutableListOf() }.add(it) + } ?: return "Création de $displayName impossible." + phase = "direct.atomicWrite" + val stream = directory.openForRewrite(document) ?: return "Écriture de $displayName impossible." + stream.use { it.write(json.toByteArray(Charsets.UTF_8)) } + logDirectExchange(displayName, "direct.write.success") null } catch (error: Exception) { - logExchangeMediaStoreError(displayName, phase, error) - error.message ?: "Publication SAF de $displayName impossible." + logDirectExchangeError(displayName, phase, error) + error.message ?: "Publication de $displayName impossible." } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt index a2f20c2..85f8949 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt @@ -1,9 +1,7 @@ package com.labfytools.trainlog.data import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.documentfile.provider.DocumentFile +import java.io.File import org.json.JSONObject sealed interface CatalogInboxResult { @@ -50,96 +48,34 @@ class SyncCatalogInbox( private val appContext = context.applicationContext - private val preferences = - appContext.getSharedPreferences( - SYNC_PREFERENCES_NAME, - Context.MODE_PRIVATE, - ) - fun hasFolderAccess(): Boolean = - savedTreeUri() != 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 - } - } + directExchangeDirectory() != null fun importPcCatalog(): CatalogInboxResult { - val treeUri = - savedTreeUri() - ?: return CatalogInboxResult - .FolderNotAuthorized + val directory = directExchangeDirectory() + ?: return CatalogInboxResult.FolderNotAuthorized - val directory = - DocumentFile - .fromTreeUri( - appContext, - treeUri, - ) - ?: return CatalogInboxResult.Error( - "Dossier Trainlog inaccessible." - ) + return importPcCatalogFromDirectory(directory) + } + + /** Test seam for the production companion ordering on a fresh database. */ + internal fun importPcCatalogFromDirectoryForTest(directory: File): CatalogInboxResult = + importPcCatalogFromDirectory(directory) + + private fun importPcCatalogFromDirectory(directory: File): CatalogInboxResult { val file = - directory.findFile( - "trainlog-pc-catalog-v1.json" - ) + directory.findDirectFile("trainlog-pc-catalog-v1.json") ?: return CatalogInboxResult.FileNotFound return try { - val stream = - appContext - .contentResolver - .openInputStream( - file.uri - ) - ?: return CatalogInboxResult.Error( - "Lecture du catalogue impossible." - ) - - val json = - stream.bufferedReader( - Charsets.UTF_8 - ) - .use { - it.readText() - } + val json = file.readText(Charsets.UTF_8) val definitionsError = importPcEquipmentDefinitions(directory) if (definitionsError != null) return CatalogInboxResult.Error(definitionsError) - val aliasesError = importExerciseAliases(directory) - if (aliasesError != null) return CatalogInboxResult.Error(aliasesError) - 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.") + val profileFile = directory.findDirectFile("trainlog-exercise-profile-state-v1.json") + val profileJson = profileFile?.readText(Charsets.UTF_8) if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson,allowPending=true)) { is ExerciseProfileStateImportResult.Applied -> Unit is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message) @@ -155,6 +91,12 @@ class SyncCatalogInbox( ) ) { 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)) { is ExerciseProfileStateImportResult.Applied -> Unit is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message) @@ -163,23 +105,16 @@ class SyncCatalogInbox( } /* Catalogue identities now exist and the strict second pass * has either installed or verified their causal state. */ - when (val sessions = importPcSessions(directory)) { - null -> when (val equipment = importPcEquipmentAssociations(directory)) { - null -> when (val bodyZones = importPcBodyZones(directory)) { - null -> when (val feedback = importTrainingFeedback(directory)) { - null -> CatalogInboxResult.Imported( - imported = result.imported, - reconciled = result.reconciled, - skipped = result.skipped, - ) - else -> CatalogInboxResult.Error(feedback) - } - else -> CatalogInboxResult.Error(bodyZones) - } - else -> CatalogInboxResult.Error(equipment) - } - else -> CatalogInboxResult.Error(sessions) - } + importPcSessions(directory)?.let { return CatalogInboxResult.Error(it) } + importPcEquipmentAssociations(directory)?.let { return CatalogInboxResult.Error(it) } + importAiSessionDrafts(directory)?.let { return CatalogInboxResult.Error(it) } + importPcBodyZones(directory)?.let { return CatalogInboxResult.Error(it) } + importTrainingFeedback(directory)?.let { return CatalogInboxResult.Error(it) } + CatalogInboxResult.Imported( + imported = result.imported, + reconciled = result.reconciled, + skipped = result.skipped, + ) } 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 * surface its error and never fall back to a stale V2 snapshot. */ - val v3 = directory.findFile("trainlog-pc-mobile-export-v3.json") - val file = v3 ?: directory.findFile("trainlog-pc-mobile-export-v2.json") ?: return null + val v3 = directory.findDirectFile("trainlog-pc-mobile-export-v3.json") + val file = v3 ?: directory.findDirectFile("trainlog-pc-mobile-export-v2.json") ?: return null return try { - val json = appContext.contentResolver.openInputStream(file.uri) - ?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } - ?: return "Lecture snapshot séances impossible." + val json = file.readText(Charsets.UTF_8) val result = if (v3 != null) repository.applyPcMobileExportV3Json(json) else repository.applyPcMobileExportV2Json(json) when (result) { @@ -221,14 +154,13 @@ class SyncCatalogInbox( } 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 * an older peer and can only add deterministic initial revisions. */ - val file = directory.findFile("trainlog-training-feedback-v2.json") - ?: directory.findFile("trainlog-training-feedback-v1.json") ?: return null + val file = directory.findDirectFile("trainlog-training-feedback-v2.json") + ?: directory.findDirectFile("trainlog-training-feedback-v1.json") ?: return null return try { - val json = appContext.contentResolver.openInputStream(file.uri)?.bufferedReader(Charsets.UTF_8) - ?.use { it.readText() } ?: return "Lecture des ressentis impossible." + val json = file.readText(Charsets.UTF_8) when (val result = repository.applyTrainingFeedbackJson(json)) { is TrainingFeedbackImportResult.Applied -> null is TrainingFeedbackImportResult.Invalid -> result.message @@ -239,15 +171,31 @@ class SyncCatalogInbox( } /** 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) - private fun importPcBodyZones(directory: DocumentFile): String? { - val file = directory.findFile("trainlog-exercise-body-zones-v1.json") ?: return null + private fun importAiSessionDrafts(directory: File): String? { + val file = directory.findDirectFile("trainlog-ai-session-drafts-v1.json") ?: return null return try { - val json = appContext.contentResolver.openInputStream(file.uri) - ?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } - ?: return "Lecture zones corporelles impossible." + val json = file.readText(Charsets.UTF_8) + when (val result = repository.applyAiSessionDraftsJson(json)) { + 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)) { is ExerciseBodyZoneImportResult.Applied -> null is ExerciseBodyZoneImportResult.Invalid -> result.message @@ -261,12 +209,10 @@ class SyncCatalogInbox( } } - private fun importExerciseAliases(directory: DocumentFile): String? { - val file = directory.findFile("trainlog-exercise-aliases-v1.json") ?: return null + private fun importExerciseAliases(directory: File): String? { + val file = directory.findDirectFile("trainlog-exercise-aliases-v1.json") ?: return null return try { - val json = appContext.contentResolver.openInputStream(file.uri) - ?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } - ?: return "Lecture des alias exercice impossible." + val json = file.readText(Charsets.UTF_8) when (val result = repository.applyExerciseAliasesJson(json)) { is ExerciseAliasImportResult.Applied -> null is ExerciseAliasImportResult.Invalid -> result.message @@ -280,12 +226,10 @@ class SyncCatalogInbox( } } - private fun importPcEquipmentDefinitions(directory: DocumentFile): String? { - val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null + private fun importPcEquipmentDefinitions(directory: File): String? { + val file = directory.findDirectFile("trainlog-pc-equipment-definitions-v1.json") ?: return null return try { - val json = appContext.contentResolver.openInputStream(file.uri) - ?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } - ?: return "Lecture définitions équipement impossible." + val json = file.readText(Charsets.UTF_8) when (val result = repository.applyPcEquipmentDefinitionsJson(json)) { is EquipmentDefinitionImportResult.Applied -> null is EquipmentDefinitionImportResult.Invalid -> result.message @@ -294,14 +238,12 @@ class SyncCatalogInbox( } catch (error: Exception) { error.message ?: "Import définitions équipement impossible." } } - private fun importPcEquipmentAssociations(directory: DocumentFile): String? { - val file = directory.findFile("trainlog-equipment-associations-v2.json") - ?: directory.findFile("trainlog-equipment-associations-v1.json") + private fun importPcEquipmentAssociations(directory: File): String? { + val file = directory.findDirectFile("trainlog-equipment-associations-v2.json") + ?: directory.findDirectFile("trainlog-equipment-associations-v1.json") ?: return null /* Historic PC sync: absence means no information. */ return try { - val json = appContext.contentResolver.openInputStream(file.uri) - ?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } - ?: return "Lecture extension équipement impossible." + val json = file.readText(Charsets.UTF_8) when (val result = repository.applyPcEquipmentAssociationsJson(json)) { is EquipmentAssociationImportResult.Applied -> null is EquipmentAssociationImportResult.Invalid -> result.message @@ -315,45 +257,15 @@ class SyncCatalogInbox( fun readSyncReceipt( requestId: String, ): SyncReceiptResult { - val treeUri = - savedTreeUri() - ?: return SyncReceiptResult - .FolderNotAuthorized - - val directory = - DocumentFile - .fromTreeUri( - appContext, - treeUri, - ) - ?: return SyncReceiptResult.Error( - "Dossier Trainlog inaccessible." - ) + val directory = directExchangeDirectory() + ?: return SyncReceiptResult.FolderNotAuthorized val file = - directory.findFile( - "trainlog-sync-receipt-v1.json" - ) + directory.findDirectFile("trainlog-sync-receipt-v1.json") ?: return SyncReceiptResult.Pending return try { - val stream = - appContext - .contentResolver - .openInputStream( - file.uri - ) - ?: return SyncReceiptResult.Error( - "Lecture du reçu impossible." - ) - - val json = - stream.bufferedReader( - Charsets.UTF_8 - ) - .use { - it.readText() - } + val json = file.readText(Charsets.UTF_8) val root = JSONObject(json) @@ -418,14 +330,17 @@ fun readSyncReceipt( } } - private fun savedTreeUri(): Uri? = - preferences - .getString( - SYNC_TREE_URI_KEY, - null, - ) - ?.let { - Uri.parse(it) - } + private fun directExchangeDirectory(): File? { + clearLegacyExchangeTreePreference(appContext) + if (!hasDirectExchangePermission()) return null + val directory = canonicalExchangeDirectory() + if (!directory.exists() && !directory.mkdirs()) return null + return directory.takeIf { it.isDirectory && it.canRead() && it.canWrite() } + } + + private fun File.findDirectFile(name: String): File? { + require('/' !in name && '\\' !in name) + return File(this, name).takeIf(File::isFile) + } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt index 695ad5a..f8af07d 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt @@ -22,31 +22,31 @@ sealed interface SyncExportResult { class SyncExporter private constructor( private val repository: TrainlogRepository, - private val safPublisher: ExchangeSafPublisher, + private val publisher: DirectExchangePublisher, ) { constructor(context: Context, repository: TrainlogRepository) : this( repository, - ExchangeSafPublisher { persistedExchangeSafDirectory(context) }, + DirectExchangePublisher { directExchangeDirectory(context) }, ) internal constructor( context: Context, repository: TrainlogRepository, - directoryProvider: () -> ExchangeSafDirectory?, - ) : this(repository, ExchangeSafPublisher(directoryProvider)) + directoryProvider: () -> DirectExchangeDirectoryAccess?, + ) : 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. */ suspend fun exportMobileBundle(): SyncExportResult = withContext(Dispatchers.IO) { - when (val opened = openSafSnapshot()) { - is ExchangeSafSnapshotResult.Ready -> exportMobileBundle(opened.snapshot) - is ExchangeSafSnapshotResult.Error -> SyncExportResult.Error(opened.message) + when (val opened = openStorageSnapshot()) { + is DirectExchangeSnapshotResult.Ready -> exportMobileBundle(opened.snapshot) + 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 ( Build.VERSION.SDK_INT < Build.VERSION_CODES.Q @@ -89,9 +89,8 @@ class SyncExporter private constructor( "trainlog-training-feedback-v2.json" to feedbackJson, "trainlog-exercise-profile-state-v1.json" to profileStateJson, ) - /* CONTRACT: the persisted user-authorized SAF tree is the sole - * outbound directory authority. MediaStore ownership must neither hide - * a canonical document nor cause creation of a numbered conflict. */ + /* CONTRACT: the fixed direct-storage directory is the sole outbound + * authority. Same-directory atomic replacement preserves exact names. */ artifacts.forEach { (displayName, json) -> snapshot.writeJson(displayName, json)?.let { error -> return SyncExportResult.Error(error) @@ -120,7 +119,7 @@ class SyncExporter private constructor( associationsJson, )?.let { return SyncExportResult.Error(it) } 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, ) } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt index 0dddf71..5b7acce 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt @@ -22,24 +22,24 @@ sealed interface SyncRequestResult { } class SyncRequestOutbox private constructor( - private val safPublisher: ExchangeSafPublisher, + private val publisher: DirectExchangePublisher, ) { constructor(context: Context) : this( - ExchangeSafPublisher { persistedExchangeSafDirectory(context) }, + DirectExchangePublisher { directExchangeDirectory(context) }, ) - internal constructor(directoryProvider: () -> ExchangeSafDirectory?) : this( - ExchangeSafPublisher(directoryProvider), + internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this( + DirectExchangePublisher(directoryProvider), ) suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) { - when (val opened = safPublisher.snapshot()) { - is ExchangeSafSnapshotResult.Ready -> requestSync(opened.snapshot) - is ExchangeSafSnapshotResult.Error -> SyncRequestResult.Error(opened.message) + when (val opened = publisher.snapshot()) { + is DirectExchangeSnapshotResult.Ready -> requestSync(opened.snapshot) + is DirectExchangeSnapshotResult.Error -> SyncRequestResult.Error(opened.message) } } - internal fun requestSync(snapshot: ExchangeSafSnapshot): SyncRequestResult { + internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult { if ( Build.VERSION.SDK_INT < Build.VERSION_CODES.Q diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 180dcc2..e85e799 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -9,6 +9,7 @@ import android.database.sqlite.SQLiteOpenHelper import android.util.JsonReader import android.util.JsonToken import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.AiSessionDraft import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.BodyObservationSummary import com.labfytools.trainlog.model.ExerciseDataFields @@ -275,6 +276,19 @@ sealed interface 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 { data class Saved( 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(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} - 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(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(*incomingAdj.toTypedArray(), row[0]!!.toLong()), + ) 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 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 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 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") @@ -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() + val seenEntries = mutableSetOf() + 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 { + val db = database.readableDatabase + val output = mutableListOf() + 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 { + val output = mutableListOf() + 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(): ActiveDraftLoadResult = try { @@ -6604,6 +6873,16 @@ private const val DESKTOP_LEG_PRESS_CANONICAL_ID = 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}$", ) +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 val appContext: Context, @@ -6612,7 +6891,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 16, + 17, ) { override fun onConfigure( db: SQLiteDatabase, @@ -6653,6 +6932,7 @@ private class TrainlogDatabaseHelper( createTrainingFeedbackTables(db) createTrainingFeedbackRevisionTables(db) createExerciseProfileStateTable(db, seedLegacy = false) + createAiSessionDraftTables(db) seedEquipment(db) } @@ -6780,6 +7060,14 @@ private class TrainlogDatabaseHelper( 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) { error( "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) { /* CONTRACT: canonical zone definitions remain in the asset; SQLite * stores only direct exercise relations and role cardinality. */ diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt index 2d68d3e..6dd9a31 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt @@ -104,6 +104,20 @@ data class ActiveSessionDraft( 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, +) + data class SessionSummary( val sessionId: String, val startedAt: String, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt index 6281a64..4d75c59 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt @@ -16,6 +16,7 @@ sealed interface AppRoute { data object Sessions : 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 AiSessionDrafts : 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 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.SessionEditor -> "Séance en cours" AppRoute.SessionGenerator -> "Programmer une séance" + AppRoute.AiSessionDrafts -> "Brouillons" AppRoute.CompletedSessions -> "Séances effectuées" is AppRoute.SessionDetail -> "Détail de la séance" is AppRoute.SessionCorrection -> "Modifier la séance" diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt index 8baf8d9..65f8d28 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt @@ -7,21 +7,27 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import com.labfytools.trainlog.data.CreateEquipmentResult import com.labfytools.trainlog.data.CatalogInboxResult +import com.labfytools.trainlog.data.ActiveDraftMutationResult import com.labfytools.trainlog.data.EquipmentCatalogEntry import com.labfytools.trainlog.data.EquipmentLoadSemantics import com.labfytools.trainlog.data.KnowledgeConfidence import com.labfytools.trainlog.data.SyncCatalogInbox +import com.labfytools.trainlog.data.StartAiSessionDraftResult import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.data.directStoragePermissionIntent import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.ui.theme.LocalTrainlogColors @Composable fun SessionsHub( draft: ActiveSessionDraft?, + pendingAiDraftCount: Int, onResume: () -> Unit, onManual: () -> Unit, + onDrafts: () -> Unit, onHistory: () -> Unit, ) { val colors = LocalTrainlogColors.current @@ -32,11 +38,94 @@ fun SessionsHub( } 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( + "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) } } +@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(null) } + var pendingDeletion by remember { mutableStateOf?>(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 fun LatestMaximaScreen(repository: TrainlogRepository) { val colors = LocalTrainlogColors.current @@ -155,13 +244,13 @@ private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics) = when (value @Composable fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) { val colors = LocalTrainlogColors.current + val context = LocalContext.current var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) } var message by remember { mutableStateOf(null) } - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> - if (uri != null) { - authorized = inbox.saveTreeUri(uri) - message = if (authorized) "Dossier d'échange autorisé." else "Autorisation du dossier impossible." - if (authorized) { + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + authorized = inbox.hasFolderAccess() + message = if (authorized) "Accès fichiers autorisé." else "Accès fichiers toujours requis." + if (authorized) { when (val result = inbox.importPcCatalog()) { is CatalogInboxResult.Imported -> { message = "Dossier autorisé · ${result.imported} exercice(s) importé(s), ${result.reconciled} réconcilié(s)." @@ -171,13 +260,12 @@ fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) { CatalogInboxResult.FolderNotAuthorized -> message = "Le dossier n'est plus autorisé." is CatalogInboxResult.Error -> message = result.message } - } } } TrainlogScreen("Paramètres") { TrainlogFrame("DOSSIER D'ÉCHANGE") { - TrainlogInfo(if (authorized) "Téléchargements/Trainlog autorisé." else "Aucun dossier Trainlog autorisé.", 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) }) + 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) "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é.", { when (val result = inbox.importPcCatalog()) { is CatalogInboxResult.Imported -> { message = "Catalogue relu · ${result.imported} nouveau(x), ${result.reconciled} réconcilié(s)."; onCatalogChanged() } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt index 702af72..fd13a5a 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt @@ -4,6 +4,7 @@ package com.labfytools.trainlog.ui import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.ui.platform.LocalContext import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.SyncRequestOutbox 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 kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers @@ -31,19 +33,19 @@ internal suspend fun publishBundleAndRequest( exporter: SyncExporter, requestOutbox: SyncRequestOutbox, ): SyncRequestResult = withContext(Dispatchers.IO) { - logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.begin") - when (val opened = exporter.openSafSnapshot()) { - is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Error -> + logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin") + when (val opened = exporter.openStorageSnapshot()) { + is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error -> 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)) { SyncExportResult.Unsupported -> SyncRequestResult.Unsupported is SyncExportResult.Error -> SyncRequestResult.Error( "Préparation Android → PC : ${publication.message}", ) is SyncExportResult.Exported -> { - logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.success") - logExchangeMediaStore("trainlog-sync-request-v1.json", "coordinator.request.begin") + logDirectExchange("SYNC_BUNDLE", "coordinator.export.success") + logDirectExchange("trainlog-sync-request-v1.json", "coordinator.request.begin") requestOutbox.requestSync(opened.snapshot) } } @@ -61,6 +63,7 @@ fun SyncScreen( val colors = LocalTrainlogColors.current val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current var status by remember { @@ -208,63 +211,32 @@ fun SyncScreen( null } - val folderLauncher = + val permissionLauncher = rememberLauncherForActivityResult( - contract = - ActivityResultContracts - .OpenDocumentTree(), + contract = ActivityResultContracts.StartActivityForResult(), ) { - uri -> - if (uri != null) { - val saved = - inbox.saveTreeUri( - uri - ) - - folderAuthorized = - saved - - success = - saved - - status = - if (saved) { - "Dossier Trainlog autorisé." - } else { - "Autorisation du dossier impossible." - } - - if (saved) { - coroutineScope.launch { - when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) { - is CatalogInboxResult.Imported -> { - success = true - - status = - ( - "Dossier autorisé · catalogue PC : " + - "${result.imported} nouveau(x), " + - "${result.reconciled} réconcilié(s)." - ) - - onCatalogChanged() - } - - CatalogInboxResult.FileNotFound -> { - success = true - - status = - "Dossier autorisé · aucun catalogue PC reçu." - } - - CatalogInboxResult.FolderNotAuthorized, - is CatalogInboxResult.Error -> { - /* Keep permission state. */ - } - } + folderAuthorized = inbox.hasFolderAccess() + success = folderAuthorized + status = if (folderAuthorized) { + "Accès fichiers autorisé · Documents/Trainlog prêt." + } else { + "Accès fichiers requis. Activez l'autorisation dans Android." + } + if (folderAuthorized) { + coroutineScope.launch { + when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) { + is CatalogInboxResult.Imported -> { + status = "Accès autorisé · catalogue PC : ${result.imported} nouveau(x), " + + "${result.reconciled} réconcilié(s)." + onCatalogChanged() } + CatalogInboxResult.FileNotFound -> + status = "Accès autorisé · aucun catalogue PC reçu." + CatalogInboxResult.FolderNotAuthorized -> folderAuthorized = false + is CatalogInboxResult.Error -> status = result.message } } + } } TrainlogScreen( @@ -305,7 +277,7 @@ fun SyncScreen( success = false status = - "Autorisez d'abord Téléchargements/Trainlog." + "Accès fichiers requis. Autorisez d'abord Documents/Trainlog." return@TrainlogAction } @@ -347,7 +319,7 @@ fun SyncScreen( ) { if (folderAuthorized) { TrainlogInfo( - "Téléchargements/Trainlog autorisé.", + "Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé", color = colors.success, ) @@ -355,17 +327,11 @@ fun SyncScreen( TrainlogAction( label = - if (folderAuthorized) { - "Changer le dossier Trainlog" - } else { - "Autoriser le dossier Trainlog" - }, + if (folderAuthorized) "Ouvrir les réglages d'accès" else "Autoriser l'accès au dossier Trainlog", description = - "Choisir Téléchargements/Trainlog.", + "Autoriser l'accès pour gérer tous les fichiers dans Android.", onClick = { - folderLauncher.launch( - null - ) + permissionLauncher.launch(directStoragePermissionIntent(context)) }, accent = colors.warning, @@ -397,7 +363,7 @@ fun SyncScreen( success = false status = - "Autorisez d'abord Téléchargements/Trainlog." + "Accès fichiers requis pour Documents/Trainlog." } CatalogInboxResult.FileNotFound -> { diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 1d57f45..42f7965 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -53,6 +53,9 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() } val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft + val pendingAiDraftCount = remember(draftRevision, catalogRevision) { + repository.listAiSessionDrafts().size + } fun open(route: AppRoute) { navigationController.open(route) } fun openSection(section: AppSection) { navigationController.openSection(section) } @@ -94,7 +97,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S { open(AppRoute.Statistics) }, { 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.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." @@ -105,6 +109,12 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S * Back and drawer navigation. */ 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)) } 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 -> diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt index 02bc82d..e126f61 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon @@ -377,14 +378,24 @@ fun DestructiveConfirmationDialog( detail: String, confirmLabel: String, onCancel: () -> Unit, + dismissOnClickOutside: Boolean = false, onConfirm: () -> Unit, ) { + val colors = LocalTrainlogColors.current AlertDialog( onDismissRequest = onCancel, title = { Text(title) }, text = { Text(detail) }, dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } }, - confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } }, - properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false), + confirmButton = { + TextButton( + onClick = onConfirm, + colors = ButtonDefaults.textButtonColors(contentColor = colors.error), + ) { Text(confirmLabel) } + }, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = dismissOnClickOutside, + ), ) } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AiSessionDraftRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AiSessionDraftRepositoryTest.kt new file mode 100644 index 0000000..f264738 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AiSessionDraftRepositoryTest.kt @@ -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" + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index a93d544..1817d4e 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -61,7 +61,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } 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")) { 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")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index e35721d..79e6879 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -64,7 +64,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } 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 * 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") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt new file mode 100644 index 0000000..83c5192 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index bdf07c6..8507af8 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -323,7 +323,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(16, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(17, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/DirectExchangeStorageTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/DirectExchangeStorageTest.kt new file mode 100644 index 0000000..05770a7 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/DirectExchangeStorageTest.kt @@ -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) + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt index 2e8c6b6..decf3be 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt @@ -125,7 +125,7 @@ class RealAndroidV12MachineExerciseMigrationTest { private fun assertValidVersionThirteen(databasePath: File) { 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;")) database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt index 625237d..1f5349c 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt @@ -33,7 +33,7 @@ class RealAndroidV14FeedbackRevisionMigrationTest { } TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() } 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_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")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 53ff88b..2aa8f4d 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -3,7 +3,6 @@ package com.labfytools.trainlog.data import android.content.Context import android.database.sqlite.SQLiteDatabase import androidx.test.core.app.ApplicationProvider -import androidx.documentfile.provider.DocumentFile import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.ExerciseDataFields @@ -1669,7 +1668,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(16, cursor.getInt(0)) + assertEquals(17, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -1796,7 +1795,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> 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 -> assertTrue(cursor.moveToFirst()) @@ -2287,12 +2286,11 @@ class TrainlogRepositoryDraftTest { v3File.writeText(malformedV3.toString()) File(directory, "trainlog-pc-mobile-export-v2.json").writeText(validV2.toString()) val inbox = SyncCatalogInbox(context, destination) - val documentDirectory = DocumentFile.fromFile(directory) - val error = inbox.importPcSessionsFromDirectoryForTest(documentDirectory) + val error = inbox.importPcSessionsFromDirectoryForTest(directory) assertTrue(error!!.contains("started_at")) assertTrue(destination.listSessions().isEmpty()) assertTrue(v3File.delete()) - assertEquals(null, inbox.importPcSessionsFromDirectoryForTest(documentDirectory)) + assertEquals(null, inbox.importPcSessionsFromDirectoryForTest(directory)) assertEquals(1, destination.listSessions().size) } finally { directory.deleteRecursively() diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt new file mode 100644 index 0000000..743be35 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt @@ -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() + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt index 9097999..afdbfa6 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt @@ -1,7 +1,6 @@ package com.labfytools.trainlog.ui import android.content.Context -import android.net.Uri import androidx.test.core.app.ApplicationProvider import com.labfytools.trainlog.data.SaveFeedbackResult 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.SyncRequestResult import com.labfytools.trainlog.data.TrainlogRepository -import com.labfytools.trainlog.data.ExchangeSafDirectory -import com.labfytools.trainlog.data.ExchangeSafDocument -import com.labfytools.trainlog.data.EXCHANGE_MEDIASTORE_LOG_TAG -import com.labfytools.trainlog.data.ExchangeSafPublisher -import com.labfytools.trainlog.data.ExchangeSafSnapshotResult +import com.labfytools.trainlog.data.DirectExchangeDirectoryAccess +import com.labfytools.trainlog.data.DirectExchangeDocument +import com.labfytools.trainlog.data.DIRECT_EXCHANGE_LOG_TAG +import com.labfytools.trainlog.data.DirectExchangePublisher +import com.labfytools.trainlog.data.DirectExchangeSnapshotResult import com.labfytools.trainlog.model.SessionDraft import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionSetDraft import java.util.UUID import java.io.ByteArrayOutputStream import java.io.OutputStream +import java.io.File import org.json.JSONObject import org.json.JSONArray 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 @@ -134,27 +135,20 @@ class SyncBundlePublicationTest { val writesBeforeSecondClick = directory.writeCounts.toMap() assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested) 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") }) - val snapshotPhases = phases.filter { it.startsWith("SAF_TREE snapshot.success ") } + val snapshotPhases = phases.filter { it.startsWith("DIRECT_STORAGE snapshot.success ") } assertEquals(1, snapshotPhases.size) assertTrue("SAF snapshot ran on main: $snapshotPhases", 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) assertTrue("SAF write ran on main: $writeBegins", writeBegins.none { it.contains("thread=main") }) - val bodyResolve = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.resolve.existing ") } - val bodyWrite = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.begin ") } - val bodyWritten = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.success ") } + val bodyWritten = phases.indexOfFirst { it.startsWith("BODY_ZONES direct.write.success") } val requestStart = phases.indexOfFirst { it == "SYNC_REQUEST coordinator.request.begin" } - assertTrue("production coordinator did not resolve existing BODY ZONES", bodyResolve >= 0) - assertTrue("BODY ZONES must open only after exact resolution", bodyWrite > bodyResolve) - assertTrue("BODY ZONES write did not complete", bodyWritten > bodyWrite) + assertTrue("BODY ZONES write did not complete", bodyWritten >= 0) 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", phases.none { it.contains(" insert.") || it.contains(" publish.") || it.contains("IS_PENDING") }) 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`() { - val publisher = ExchangeSafPublisher { directory } - val opened = publisher.snapshot() as ExchangeSafSnapshotResult.Ready + val publisher = DirectExchangePublisher { directory } + val opened = publisher.snapshot() as DirectExchangeSnapshotResult.Ready assertEquals(1, directory.enumerationCount) assertEquals(null, opened.snapshot.writeJson("foo.json", "first")) assertEquals(null, opened.snapshot.writeJson("foo.json", "second")) @@ -185,34 +179,135 @@ class SyncBundlePublicationTest { 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 class FakeSafDirectory : ExchangeSafDirectory { + private class FakeSafDirectory : DirectExchangeDirectoryAccess { private val contents = linkedMapOf() private val creates = mutableMapOf() val writeCounts = mutableMapOf() var enumerationCount = 0 private set + var createdNameOverride: String? = null + var rawExactDuplicates = 1 + private val discoveryHidden = mutableSetOf() 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 canonicalCount(name: String): Int = if (contents.containsKey(name)) 1 else 0 fun createCount(name: String): Int = creates[name] ?: 0 fun writeCount(name: String): Int = writeCounts[name] ?: 0 + fun names(): Set = contents.keys - override fun listDirectChildren(): List { + override fun listDirectChildren(): List { enumerationCount++ - return contents.keys.map(::document) + return contents.keys.filterNot(discoveryHidden::contains).map(::document) } - override fun createJson(displayName: String): ExchangeSafDocument { - check(!contents.containsKey(displayName)) + override fun findExact(displayName: String): List = + 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 - contents[displayName] = byteArrayOf() - return document(displayName) + contents[actualName] = byteArrayOf() + return document(actualName) } - override fun openForRewrite(document: ExchangeSafDocument): OutputStream = + override fun openForRewrite(document: DirectExchangeDocument): OutputStream = object : ByteArrayOutputStream() { override fun close() { super.close() @@ -221,8 +316,8 @@ class SyncBundlePublicationTest { } } - private fun document(name: String) = ExchangeSafDocument( - Uri.parse("content://trainlog-test/tree/Download%2FTrainlog/document/$name"), + private fun document(name: String) = DirectExchangeDocument( + File("/storage/emulated/0/Documents/Trainlog", name), name, ) } diff --git a/docs/android.md b/docs/android.md index b041224..4fef8ed 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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 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_` 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 before it exposes `trainlog-sync-request-v1.json`. The bundle contains mobile V3, mobile equipment definitions, equipment associations V2, exercise aliases V1, BODY ZONES V1, Training Feedback V2, and Exercise Profile State V1. The -request is not written if any required publication fails. The persisted, -user-authorized `Download/Trainlog` SAF tree is the canonical authority for -both inbound and outbound exchange: MediaStore ownership and app-scoped row -visibility are not consulted. Exact display names are rewritten through their -SAF document URI; absence creates the exact name once, and multiple exact names -fail conservatively. Conflict copies such as `name (1).json` are historical -objects, never aliases of `name.json`, and are neither selected nor deleted. -One synchronization enumerates the direct SAF children once, builds a bounded -exact-name multimap, and shares that transaction-local snapshot across all -outbound artifacts and the request. Newly created documents update that map. -The next synchronization enumerates again so PC/MTP changes are never hidden by -a long-lived cache. Snapshot, create and stream operations run on the I/O -dispatcher; Compose only launches the suspend coordinator and applies results. +request is not written if any required publication fails. After the user +enables `MANAGE_EXTERNAL_STORAGE` in Android settings, the fixed direct path +`/storage/emulated/0/Documents/Trainlog` is the sole authority for inbound and +outbound exchange. Trainlog creates it if absent and requires a readable, +writable directory. No tree URI, persisted URI permission, directory picker, +`DocumentFile`, `DocumentsContract`, or MediaStore row participates. + +Each JSON payload is written to a uniquely named temporary file in that same +directory, flushed and fsync'd, then moved over the exact canonical filename +with atomic replacement where the filesystem supports it. This prevents SAF +provider suffixes. Existing `name (N).json` files are unrelated historical +objects and are never selected, overwritten, moved, or deleted. The next sync +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, equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are @@ -475,7 +503,7 @@ bo_ Android maintains: ```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, @@ -511,16 +539,17 @@ exact transferred completed rows are exported after finalization. ## 10. PC catalog access -PC-created files are accessed through a persistent Storage Access Framework -grant. +PC-created files are accessed directly after the user enables Android's +all-files access for Trainlog. The selected folder must be: ```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. @@ -538,10 +567,9 @@ Android writes: trainlog-sync-request-v1.json ``` -The request is resolved and rewritten through the same persisted SAF tree as -the bundle. Android does not create a numbered request sibling merely because -MediaStore cannot expose an MTP- or peer-created canonical object. The stable -`request_id` remains the replay boundary. +The request is replaced through the same direct `Documents/Trainlog` directory +as the bundle. No document provider can manufacture a numbered request sibling. +The stable `request_id` remains the replay boundary. The application-start catalog import does not publish an outbound bundle. Exactly one explicit `Synchroniser maintenant` action invokes one export and diff --git a/docs/architecture.md b/docs/architecture.md index b705809..2e45a9c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ Android capture client | v Android shared storage - Download/Trainlog + Documents/Trainlog | | direct MTP v @@ -168,7 +168,7 @@ silently rendered as unclassified. ### 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 one-time backfill copies the linked catalogue mode without changing child rows 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 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 and a private synchronization baseline, then seeds only stable-ID mappings 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: ```text -Download/Trainlog +Documents/Trainlog ``` ## 7. Shared synchronization engine diff --git a/docs/current_state.md b/docs/current_state.md index 0329cae..a247a42 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -13,6 +13,9 @@ GATE_1_TRAINLOG_FORMAT_V1=PASS/FROZEN GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS 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 ANDROID_LOCAL_DATABASE_V10=PASS @@ -81,7 +84,7 @@ Implemented: F7 actions, and restored focus after overlays/routes); - UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20 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, bounded durable current-profile revision ancestry, occurrence-level equipment identity, and desktop-local custom-equipment @@ -110,6 +113,35 @@ Implemented: - manual Android -> PC, PC -> Android, and bidirectional synchronization; - 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: ```text @@ -130,7 +162,7 @@ status can advance. Implemented: - 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; - `BODY_FOCUS_HOME_V1`: a compact accessible Home front/back BODY ZONES map, textual top-three exposure ranking and Catalogue/Statistics drill-through; @@ -151,7 +183,8 @@ Implemented: - local session history/detail; - body measurements; - 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; - 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 catalog representation, and loads the same assets on Android. It has no 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, an optional scientific profile, and an explicit resolved/unresolved science 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 `FINAL_REVIEW_REPAIR_VERIFICATION=PASS` and `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 context tests under ASan/UBSan plus Python timestamp validation, normal and 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: ```text -Download/Trainlog +Documents/Trainlog ``` 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 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` direction. `direction inconnue` is reserved for legacy history rows whose 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 same 32-relation stable-ID hash, and Android application tables plus companion exercise states compared equal to the first pass. Android now resolves the -exact request and every outbound artifact through the persisted authorized SAF -tree, independently of MediaStore ownership visibility. Historical numbered -copies remain untouched. One transaction-local exact-name snapshot is shared +exact request and every outbound artifact through direct filesystem access to +`Documents/Trainlog`. Historical numbered copies remain untouched. One +transaction-local exact-name snapshot is shared 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 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 Body Zones V1, exercise reconciliation, definition-first V2 synchronization diff --git a/docs/database.md b/docs/database.md index d4208de..928804b 100644 --- a/docs/database.md +++ b/docs/database.md @@ -16,8 +16,8 @@ is a later, separately versioned migration. ## 1. Status ```text -TRAINLOG_DATABASE_SCHEMA_VERSION=17 -DATABASE_SCHEMA_V17=PASS +TRAINLOG_DATABASE_SCHEMA_VERSION=18 +DATABASE_SCHEMA_V18=IMPLEMENTED_VALIDATION_PENDING TRAINLOG_FORMAT_V1=FROZEN ``` @@ -36,7 +36,7 @@ PRAGMA user_version; Current value: ```text -12 +18 ``` 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 `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_` 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 v13 facts. Both use stable immutable IDs, exact timestamps, non-empty bounded text at API boundaries, and parent-owned cascading foreign keys to @@ -447,7 +466,7 @@ distance km 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 identity/multi-occurrence support in v7, and the widened custom-equipment definition graph in v8. Version 9 adds completed/draft explicit max rows, raw diff --git a/docs/exchange_format.md b/docs/exchange_format.md index 9e7f861..a6d7271 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -1,5 +1,65 @@ # 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_` 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/.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 ```text diff --git a/docs/roadmap.md b/docs/roadmap.md index 2c03b55..bbe49f7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 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 ```text diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 058d428..df2b44b 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -2,7 +2,7 @@ ## 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 the BODY ZONES companion without wire-format changes. Machine metadata is not 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 BODY_ZONE_SYNC_V1=PASS BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS +TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED ``` @@ -63,13 +64,22 @@ v1 format. Canonical Android shared-storage directory: ```text -Download/Trainlog +Documents/Trainlog ``` Desktop accesses this directory through direct MTP. -Android accesses PC-created artifacts through a persistent Storage Access -Framework folder grant. +Android accesses this exact public directory directly after the user enables +`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 @@ -87,6 +97,7 @@ Framework folder grant. | 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-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 | | 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 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 -`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`, 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 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/.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 Canonical implementation: @@ -472,16 +503,37 @@ Android-triggered path: Android request -> trainlog-syncd -> shared engine +-> Drive AI draft fetch/import/archive attempt +-> PC -> Android AI draft companion publication -> 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: ```text -a Android -> PC: definition V1 -> mobile V3 -> body zones V1 -> association V2; no publish -p PC -> Android: definition V1 -> catalog V1 -> body zones V1 -> mobile V3 - (including bodies) -> association V2; no receive -b bidirectional: complete inbound sequence, then complete outbound sequence +a Android -> PC: definition V1 -> mobile V3 -> body zones V1 -> association V2 + -> Drive AI draft midpoint; no Android publish +p PC -> Android: Drive AI draft midpoint -> AI-drafts companion -> profile + 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 @@ -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 a no-op. Replaying the same artifact neither changes the current revision nor 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. diff --git a/docs/tests.md b/docs/tests.md index c87ddbc..1481cea 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1,5 +1,11 @@ # 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 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 -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: ```text @@ -388,6 +394,43 @@ reprocessing one request as a new one. The TUI also invokes the same engine manually and exposes structured run 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 For meaningful C checkpoints: @@ -462,7 +505,7 @@ Coverage proves: and footer F6/F7 single-occurrence output at 120x35, 100x30, 80x24, and 72x20. -Historical validation checkpoint (the current desktop suite is 48/48): +Historical validation checkpoint (the current desktop suite is 56/56): ```text 39/39 Meson tests PASS @@ -496,12 +539,17 @@ Historical validation checkpoint (the current desktop suite is 48/48): ## 13. Android session draft v1 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 existing rows with `load_mode=none`, zero rest and NULL targets. The v11 -> v12 migration adds the durable flattened exercise-alias table. Its exact 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. +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 restore, remove/discard, atomic 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 historical values, unchanged current profile history, and idempotent replay. `SyncBundlePublicationTest` exercises the production request coordinator and a -synthetic user-authorized SAF directory whose exact BODY ZONES canonical is -deliberately unavailable through MediaStore. The exact SAF object is rewritten -beside 31 preserved historical copies, no MediaStore insert/pending publication -or `(32)` copy occurs, all seven Android-origin companions precede the request -signal, and replay reuses canonical objects without stable-feedback churn. +synthetic direct exchange directory. The exact BODY ZONES file is rewritten +beside 31 preserved historical copies, no `(32)` copy occurs, all seven +Android-origin companions precede the request signal, and replay reuses +canonical names without stable-feedback churn. It also requires one direct-child enumeration for the entire export-plus-request transaction, one additional enumeration for each later synchronization, index 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 profile pre-pass before mobile import, strict post-pass gating, replay, legacy peer compatibility, and the explicit missing-companion diagnostic even when a -stale local temporary profile artifact exists. -Every Android outbound artifact and the request use the shared exact-name SAF -resolver; app-scoped MediaStore visibility is not part of canonical selection. +stale local temporary profile artifact exists. It also receives a valid alias +V1 companion through the production MTP orchestration, imports it into an +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 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 pass without revision-state or history-count churn. It also checks that the 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`. diff --git a/format/trainlog-ai-session-draft-v1.schema.json b/format/trainlog-ai-session-draft-v1.schema.json new file mode 100644 index 0000000..613957d --- /dev/null +++ b/format/trainlog-ai-session-draft-v1.schema.json @@ -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"}} + } + } + } +} diff --git a/format/trainlog-ai-session-drafts-v1.schema.json b/format/trainlog-ai-session-drafts-v1.schema.json new file mode 100644 index 0000000..54975d1 --- /dev/null +++ b/format/trainlog-ai-session-drafts-v1.schema.json @@ -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"}} + } + } + } +} diff --git a/tests/test_ai_history_export.py b/tests/test_ai_history_export.py new file mode 100644 index 0000000..5a6c54f --- /dev/null +++ b/tests/test_ai_history_export.py @@ -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() diff --git a/tests/test_ai_session_draft.py b/tests/test_ai_session_draft.py new file mode 100644 index 0000000..d9afe61 --- /dev/null +++ b/tests/test_ai_session_draft.py @@ -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() diff --git a/tests/test_post_sync_ai_drive.py b/tests/test_post_sync_ai_drive.py new file mode 100644 index 0000000..8828e1a --- /dev/null +++ b/tests/test_post_sync_ai_drive.py @@ -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() diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py new file mode 100644 index 0000000..be58441 --- /dev/null +++ b/tools/export_ai_history.py @@ -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) diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py new file mode 100644 index 0000000..f05bec2 --- /dev/null +++ b/tools/export_ai_session_drafts.py @@ -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()) diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 523bc00..7960ff9 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,7 +28,7 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) 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") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index f0a505a..d7791c3 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -24,7 +24,7 @@ def main(): connection = connect_database(args.database) connection.row_factory = sqlite3.Row 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") equipment = [dict(row) for row in connection.execute( "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 9a48339..caeac9e 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -16,7 +16,7 @@ def main(): args = parser.parse_args() con = connect_database(args.database) 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") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 9774867..b208b51 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -41,7 +41,7 @@ def main(): connection = connect_database(args.database) connection.row_factory = sqlite3.Row 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") exercises = [] for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"): diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index d4491e0..c3275a8 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -25,7 +25,7 @@ def main(): con.row_factory = sqlite3.Row try: 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}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 1f76a15..91ff06e 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -68,7 +68,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in (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( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 84107af..e0827c9 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -69,7 +69,7 @@ def main(): con.row_factory = sqlite3.Row try: 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)") known_equipment = supplied_equipment_ids() canonical_names = load_exercise_names() diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index d4900b1..b0c452f 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -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() db=connect_database(args.database) 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() 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") diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py new file mode 100644 index 0000000..e47414d --- /dev/null +++ b/tools/import_ai_session_draft.py @@ -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_ 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_ 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()) diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 8bc29ae..903e150 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -173,7 +173,7 @@ def main(): fail("clés extension équipement invalides") connection = connect_database(args.database) 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") known = load_catalog(args.catalog) known.update(row[0] for row in connection.execute( diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index ae5f3f1..2d064ce 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -61,7 +61,7 @@ def main(): definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog)) connection = connect_database(args.database) 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") imported = skipped = 0 # Validate every same-ID row before inserting any definition. diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 9283595..5e21772 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -63,7 +63,7 @@ def main(): con = connect_database(args.database) try: 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") con.execute("BEGIN IMMEDIATE") for source, canonical in aliases: diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index 0cc5272..d9fe93f 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -205,7 +205,7 @@ def main(): connection = connect_database(args.database) updated = skipped = kept_local = 0 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") connection.execute("PRAGMA foreign_keys=ON") connection.execute("BEGIN IMMEDIATE") diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index 585cfe5..010767f 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -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") parse_timestamp(root["generated_at"], "generated_at") 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") for item in root["exercises"]: validate(item);eid=item["exercise_id"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 7d5d8bf..e077246 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -813,9 +813,9 @@ def require_supported_schema(connection): # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # readable for legacy artifacts and are never made to fake that table. - if version not in (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( - f"base desktop schema v5 à v17 attendue, version trouvée: {version}" + f"base desktop schema v5 à v18 attendue, version trouvée: {version}" ) diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index aea9f9c..edacf94 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -51,7 +51,7 @@ def main(): roots_added=roots_skipped=revisions_added=revisions_skipped=0 try: 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() 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"} diff --git a/tools/mark_ai_session_drafts_published.py b/tools/mark_ai_session_drafts_published.py new file mode 100644 index 0000000..e5f647d --- /dev/null +++ b/tools/mark_ai_session_drafts_published.py @@ -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()) diff --git a/tools/post_sync_ai_drive.py b/tools/post_sync_ai_drive.py new file mode 100644 index 0000000..2a477d9 --- /dev/null +++ b/tools/post_sync_ai_drive.py @@ -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()) diff --git a/tools/sync_ai_session_draft_drive.py b/tools/sync_ai_session_draft_drive.py new file mode 100644 index 0000000..b6f2063 --- /dev/null +++ b/tools/sync_ai_session_draft_drive.py @@ -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()) diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index edcdf1b..7f091d6 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -11,7 +11,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 17 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 18 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/include/trainlog/sync.h b/tui/include/trainlog/sync.h index f4ccf8a..edc0a9b 100644 --- a/tui/include/trainlog/sync.h +++ b/tui/include/trainlog/sync.h @@ -41,6 +41,16 @@ typedef struct TrainlogSyncDeviceInfo { TrainlogMtpStorage storage; } 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 { bool success; bool request_present; @@ -72,6 +82,7 @@ typedef struct TrainlogSyncReport { size_t equipment_definitions_skipped; size_t catalog_published; + TrainlogAiInboundStatus ai_inbound_status; char summary[ TRAINLOG_SYNC_SUMMARY_MAX + 1U diff --git a/tui/meson.build b/tui/meson.build index c72913d..f3490a8 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -79,6 +79,7 @@ session_generation_policy_generated = custom_target( strict_c_args = [ '-D_POSIX_C_SOURCE=200809L', + '-DTRAINLOG_TOOLS_DIR="@0@"'.format(meson.project_source_root() / 'tools'), '-Wconversion', '-Wformat=2', '-Wshadow', @@ -674,6 +675,24 @@ test_feedback_correction = executable( ) 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', 'tests/test_schema_v16_migration.c', diff --git a/tui/src/database.c b/tui/src/database.c index b79668c..5ded2f4 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -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;" "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. * CONTRACT: opening it adds the bounded durable lineage table without changing * 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) { status = TRAINLOG_STATUS_OK; } 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; } else { if (version == 1) { @@ -1586,6 +1657,12 @@ static TrainlogStatus initialize_or_validate_schema( if (status == TRAINLOG_STATUS_OK && version < 17) { 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, ENSURE_V17_PROFILE_REVISIONS_SQL); if (status == TRAINLOG_STATUS_OK) status = normalize_v17_prototype_profiles(database); @@ -1598,7 +1675,7 @@ static TrainlogStatus initialize_or_validate_schema( set_open_diagnostic( output_diagnostic, 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, SQLITE_ERROR ); diff --git a/tui/src/sync.c b/tui/src/sync.c index a1887e2..197efcd 100644 --- a/tui/src/sync.c +++ b/tui/src/sync.c @@ -63,6 +63,8 @@ static const char *const TRAINING_FEEDBACK_V1_NAME = "trainlog-training-feedback-v1.json"; static const char *const EXERCISE_PROFILE_STATE_NAME = "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 = "trainlog-sync-request-v1.json"; @@ -100,6 +102,8 @@ static const char *const TRAINING_FEEDBACK_LOCAL = "/tmp/trainlog-training-feedback-v2.json"; static const char *const EXERCISE_PROFILE_STATE_LOCAL = "/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 = "/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 = "/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 { int saved_stdout; int saved_stderr; @@ -165,6 +174,60 @@ static void sync_compose_diagnostic( 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( TrainlogSyncDirection direction ) @@ -909,7 +972,7 @@ static TrainlogStatus sync_find_exchange_folder( uint32_t *output_folder_id ) { - uint32_t download_id = 0U; + uint32_t documents_id = 0U; uint64_t ignored_size = 0U; TrainlogStatus status; @@ -920,13 +983,16 @@ static TrainlogStatus sync_find_exchange_folder( 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 = sync_find_child( device, UINT32_MAX, - "Download", + "Documents", true, - &download_id, + &documents_id, &ignored_size ); @@ -940,7 +1006,7 @@ static TrainlogStatus sync_find_exchange_folder( return sync_find_child( device, - download_id, + documents_id, "Trainlog", true, output_folder_id, @@ -1117,6 +1183,7 @@ static bool sync_resolve_repo_tool( size_t output_size ) { + const char *configured_directory; char executable[ PATH_MAX + 1U ]; @@ -1134,6 +1201,29 @@ static bool sync_resolve_repo_tool( 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 = readlink( "/proc/self/exe", @@ -1273,17 +1363,6 @@ static TrainlogStatus sync_run_python_tool( output[0] = '\0'; - if ( - !sync_resolve_repo_tool( - tool_name, - tool, - sizeof(tool) - ) - ) { - return - TRAINLOG_STATUS_NOT_FOUND; - } - result_fd = open( result_path, @@ -1295,10 +1374,21 @@ static TrainlogStatus sync_run_python_tool( ); if (result_fd < 0) { + (void)snprintf(output, output_size, + "résultat outil Python inaccessible (errno=%d)", errno); return 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(); if (child < (pid_t)0) { @@ -1306,8 +1396,9 @@ static TrainlogStatus sync_run_python_tool( result_fd ); - return - TRAINLOG_STATUS_SYSTEM_ERROR; + (void)snprintf(output, output_size, + "lancement outil Python échoué (errno=%d)", errno); + return TRAINLOG_STATUS_SYSTEM_ERROR; } 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); } @@ -1380,15 +1473,16 @@ static TrainlogStatus sync_run_python_tool( 0 ) < (pid_t)0 ) { - return - TRAINLOG_STATUS_SYSTEM_ERROR; + (void)snprintf(output, output_size, + "attente outil Python échouée (errno=%d)", errno); + return TRAINLOG_STATUS_SYSTEM_ERROR; } - (void)sync_read_text( - result_path, - output, - output_size - ); + if (!sync_read_text(result_path, output, output_size)) { + (void)snprintf(output, output_size, + "lecture diagnostic outil Python échouée"); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } if ( !WIFEXITED( @@ -1398,6 +1492,18 @@ static TrainlogStatus sync_run_python_tool( child_status ) != 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 TRAINLOG_STATUS_DATABASE_ERROR; } @@ -2354,6 +2460,13 @@ bool trainlog_sync_record_local_run( 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( json_file, ",\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( detail_file, "\nANDROID → PC\n" @@ -2545,6 +2663,58 @@ void trainlog_sync_build_summary( : "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( @@ -2750,7 +2920,7 @@ TrainlogStatus trainlog_sync_run( (void)snprintf( 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 ); @@ -2882,7 +3052,7 @@ TrainlogStatus trainlog_sync_run( } if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) { - goto outbound; + goto ai_draft_midpoint; } /* WHY: aliases must exist before the frozen session/catalog snapshot is @@ -3196,13 +3366,67 @@ TrainlogStatus trainlog_sync_run( 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) { output->success = true; final_status = TRAINLOG_STATUS_OK; 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", EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL, EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output)); @@ -3468,11 +3692,6 @@ finalize: } } - (void)trainlog_sync_record_local_run( - trigger, - output - ); - if ( output->request_present && 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: if (silence_active) { sync_silence_end( diff --git a/tui/tests/test_body_zones.c b/tui/tests/test_body_zones.c index f2abe90..701c7e6 100644 --- a/tui/tests/test_body_zones.c +++ b/tui/tests/test_body_zones.c @@ -199,7 +199,7 @@ static int migration_preserves_identity_and_history(void) CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; 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, "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK); CHECK(count == 2U); diff --git a/tui/tests/test_schema_v16_migration.c b/tui/tests/test_schema_v16_migration.c index aeb9fc5..a484e2a 100644 --- a/tui/tests/test_schema_v16_migration.c +++ b/tui/tests/test_schema_v16_migration.c @@ -84,7 +84,7 @@ static bool run_test(void) (void)fprintf(stderr, "open diagnostic: %s\n", diagnostic); 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; 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)); @@ -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 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 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(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_update_exercise_profiled(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", "Planche face sol", diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 9b80a0e..9507685 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strncmp(diagnostic, "migrate database to schema v17: SQLite rc=", - strlen("migrate database to schema v17: SQLite rc=")) == 0); + CHECK(strncmp(diagnostic, "migrate database to schema v18: SQLite rc=", + strlen("migrate database to schema v18: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void) CHECK(fd >= 0); CHECK(close(fd) == 0); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, "PRAGMA user_version=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(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); 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(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 5c1b312..4a3da8d 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v17") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v18") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); diff --git a/tui/tests/test_sync_body_zone_wiring.c b/tui/tests/test_sync_body_zone_wiring.c index 6cab60a..5eb2093 100644 --- a/tui/tests/test_sync_body_zone_wiring.c +++ b/tui/tests/test_sync_body_zone_wiring.c @@ -29,6 +29,75 @@ static const char *remote_mobile_name = "trainlog-mobile-export-v2.json"; static unsigned int remote_mobile_version = 2U; static const char *remote_tracking_mode = "reps"; 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) { @@ -134,13 +203,20 @@ TrainlogStatus trainlog_mtp_list_folder( return TRAINLOG_STATUS_INVALID_ARGUMENT; } if (parent_folder_id == UINT32_MAX) { - if (capacity < 1U) { + if (capacity < 2U) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } - set_entry(&output[0], 1U, UINT32_MAX, "Download", true); - *output_count = 1U; + set_entry(&output[0], 1U, UINT32_MAX, "Documents", true); + /* 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; } + if (parent_folder_id == 90U) { + fprintf(stderr, "legacy Download tree was accessed\n"); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } if (parent_folder_id == 1U) { if (capacity < 1U) { return TRAINLOG_STATUS_INVALID_ARGUMENT; @@ -150,7 +226,9 @@ TrainlogStatus trainlog_mtp_list_folder( return TRAINLOG_STATUS_OK; } 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) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } @@ -160,8 +238,13 @@ TrainlogStatus trainlog_mtp_list_folder( set_entry(&output[2], 12U, 2U, "trainlog-equipment-associations-v2.json", false); if (remote_profile_available) { - set_entry(&output[3], 13U, 2U, + set_entry(&output[next], 13U, 2U, "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; return TRAINLOG_STATUS_OK; @@ -186,7 +269,8 @@ TrainlogStatus trainlog_mtp_receive_file( name = item_id == 10U ? remote_mobile_name : item_id == 11U ? "trainlog-exercise-body-zones-v1.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) { return TRAINLOG_STATUS_NOT_FOUND; } @@ -289,6 +373,18 @@ static bool write_artifacts( } 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), "%s/trainlog-exercise-profile-state-v1.json", remote_root); CHECK(written >= 0 && (size_t)written < sizeof(path)); @@ -382,6 +478,18 @@ static bool prepare_database( 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( const char *database_path, bool source_should_resolve, @@ -466,6 +574,24 @@ static bool successful_journal(const char *case_root, const char *sync_id) 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) { char path[4096]; @@ -521,7 +647,9 @@ static bool run_success_case( database_path, sizeof(database_path))); 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 : TRAINLOG_SYNC_ANDROID_TO_PC, &report); if (status != TRAINLOG_STATUS_OK) { @@ -530,6 +658,9 @@ static bool run_success_case( CHECK(status == TRAINLOG_STATUS_OK); } 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(successful_journal(case_root, report.sync_id)); if (persistent_alias) { @@ -551,10 +682,13 @@ static bool run_success_case( } /* 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 : TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK); 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(successful_journal(case_root, report.sync_id)); if (persistent_alias) { @@ -628,7 +762,7 @@ static bool run_missing_profile_companion_case(const char *case_root) remote_profile_available = false; CHECK(prepare_database(case_root, false, false, 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, TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_DATABASE_ERROR); @@ -642,6 +776,139 @@ static bool run_missing_profile_companion_case(const char *case_root) 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) { char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX"; @@ -650,9 +917,21 @@ static bool run_all(void) char case_c[4096]; char case_d[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); CHECK(root != NULL); + CHECK(install_fake_rclone(root)); CHECK(snprintf(remote_root, sizeof(remote_root), "%s/remote", root) > 0); CHECK(mkdir(remote_root, 0700) == 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_d, sizeof(case_d), "%s/case-d", 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_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_v3_unknown_with_stale_v2_proof_case(case_d)); 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"); return true; } diff --git a/tui/tools/mtp_mobile_export_probe.c b/tui/tools/mtp_mobile_export_probe.c index d4e31de..6bca502 100644 --- a/tui/tools/mtp_mobile_export_probe.c +++ b/tui/tools/mtp_mobile_export_probe.c @@ -189,7 +189,7 @@ int main(void) size_t device_count = 0U; size_t storage_count = 0U; - uint32_t download_id = 0U; + uint32_t documents_id = 0U; uint32_t trainlog_id = 0U; uint32_t export_id = 0U; uint64_t export_size = 0U; @@ -240,14 +240,14 @@ int main(void) &devices[0], storages[0].storage_id, UINT32_MAX, - "Download", - &download_id + "Documents", + &documents_id ); if (status != TRAINLOG_STATUS_OK) { (void)fprintf( stderr, - "Download folder not found\n" + "Documents folder not found\n" ); return 1; @@ -257,7 +257,7 @@ int main(void) find_child_folder( &devices[0], storages[0].storage_id, - download_id, + documents_id, "Trainlog", &trainlog_id ); @@ -265,7 +265,7 @@ int main(void) if (status != TRAINLOG_STATUS_OK) { (void)fprintf( stderr, - "Download/Trainlog not found\n" + "Documents/Trainlog not found\n" ); return 1; @@ -329,7 +329,7 @@ int main(void) ); (void)printf( - "remote=Download/Trainlog/" + "remote=Documents/Trainlog/" "trainlog-mobile-export-v1.json\n" );