feat: add AI session draft sync workflow
This commit is contained in:
parent
32fae197ee
commit
48d163b0a6
74 changed files with 4646 additions and 564 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,6 +25,7 @@ local.properties
|
||||||
/data/
|
/data/
|
||||||
exports/
|
exports/
|
||||||
.local-backups/
|
.local-backups/
|
||||||
|
/trainlog_ai_export_v1.json
|
||||||
|
|
||||||
# Python helpers
|
# Python helpers
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@ Do not introduce a mandatory GVFS/FUSE mount.
|
||||||
Canonical exchange folder:
|
Canonical exchange folder:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Download/Trainlog
|
Documents/Trainlog
|
||||||
```
|
```
|
||||||
|
|
||||||
The shared desktop synchronization engine is:
|
The shared desktop synchronization engine is:
|
||||||
|
|
|
||||||
49
CHANGELOG.md
49
CHANGELOG.md
|
|
@ -1,5 +1,44 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- Added an Android confirmation dialog before deleting an imported AI session
|
||||||
|
draft. Cancel, Back and outside dismissal are non-mutating; only the
|
||||||
|
destructive confirmation invokes the existing deleted-tombstone operation.
|
||||||
|
|
||||||
|
- Replaced Android SAF folder grants with user-enabled
|
||||||
|
`MANAGE_EXTERNAL_STORAGE` access to the single fixed path
|
||||||
|
`/storage/emulated/0/Documents/Trainlog`. Android opens the system all-files
|
||||||
|
permission screen, writes canonical artifacts through fsync plus
|
||||||
|
same-directory replacement, reads PC artifacts directly, and removes stale
|
||||||
|
tree-URI preferences. Desktop publication remains `Documents/Trainlog`; the
|
||||||
|
historical Download tree and recovery backup remain untouched.
|
||||||
|
|
||||||
|
- Added `TRAINLOG_AI_SESSION_DRAFT_V1` as a separate strict proposal path:
|
||||||
|
desktop schema v18 imports one Drive source with `aid_` identity/digest
|
||||||
|
idempotence and post-commit archive bookkeeping, then publishes the bounded
|
||||||
|
`trainlog-ai-session-drafts` V1 Android companion in durable deterministic
|
||||||
|
batches, marking only successful MTP publications. Entry notes are rejected;
|
||||||
|
only draft-level notes belong to the source contract. Inbound helper outcomes
|
||||||
|
remain visible without turning Drive/archive/fetched-invalid results into a
|
||||||
|
failed device sync. Android schema v17 keeps
|
||||||
|
those proposals separate from its singleton active capture draft and retains
|
||||||
|
start/delete tombstones against replay. Automated contract coverage exists;
|
||||||
|
the required real Drive and Android-triggered bidirectional smoke validation
|
||||||
|
remains pending.
|
||||||
|
|
||||||
|
- Fixed automatic Android→PC exercise-alias imports when the desktop binary is
|
||||||
|
installed outside the repository build tree. Python sync helpers now resolve
|
||||||
|
from Meson's authoritative tools directory, stale result files are truncated
|
||||||
|
before resolution, and bounded child stderr/exit diagnostics reach sync
|
||||||
|
history instead of collapsing to a generic import failure.
|
||||||
|
- Added `TRAINLOG_AI_EXPORT_V1`, a deterministic local JSON history export for
|
||||||
|
manual external analysis. It opens desktop SQLite explicitly read-only,
|
||||||
|
preserves occurrence-owned immediate feedback and session-owned follow-ups,
|
||||||
|
and emits no inferred recommendations or progression.
|
||||||
|
- Added the shared desktop post-sync publication: successful TUI and
|
||||||
|
Android-requested sync runs regenerate the AI export and invoke external
|
||||||
|
`rclone copyto` for Google Drive. Export/Drive failures remain visible but do
|
||||||
|
not invalidate an already successful Trainlog synchronization.
|
||||||
|
|
||||||
- Fixed Android-triggered synchronization so the complete current Android→PC
|
- Fixed Android-triggered synchronization so the complete current Android→PC
|
||||||
companion bundle is published before the request signal, including Training
|
companion bundle is published before the request signal, including Training
|
||||||
Feedback V2 and Exercise Profile State V1. Desktop orchestration now refuses
|
Feedback V2 and Exercise Profile State V1. Desktop orchestration now refuses
|
||||||
|
|
@ -8,6 +47,16 @@
|
||||||
- Made Android exchange-file reuse exact and directory-scoped: pending,
|
- Made Android exchange-file reuse exact and directory-scoped: pending,
|
||||||
trashed, unrelated, and conflict-numbered MediaStore rows no longer enter
|
trashed, unrelated, and conflict-numbered MediaStore rows no longer enter
|
||||||
canonical selection, while genuine duplicate exact names fail closed.
|
canonical selection, while genuine duplicate exact names fail closed.
|
||||||
|
- Hardened the shared Android SAF publisher so every outbound companion and
|
||||||
|
sync request rewrites an existing exact canonical document with truncation,
|
||||||
|
never selects historical `(N)` copies, and reports
|
||||||
|
`SAF_CANONICAL_NAME_CONFLICT` before writing when a provider renames a newly
|
||||||
|
created document.
|
||||||
|
- Added an exact-document fallback for Samsung Android 16
|
||||||
|
`ExternalStorageProvider`, whose child query can omit ownerless MTP files even
|
||||||
|
though creation detects their names. Canonical rows are reconstructed only
|
||||||
|
beneath the granted tree and verified by exact provider display name;
|
||||||
|
historical suffixes remain untouched.
|
||||||
|
|
||||||
## Unreleased — Desktop historical tracking snapshot
|
## Unreleased — Desktop historical tracking snapshot
|
||||||
|
|
||||||
|
|
|
||||||
59
README.md
59
README.md
|
|
@ -15,6 +15,7 @@ desktop.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V11=PASS
|
DESKTOP_SCHEMA_V11=PASS
|
||||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||||
|
|
@ -67,7 +68,7 @@ Android occurrence pages and latest-MAX context share the settled exact-instant
|
||||||
ordering and source-text cursor contract. The independent temporal review
|
ordering and source-text cursor contract. The independent temporal review
|
||||||
returned PASS with no findings. The initial full-tranche audit's four findings
|
returned PASS with no findings. The initial full-tranche audit's four findings
|
||||||
were resolved by one bounded repair chain and independently verified. Fresh
|
were resolved by one bounded repair chain and independently verified. Fresh
|
||||||
validation passed: strict build, 42 Meson tests, Python knowledge/temporal
|
validation passed: strict build, 56 Meson tests, Python knowledge/temporal
|
||||||
tests, validators, headers, sanitizers, 12-form temporal probes, Android 56
|
tests, validators, headers, sanitizers, 12-form temporal probes, Android 56
|
||||||
tests with one known fixture skip, and Java 17 debug assembly.
|
tests with one known fixture skip, and Java 17 debug assembly.
|
||||||
The [temporal correction record](docs/reviews/training_knowledge_v1_temporal_contract.md)
|
The [temporal correction record](docs/reviews/training_knowledge_v1_temporal_contract.md)
|
||||||
|
|
@ -82,7 +83,7 @@ defines the accepted grammar, regression evidence and remaining review boundary.
|
||||||
automatic mobile snapshot
|
automatic mobile snapshot
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
Download/Trainlog on Android storage
|
Documents/Trainlog on Android storage
|
||||||
|
|
|
|
||||||
| direct MTP / libmtp
|
| direct MTP / libmtp
|
||||||
v
|
v
|
||||||
|
|
@ -192,6 +193,58 @@ python tools/validate_import_contract.py
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Local history export for external analysis
|
||||||
|
|
||||||
|
Create the read-only `TRAINLOG_AI_EXPORT_V1` artifact in the current directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/export_ai_history.py --database ~/.local/share/trainlog/trainlog.db
|
||||||
|
```
|
||||||
|
|
||||||
|
The default output is `trainlog_ai_export_v1.json`. It is ignored by Git
|
||||||
|
because it contains user data. After every completely successful desktop sync,
|
||||||
|
the shared sync engine regenerates this artifact and asks the external desktop
|
||||||
|
`rclone` command to copy it to
|
||||||
|
`TrainLog Gdrive:Trainlog/AI/trainlog_ai_export_v1.json`. Export or Drive
|
||||||
|
failure is reported without changing the successful Trainlog sync result.
|
||||||
|
Android never runs `rclone`, and its configuration/OAuth credentials remain in
|
||||||
|
the user's normal `~/.config/rclone/` location outside this repository and the
|
||||||
|
APK. See [the exchange documentation](docs/exchange_format.md) for the
|
||||||
|
versioned raw-data contract and feedback ownership rules.
|
||||||
|
|
||||||
|
## AI session-draft exchange
|
||||||
|
|
||||||
|
`TRAINLOG_AI_SESSION_DRAFT_V1` is a separate, strict proposal exchange; it
|
||||||
|
does not alter `TRAINLOG_FORMAT_V1`, the mobile export, or the existing
|
||||||
|
read-only `TRAINLOG_AI_EXPORT_V1` history export. An external producer places
|
||||||
|
one source object at
|
||||||
|
`TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json`. The desktop
|
||||||
|
fetches it during synchronization, validates and imports it transactionally,
|
||||||
|
then copies those exact fetched bytes after that commit to
|
||||||
|
`TrainLog Gdrive:Trainlog/AI/archive/` under its stable `aid_<uuid-v4>`
|
||||||
|
identity. The mutable inbox object is never moved or deleted: its logical
|
||||||
|
replay is idempotent, and an archive failure is visible and retryable without
|
||||||
|
undoing the committed import.
|
||||||
|
|
||||||
|
The desktop then publishes the distinct PC-to-Android companion
|
||||||
|
`trainlog-ai-session-drafts-v1.json` in `Documents/Trainlog`. Android keeps its
|
||||||
|
pending proposals as a collection, separate from its exactly-one active
|
||||||
|
capture draft. Starting one proposal copies targets only into that singleton
|
||||||
|
draft and atomically tombstones the proposal; deleting a proposal also leaves
|
||||||
|
a tombstone, so replay cannot recreate it. No proposal creates performed work,
|
||||||
|
history, MAX, or feedback.
|
||||||
|
|
||||||
|
The source and companion are bounded and strict: UUIDv4 `aid_` identities,
|
||||||
|
nonempty trimmed text when supplied (title 120 and draft notes 2,000 characters;
|
||||||
|
entries have no notes), at most 64 ordered entries, target sets 1..99, target
|
||||||
|
reps 1..999, and at most 256 unpublished drafts per deterministic publication.
|
||||||
|
Successful MTP publication advances the durable batch cursor; failure retries
|
||||||
|
the same batch. Only `SETS` plans are
|
||||||
|
allowed; the selected exercise's REPS-or-DURATION profile determines the one
|
||||||
|
valid target metric. The final real Google Drive and Android-triggered
|
||||||
|
bidirectional smoke test remains manual, so this checkpoint is documented as
|
||||||
|
`VALIDATION_PENDING`, not PASS. See [AI draft exchange](docs/exchange_format.md).
|
||||||
|
|
||||||
## Android build
|
## Android build
|
||||||
|
|
||||||
Android keeps one durable in-progress workout in its local SQLite database.
|
Android keeps one durable in-progress workout in its local SQLite database.
|
||||||
|
|
@ -205,7 +258,7 @@ export or desktop synchronization as completed sessions.
|
||||||
Schema migrations are additive and preserve existing capture data. See
|
Schema migrations are additive and preserve existing capture data. See
|
||||||
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
||||||
|
|
||||||
The current Android schema is v14. Its additive v4 -> v14 chain adds the shared
|
The current Android schema is v17. Its additive v4 -> v17 chain adds the shared
|
||||||
equipment catalogue, per-occurrence equipment links, durable occurrence
|
equipment catalogue, per-occurrence equipment links, durable occurrence
|
||||||
identities, custom-equipment definition support, explicit MAX results and
|
identities, custom-equipment definition support, explicit MAX results and
|
||||||
stable-source Test max resumption, then direct primary/secondary body-zone
|
stable-source Test max resumption, then direct primary/secondary body-zone
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,6 @@ dependencies {
|
||||||
"androidx.compose.ui:ui-tooling-preview"
|
"androidx.compose.ui:ui-tooling-preview"
|
||||||
)
|
)
|
||||||
|
|
||||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
|
||||||
|
|
||||||
debugImplementation(
|
debugImplementation(
|
||||||
"androidx.compose.ui:ui-tooling"
|
"androidx.compose.ui:ui-tooling"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- Voice feedback stores recognition text only; no audio file is created. -->
|
<!-- Voice feedback stores recognition text only; no audio file is created. -->
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<!-- Personal sideloaded app: permanent bidirectional MTP exchange uses one
|
||||||
|
user-visible directory, Documents/Trainlog, without a SAF provider. -->
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
|
||||||
|
/* CONTRACT: only Android's settings UI can grant all-files access. Prefer the
|
||||||
|
* package-scoped screen and fall back to the platform-wide screen when an OEM
|
||||||
|
* does not expose the former activity. */
|
||||||
|
internal fun directStoragePermissionIntent(context: Context): Intent {
|
||||||
|
val packageIntent = Intent(
|
||||||
|
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||||
|
Uri.parse("package:${context.packageName}"),
|
||||||
|
)
|
||||||
|
return if (packageIntent.resolveActivity(context.packageManager) != null) {
|
||||||
|
packageIntent
|
||||||
|
} else {
|
||||||
|
Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,35 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.os.Build
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
internal const val SYNC_PREFERENCES_NAME = "trainlog-sync"
|
internal const val SYNC_PREFERENCES_NAME = "trainlog-sync"
|
||||||
internal const val SYNC_TREE_URI_KEY = "trainlog_tree_uri"
|
internal const val SYNC_TREE_URI_KEY = "trainlog_tree_uri"
|
||||||
internal const val EXCHANGE_MEDIASTORE_LOG_TAG = "TrainlogMediaStore"
|
internal const val DIRECT_EXCHANGE_LOG_TAG = "TrainlogDirectStorage"
|
||||||
|
internal const val EXCHANGE_FOLDER_DISPLAY_PATH = "Documents/Trainlog"
|
||||||
|
|
||||||
|
/* CONTRACT: direct exchange storage has one fixed public path. The legacy SAF
|
||||||
|
* preference is not authority and is removed without touching external files. */
|
||||||
|
internal fun clearLegacyExchangeTreePreference(context: Context) {
|
||||||
|
context.applicationContext.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
|
.edit().remove(SYNC_TREE_URI_KEY).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun hasDirectExchangePermission(): Boolean =
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
||||||
|
runCatching { Environment.isExternalStorageManager() }.getOrDefault(false)
|
||||||
|
|
||||||
|
internal fun canonicalExchangeDirectory(): File =
|
||||||
|
File(Environment.getExternalStorageDirectory(), "Documents/Trainlog")
|
||||||
|
|
||||||
internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) {
|
internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) {
|
||||||
"trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES"
|
"trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES"
|
||||||
|
|
@ -22,141 +43,164 @@ internal fun exchangeArtifactIdentity(displayName: String): String = when (displ
|
||||||
else -> displayName
|
else -> displayName
|
||||||
}
|
}
|
||||||
|
|
||||||
/* WHY: document providers may throw the same message from different SAF
|
internal fun logDirectExchange(displayName: String, phase: String, detail: String? = null) {
|
||||||
* operations. CONTRACT: traces contain artifact identity, operation phase,
|
|
||||||
* URI and exception metadata only; user JSON is never logged. */
|
|
||||||
internal fun logExchangeMediaStore(displayName: String, phase: String, detail: String? = null) {
|
|
||||||
val suffix = detail?.let { " $it" } ?: ""
|
val suffix = detail?.let { " $it" } ?: ""
|
||||||
Log.d(EXCHANGE_MEDIASTORE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} $phase$suffix")
|
Log.d(DIRECT_EXCHANGE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} $phase$suffix")
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun logExchangeMediaStoreError(displayName: String, phase: String, error: Throwable) {
|
internal fun logDirectExchangeError(displayName: String, phase: String, error: Throwable) {
|
||||||
Log.e(
|
Log.e(DIRECT_EXCHANGE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} error phase=$phase " +
|
||||||
EXCHANGE_MEDIASTORE_LOG_TAG,
|
"exception=${error.javaClass.name}:${error.message}")
|
||||||
"${exchangeArtifactIdentity(displayName)} error phase=$phase " +
|
|
||||||
"exception=${error.javaClass.name}:${error.message}",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal data class ExchangeSafDocument(
|
internal data class DirectExchangeDocument(val file: File, val displayName: String)
|
||||||
val uri: Uri,
|
|
||||||
val displayName: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal interface ExchangeSafDirectory {
|
internal interface DirectExchangeDirectoryAccess {
|
||||||
fun listDirectChildren(): List<ExchangeSafDocument>
|
fun listDirectChildren(): List<DirectExchangeDocument>
|
||||||
fun createJson(displayName: String): ExchangeSafDocument?
|
fun findExact(displayName: String): List<DirectExchangeDocument>
|
||||||
fun openForRewrite(document: ExchangeSafDocument): OutputStream?
|
fun createJson(displayName: String): DirectExchangeDocument?
|
||||||
|
fun openForRewrite(document: DirectExchangeDocument): OutputStream?
|
||||||
}
|
}
|
||||||
|
|
||||||
private class DocumentFileExchangeSafDirectory(
|
/* WHY: provider writes allowed Samsung to manufacture numbered conflict
|
||||||
private val context: Context,
|
* copies. Direct files make the exact canonical name authoritative.
|
||||||
private val directory: DocumentFile,
|
* INVARIANT: bytes are fully written and fsync'd in this directory before a
|
||||||
) : ExchangeSafDirectory {
|
* same-directory move replaces the canonical path. */
|
||||||
override fun listDirectChildren(): List<ExchangeSafDocument> =
|
private class DirectExchangeDirectory(private val directory: File) : DirectExchangeDirectoryAccess {
|
||||||
directory.listFiles().mapNotNull { child ->
|
override fun listDirectChildren(): List<DirectExchangeDocument> =
|
||||||
child.name?.let { ExchangeSafDocument(child.uri, it) }
|
directory.listFiles().orEmpty().filter(File::isFile).map { DirectExchangeDocument(it, it.name) }
|
||||||
|
|
||||||
|
override fun findExact(displayName: String): List<DirectExchangeDocument> {
|
||||||
|
requireCanonicalLeaf(displayName)
|
||||||
|
val candidate = File(directory, displayName)
|
||||||
|
return if (candidate.isFile) listOf(DirectExchangeDocument(candidate, displayName)) else emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createJson(displayName: String): 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."
|
||||||
}
|
}
|
||||||
|
val temporary = File(directory, ".trainlog-${document.displayName}-${UUID.randomUUID()}.tmp")
|
||||||
override fun createJson(displayName: String): ExchangeSafDocument? =
|
val delegate = FileOutputStream(temporary, false)
|
||||||
directory.createFile("application/json", displayName)?.let { created ->
|
return object : OutputStream() {
|
||||||
val actualName = created.name
|
private var closed = false
|
||||||
require(actualName == displayName) {
|
private var failed = false
|
||||||
"Le fournisseur SAF a créé $actualName au lieu de $displayName."
|
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? =
|
private fun requireCanonicalLeaf(displayName: String) {
|
||||||
context.contentResolver.openOutputStream(document.uri, "wt")
|
require(displayName.isNotEmpty() && displayName != "." && displayName != "..")
|
||||||
|
require('/' !in displayName && '\\' !in displayName) { "Nom d'artefact invalide." }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun persistedExchangeSafDirectory(context: Context): ExchangeSafDirectory? {
|
internal fun directExchangeDirectoryForPath(directory: File): DirectExchangeDirectoryAccess? {
|
||||||
val appContext = context.applicationContext
|
if (!directory.exists() && !directory.mkdirs()) return null
|
||||||
val treeUri = appContext.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE)
|
if (!directory.isDirectory || !directory.canRead() || !directory.canWrite()) return null
|
||||||
.getString(SYNC_TREE_URI_KEY, null)?.let(Uri::parse) ?: return null
|
return DirectExchangeDirectory(directory)
|
||||||
val directory = DocumentFile.fromTreeUri(appContext, treeUri) ?: return null
|
|
||||||
if (!directory.isDirectory || !directory.canWrite()) return null
|
|
||||||
return DocumentFileExchangeSafDirectory(appContext, directory)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class ExchangeSafPublisher(
|
internal fun directExchangeDirectory(context: Context): DirectExchangeDirectoryAccess? {
|
||||||
private val directoryProvider: () -> ExchangeSafDirectory?,
|
clearLegacyExchangeTreePreference(context)
|
||||||
) {
|
if (!hasDirectExchangePermission()) return null
|
||||||
fun snapshot(): ExchangeSafSnapshotResult {
|
return directExchangeDirectoryForPath(canonicalExchangeDirectory())
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DirectExchangePublisher(private val directoryProvider: () -> DirectExchangeDirectoryAccess?) {
|
||||||
|
fun snapshot(): DirectExchangeSnapshotResult {
|
||||||
val directory = directoryProvider()
|
val directory = directoryProvider()
|
||||||
?: return ExchangeSafSnapshotResult.Error(
|
?: return DirectExchangeSnapshotResult.Error("Accès fichiers requis pour Documents/Trainlog.")
|
||||||
"Dossier Trainlog non autorisé ou inaccessible.",
|
|
||||||
)
|
|
||||||
val started = android.os.SystemClock.elapsedRealtime()
|
val started = android.os.SystemClock.elapsedRealtime()
|
||||||
return try {
|
return try {
|
||||||
logExchangeMediaStore("SAF_TREE", "snapshot.begin", "thread=${Thread.currentThread().name}")
|
logDirectExchange("DIRECT_STORAGE", "snapshot.begin", "thread=${Thread.currentThread().name}")
|
||||||
val children = directory.listDirectChildren()
|
val children = directory.listDirectChildren()
|
||||||
val byName = children.groupByTo(linkedMapOf()) { it.displayName }
|
val byName = children.groupByTo(linkedMapOf()) { it.displayName }
|
||||||
val elapsed = android.os.SystemClock.elapsedRealtime() - started
|
logDirectExchange("DIRECT_STORAGE", "snapshot.success",
|
||||||
logExchangeMediaStore(
|
"children=${children.size} duration_ms=${android.os.SystemClock.elapsedRealtime() - started} " +
|
||||||
"SAF_TREE",
|
"thread=${Thread.currentThread().name}")
|
||||||
"snapshot.success",
|
DirectExchangeSnapshotResult.Ready(DirectExchangeSnapshot(directory, byName))
|
||||||
"children=${children.size} duration_ms=$elapsed thread=${Thread.currentThread().name}",
|
|
||||||
)
|
|
||||||
ExchangeSafSnapshotResult.Ready(ExchangeSafSnapshot(directory, byName))
|
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
logExchangeMediaStoreError("SAF_TREE", "snapshot", error)
|
logDirectExchangeError("DIRECT_STORAGE", "snapshot", error)
|
||||||
ExchangeSafSnapshotResult.Error(error.message ?: "Lecture du dossier SAF impossible.")
|
DirectExchangeSnapshotResult.Error(error.message ?: "Lecture de Documents/Trainlog impossible.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed interface ExchangeSafSnapshotResult {
|
internal sealed interface DirectExchangeSnapshotResult {
|
||||||
data class Ready(val snapshot: ExchangeSafSnapshot) : ExchangeSafSnapshotResult
|
data class Ready(val snapshot: DirectExchangeSnapshot) : DirectExchangeSnapshotResult
|
||||||
data class Error(val message: String) : ExchangeSafSnapshotResult
|
data class Error(val message: String) : DirectExchangeSnapshotResult
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class ExchangeSafSnapshot(
|
internal class DirectExchangeSnapshot(
|
||||||
private val directory: ExchangeSafDirectory,
|
private val directory: DirectExchangeDirectoryAccess,
|
||||||
private val byName: MutableMap<String, MutableList<ExchangeSafDocument>>,
|
private val byName: MutableMap<String, MutableList<DirectExchangeDocument>>,
|
||||||
) {
|
) {
|
||||||
fun writeJson(displayName: String, json: String): String? {
|
fun writeJson(displayName: String, json: String): String? {
|
||||||
var phase = "saf.resolve"
|
var phase = "direct.resolve"
|
||||||
return try {
|
return try {
|
||||||
logExchangeMediaStore(displayName, "saf.resolve.begin")
|
|
||||||
val exact = byName[displayName].orEmpty()
|
val exact = byName[displayName].orEmpty()
|
||||||
require(exact.size <= 1) {
|
require(exact.size <= 1) { "DIRECT_STORAGE_DUPLICATE_CANONICAL: nom=$displayName count=${exact.size}" }
|
||||||
"Plusieurs documents SAF canoniques exacts pour $displayName (${exact.size})."
|
val resolved = exact.singleOrNull() ?: directory.findExact(displayName).also {
|
||||||
}
|
require(it.size <= 1) { "DIRECT_STORAGE_DUPLICATE_CANONICAL: nom=$displayName count=${it.size}" }
|
||||||
val existing = exact.singleOrNull()
|
}.singleOrNull()
|
||||||
val document = if (existing != null) {
|
val document = resolved ?: directory.createJson(displayName)?.also {
|
||||||
logExchangeMediaStore(displayName, "saf.resolve.existing", "uri=${existing.uri}")
|
require(it.displayName == displayName) {
|
||||||
existing
|
"DIRECT_STORAGE_CANONICAL_NAME_CONFLICT: attendu=$displayName obtenu=${it.displayName}"
|
||||||
} else {
|
}
|
||||||
phase = "saf.create"
|
byName.getOrPut(displayName) { mutableListOf() }.add(it)
|
||||||
logExchangeMediaStore(displayName, "saf.resolve.absent")
|
} ?: return "Création de $displayName impossible."
|
||||||
logExchangeMediaStore(displayName, "saf.create.begin", "mime=application/json")
|
phase = "direct.atomicWrite"
|
||||||
directory.createJson(displayName)?.also { created ->
|
val stream = directory.openForRewrite(document) ?: return "Écriture de $displayName impossible."
|
||||||
/* INVARIANT: creation mutates this transaction-local index
|
stream.use { it.write(json.toByteArray(Charsets.UTF_8)) }
|
||||||
* so later artifacts never rescan the live directory. */
|
logDirectExchange(displayName, "direct.write.success")
|
||||||
byName.getOrPut(displayName) { mutableListOf() }.add(created)
|
|
||||||
logExchangeMediaStore(displayName, "saf.create.success", "uri=${created.uri}")
|
|
||||||
} ?: return "Création SAF de $displayName impossible."
|
|
||||||
}
|
|
||||||
phase = "saf.openOutputStream"
|
|
||||||
logExchangeMediaStore(
|
|
||||||
displayName,
|
|
||||||
"saf.write.begin",
|
|
||||||
"uri=${document.uri} mode=wt thread=${Thread.currentThread().name}",
|
|
||||||
)
|
|
||||||
val stream = directory.openForRewrite(document)
|
|
||||||
?: return "Écriture SAF de $displayName impossible."
|
|
||||||
phase = "saf.stream.write"
|
|
||||||
stream.use {
|
|
||||||
it.write(json.toByteArray(Charsets.UTF_8))
|
|
||||||
it.flush()
|
|
||||||
}
|
|
||||||
logExchangeMediaStore(displayName, "saf.write.success", "uri=${document.uri}")
|
|
||||||
null
|
null
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
logExchangeMediaStoreError(displayName, phase, error)
|
logDirectExchangeError(displayName, phase, error)
|
||||||
error.message ?: "Publication SAF de $displayName impossible."
|
error.message ?: "Publication de $displayName impossible."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import java.io.File
|
||||||
import android.net.Uri
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
sealed interface CatalogInboxResult {
|
sealed interface CatalogInboxResult {
|
||||||
|
|
@ -50,96 +48,34 @@ class SyncCatalogInbox(
|
||||||
private val appContext =
|
private val appContext =
|
||||||
context.applicationContext
|
context.applicationContext
|
||||||
|
|
||||||
private val preferences =
|
|
||||||
appContext.getSharedPreferences(
|
|
||||||
SYNC_PREFERENCES_NAME,
|
|
||||||
Context.MODE_PRIVATE,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun hasFolderAccess(): Boolean =
|
fun hasFolderAccess(): Boolean =
|
||||||
savedTreeUri() != null
|
directExchangeDirectory() != null
|
||||||
|
|
||||||
fun saveTreeUri(
|
|
||||||
uri: Uri,
|
|
||||||
): Boolean {
|
|
||||||
return try {
|
|
||||||
appContext
|
|
||||||
.contentResolver
|
|
||||||
.takePersistableUriPermission(
|
|
||||||
uri,
|
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
|
||||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
|
||||||
)
|
|
||||||
|
|
||||||
preferences
|
|
||||||
.edit()
|
|
||||||
.putString(
|
|
||||||
SYNC_TREE_URI_KEY,
|
|
||||||
uri.toString(),
|
|
||||||
)
|
|
||||||
.apply()
|
|
||||||
|
|
||||||
true
|
|
||||||
} catch (
|
|
||||||
error: SecurityException
|
|
||||||
) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun importPcCatalog():
|
fun importPcCatalog():
|
||||||
CatalogInboxResult {
|
CatalogInboxResult {
|
||||||
val treeUri =
|
val directory = directExchangeDirectory()
|
||||||
savedTreeUri()
|
?: return CatalogInboxResult.FolderNotAuthorized
|
||||||
?: return CatalogInboxResult
|
|
||||||
.FolderNotAuthorized
|
|
||||||
|
|
||||||
val directory =
|
return importPcCatalogFromDirectory(directory)
|
||||||
DocumentFile
|
}
|
||||||
.fromTreeUri(
|
|
||||||
appContext,
|
/** Test seam for the production companion ordering on a fresh database. */
|
||||||
treeUri,
|
internal fun importPcCatalogFromDirectoryForTest(directory: File): CatalogInboxResult =
|
||||||
)
|
importPcCatalogFromDirectory(directory)
|
||||||
?: return CatalogInboxResult.Error(
|
|
||||||
"Dossier Trainlog inaccessible."
|
private fun importPcCatalogFromDirectory(directory: File): CatalogInboxResult {
|
||||||
)
|
|
||||||
|
|
||||||
val file =
|
val file =
|
||||||
directory.findFile(
|
directory.findDirectFile("trainlog-pc-catalog-v1.json")
|
||||||
"trainlog-pc-catalog-v1.json"
|
|
||||||
)
|
|
||||||
?: return CatalogInboxResult.FileNotFound
|
?: return CatalogInboxResult.FileNotFound
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val stream =
|
val json = file.readText(Charsets.UTF_8)
|
||||||
appContext
|
|
||||||
.contentResolver
|
|
||||||
.openInputStream(
|
|
||||||
file.uri
|
|
||||||
)
|
|
||||||
?: return CatalogInboxResult.Error(
|
|
||||||
"Lecture du catalogue impossible."
|
|
||||||
)
|
|
||||||
|
|
||||||
val json =
|
|
||||||
stream.bufferedReader(
|
|
||||||
Charsets.UTF_8
|
|
||||||
)
|
|
||||||
.use {
|
|
||||||
it.readText()
|
|
||||||
}
|
|
||||||
|
|
||||||
val definitionsError = importPcEquipmentDefinitions(directory)
|
val definitionsError = importPcEquipmentDefinitions(directory)
|
||||||
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
|
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
|
||||||
val aliasesError = importExerciseAliases(directory)
|
val profileFile = directory.findDirectFile("trainlog-exercise-profile-state-v1.json")
|
||||||
if (aliasesError != null) return CatalogInboxResult.Error(aliasesError)
|
val profileJson = profileFile?.readText(Charsets.UTF_8)
|
||||||
val profileFile = directory.findFile("trainlog-exercise-profile-state-v1.json")
|
|
||||||
val profileJson = profileFile?.let { candidate ->
|
|
||||||
appContext.contentResolver.openInputStream(candidate.uri)
|
|
||||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
|
||||||
}
|
|
||||||
if (profileFile != null && profileJson == null)
|
|
||||||
return CatalogInboxResult.Error("Lecture profile-state impossible.")
|
|
||||||
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson,allowPending=true)) {
|
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson,allowPending=true)) {
|
||||||
is ExerciseProfileStateImportResult.Applied -> Unit
|
is ExerciseProfileStateImportResult.Applied -> Unit
|
||||||
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
|
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
|
||||||
|
|
@ -155,6 +91,12 @@ class SyncCatalogInbox(
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
is PcCatalogImportResult.Applied -> {
|
is PcCatalogImportResult.Applied -> {
|
||||||
|
/* WHY: a fresh Android database cannot validate an alias
|
||||||
|
* target until the catalog has created that canonical
|
||||||
|
* exercise. CONTRACT: aliases still precede session import
|
||||||
|
* so retired creator IDs resolve during the same sync. */
|
||||||
|
val aliasesError = importExerciseAliases(directory)
|
||||||
|
if (aliasesError != null) return CatalogInboxResult.Error(aliasesError)
|
||||||
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson)) {
|
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson)) {
|
||||||
is ExerciseProfileStateImportResult.Applied -> Unit
|
is ExerciseProfileStateImportResult.Applied -> Unit
|
||||||
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
|
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
|
||||||
|
|
@ -163,23 +105,16 @@ class SyncCatalogInbox(
|
||||||
}
|
}
|
||||||
/* Catalogue identities now exist and the strict second pass
|
/* Catalogue identities now exist and the strict second pass
|
||||||
* has either installed or verified their causal state. */
|
* has either installed or verified their causal state. */
|
||||||
when (val sessions = importPcSessions(directory)) {
|
importPcSessions(directory)?.let { return CatalogInboxResult.Error(it) }
|
||||||
null -> when (val equipment = importPcEquipmentAssociations(directory)) {
|
importPcEquipmentAssociations(directory)?.let { return CatalogInboxResult.Error(it) }
|
||||||
null -> when (val bodyZones = importPcBodyZones(directory)) {
|
importAiSessionDrafts(directory)?.let { return CatalogInboxResult.Error(it) }
|
||||||
null -> when (val feedback = importTrainingFeedback(directory)) {
|
importPcBodyZones(directory)?.let { return CatalogInboxResult.Error(it) }
|
||||||
null -> CatalogInboxResult.Imported(
|
importTrainingFeedback(directory)?.let { return CatalogInboxResult.Error(it) }
|
||||||
imported = result.imported,
|
CatalogInboxResult.Imported(
|
||||||
reconciled = result.reconciled,
|
imported = result.imported,
|
||||||
skipped = result.skipped,
|
reconciled = result.reconciled,
|
||||||
)
|
skipped = result.skipped,
|
||||||
else -> CatalogInboxResult.Error(feedback)
|
)
|
||||||
}
|
|
||||||
else -> CatalogInboxResult.Error(bodyZones)
|
|
||||||
}
|
|
||||||
else -> CatalogInboxResult.Error(equipment)
|
|
||||||
}
|
|
||||||
else -> CatalogInboxResult.Error(sessions)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is PcCatalogImportResult.Invalid ->
|
is PcCatalogImportResult.Invalid ->
|
||||||
|
|
@ -202,15 +137,13 @@ class SyncCatalogInbox(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importPcSessions(directory: DocumentFile): String? {
|
private fun importPcSessions(directory: File): String? {
|
||||||
/* CONTRACT: a present V3 artifact is authoritative. Invalid V3 must
|
/* CONTRACT: a present V3 artifact is authoritative. Invalid V3 must
|
||||||
* surface its error and never fall back to a stale V2 snapshot. */
|
* surface its error and never fall back to a stale V2 snapshot. */
|
||||||
val v3 = directory.findFile("trainlog-pc-mobile-export-v3.json")
|
val v3 = directory.findDirectFile("trainlog-pc-mobile-export-v3.json")
|
||||||
val file = v3 ?: directory.findFile("trainlog-pc-mobile-export-v2.json") ?: return null
|
val file = v3 ?: directory.findDirectFile("trainlog-pc-mobile-export-v2.json") ?: return null
|
||||||
return try {
|
return try {
|
||||||
val json = appContext.contentResolver.openInputStream(file.uri)
|
val json = file.readText(Charsets.UTF_8)
|
||||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
|
||||||
?: return "Lecture snapshot séances impossible."
|
|
||||||
val result = if (v3 != null) repository.applyPcMobileExportV3Json(json)
|
val result = if (v3 != null) repository.applyPcMobileExportV3Json(json)
|
||||||
else repository.applyPcMobileExportV2Json(json)
|
else repository.applyPcMobileExportV2Json(json)
|
||||||
when (result) {
|
when (result) {
|
||||||
|
|
@ -221,14 +154,13 @@ class SyncCatalogInbox(
|
||||||
} catch (error: Exception) { error.message ?: "Import séances impossible." }
|
} catch (error: Exception) { error.message ?: "Import séances impossible." }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importTrainingFeedback(directory: DocumentFile): String? {
|
private fun importTrainingFeedback(directory: File): String? {
|
||||||
/* V2 is authoritative for revision history. V1 remains a fallback for
|
/* V2 is authoritative for revision history. V1 remains a fallback for
|
||||||
* an older peer and can only add deterministic initial revisions. */
|
* an older peer and can only add deterministic initial revisions. */
|
||||||
val file = directory.findFile("trainlog-training-feedback-v2.json")
|
val file = directory.findDirectFile("trainlog-training-feedback-v2.json")
|
||||||
?: directory.findFile("trainlog-training-feedback-v1.json") ?: return null
|
?: directory.findDirectFile("trainlog-training-feedback-v1.json") ?: return null
|
||||||
return try {
|
return try {
|
||||||
val json = appContext.contentResolver.openInputStream(file.uri)?.bufferedReader(Charsets.UTF_8)
|
val json = file.readText(Charsets.UTF_8)
|
||||||
?.use { it.readText() } ?: return "Lecture des ressentis impossible."
|
|
||||||
when (val result = repository.applyTrainingFeedbackJson(json)) {
|
when (val result = repository.applyTrainingFeedbackJson(json)) {
|
||||||
is TrainingFeedbackImportResult.Applied -> null
|
is TrainingFeedbackImportResult.Applied -> null
|
||||||
is TrainingFeedbackImportResult.Invalid -> result.message
|
is TrainingFeedbackImportResult.Invalid -> result.message
|
||||||
|
|
@ -239,15 +171,31 @@ class SyncCatalogInbox(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test seam for the real filename-priority boundary; production uses the same method. */
|
/** Test seam for the real filename-priority boundary; production uses the same method. */
|
||||||
internal fun importPcSessionsFromDirectoryForTest(directory: DocumentFile): String? =
|
internal fun importPcSessionsFromDirectoryForTest(directory: File): String? =
|
||||||
importPcSessions(directory)
|
importPcSessions(directory)
|
||||||
|
|
||||||
private fun importPcBodyZones(directory: DocumentFile): String? {
|
private fun importAiSessionDrafts(directory: File): String? {
|
||||||
val file = directory.findFile("trainlog-exercise-body-zones-v1.json") ?: return null
|
val file = directory.findDirectFile("trainlog-ai-session-drafts-v1.json") ?: return null
|
||||||
return try {
|
return try {
|
||||||
val json = appContext.contentResolver.openInputStream(file.uri)
|
val json = file.readText(Charsets.UTF_8)
|
||||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
when (val result = repository.applyAiSessionDraftsJson(json)) {
|
||||||
?: return "Lecture zones corporelles impossible."
|
is AiSessionDraftImportResult.Applied -> null
|
||||||
|
is AiSessionDraftImportResult.Invalid -> result.message
|
||||||
|
AiSessionDraftImportResult.DatabaseError -> "Erreur base locale brouillons IA."
|
||||||
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
error.message ?: "Import des brouillons IA impossible."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam for the production one-sync prerequisite ordering. */
|
||||||
|
internal fun importAiSessionDraftsFromDirectoryForTest(directory: File): String? =
|
||||||
|
importAiSessionDrafts(directory)
|
||||||
|
|
||||||
|
private fun importPcBodyZones(directory: File): String? {
|
||||||
|
val file = directory.findDirectFile("trainlog-exercise-body-zones-v1.json") ?: return null
|
||||||
|
return try {
|
||||||
|
val json = file.readText(Charsets.UTF_8)
|
||||||
when (val result = repository.applyExerciseBodyZonesJson(json)) {
|
when (val result = repository.applyExerciseBodyZonesJson(json)) {
|
||||||
is ExerciseBodyZoneImportResult.Applied -> null
|
is ExerciseBodyZoneImportResult.Applied -> null
|
||||||
is ExerciseBodyZoneImportResult.Invalid -> result.message
|
is ExerciseBodyZoneImportResult.Invalid -> result.message
|
||||||
|
|
@ -261,12 +209,10 @@ class SyncCatalogInbox(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importExerciseAliases(directory: DocumentFile): String? {
|
private fun importExerciseAliases(directory: File): String? {
|
||||||
val file = directory.findFile("trainlog-exercise-aliases-v1.json") ?: return null
|
val file = directory.findDirectFile("trainlog-exercise-aliases-v1.json") ?: return null
|
||||||
return try {
|
return try {
|
||||||
val json = appContext.contentResolver.openInputStream(file.uri)
|
val json = file.readText(Charsets.UTF_8)
|
||||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
|
||||||
?: return "Lecture des alias exercice impossible."
|
|
||||||
when (val result = repository.applyExerciseAliasesJson(json)) {
|
when (val result = repository.applyExerciseAliasesJson(json)) {
|
||||||
is ExerciseAliasImportResult.Applied -> null
|
is ExerciseAliasImportResult.Applied -> null
|
||||||
is ExerciseAliasImportResult.Invalid -> result.message
|
is ExerciseAliasImportResult.Invalid -> result.message
|
||||||
|
|
@ -280,12 +226,10 @@ class SyncCatalogInbox(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importPcEquipmentDefinitions(directory: DocumentFile): String? {
|
private fun importPcEquipmentDefinitions(directory: File): String? {
|
||||||
val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null
|
val file = directory.findDirectFile("trainlog-pc-equipment-definitions-v1.json") ?: return null
|
||||||
return try {
|
return try {
|
||||||
val json = appContext.contentResolver.openInputStream(file.uri)
|
val json = file.readText(Charsets.UTF_8)
|
||||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
|
||||||
?: return "Lecture définitions équipement impossible."
|
|
||||||
when (val result = repository.applyPcEquipmentDefinitionsJson(json)) {
|
when (val result = repository.applyPcEquipmentDefinitionsJson(json)) {
|
||||||
is EquipmentDefinitionImportResult.Applied -> null
|
is EquipmentDefinitionImportResult.Applied -> null
|
||||||
is EquipmentDefinitionImportResult.Invalid -> result.message
|
is EquipmentDefinitionImportResult.Invalid -> result.message
|
||||||
|
|
@ -294,14 +238,12 @@ class SyncCatalogInbox(
|
||||||
} catch (error: Exception) { error.message ?: "Import définitions équipement impossible." }
|
} catch (error: Exception) { error.message ?: "Import définitions équipement impossible." }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importPcEquipmentAssociations(directory: DocumentFile): String? {
|
private fun importPcEquipmentAssociations(directory: File): String? {
|
||||||
val file = directory.findFile("trainlog-equipment-associations-v2.json")
|
val file = directory.findDirectFile("trainlog-equipment-associations-v2.json")
|
||||||
?: directory.findFile("trainlog-equipment-associations-v1.json")
|
?: directory.findDirectFile("trainlog-equipment-associations-v1.json")
|
||||||
?: return null /* Historic PC sync: absence means no information. */
|
?: return null /* Historic PC sync: absence means no information. */
|
||||||
return try {
|
return try {
|
||||||
val json = appContext.contentResolver.openInputStream(file.uri)
|
val json = file.readText(Charsets.UTF_8)
|
||||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
|
||||||
?: return "Lecture extension équipement impossible."
|
|
||||||
when (val result = repository.applyPcEquipmentAssociationsJson(json)) {
|
when (val result = repository.applyPcEquipmentAssociationsJson(json)) {
|
||||||
is EquipmentAssociationImportResult.Applied -> null
|
is EquipmentAssociationImportResult.Applied -> null
|
||||||
is EquipmentAssociationImportResult.Invalid -> result.message
|
is EquipmentAssociationImportResult.Invalid -> result.message
|
||||||
|
|
@ -315,45 +257,15 @@ class SyncCatalogInbox(
|
||||||
fun readSyncReceipt(
|
fun readSyncReceipt(
|
||||||
requestId: String,
|
requestId: String,
|
||||||
): SyncReceiptResult {
|
): SyncReceiptResult {
|
||||||
val treeUri =
|
val directory = directExchangeDirectory()
|
||||||
savedTreeUri()
|
?: return SyncReceiptResult.FolderNotAuthorized
|
||||||
?: return SyncReceiptResult
|
|
||||||
.FolderNotAuthorized
|
|
||||||
|
|
||||||
val directory =
|
|
||||||
DocumentFile
|
|
||||||
.fromTreeUri(
|
|
||||||
appContext,
|
|
||||||
treeUri,
|
|
||||||
)
|
|
||||||
?: return SyncReceiptResult.Error(
|
|
||||||
"Dossier Trainlog inaccessible."
|
|
||||||
)
|
|
||||||
|
|
||||||
val file =
|
val file =
|
||||||
directory.findFile(
|
directory.findDirectFile("trainlog-sync-receipt-v1.json")
|
||||||
"trainlog-sync-receipt-v1.json"
|
|
||||||
)
|
|
||||||
?: return SyncReceiptResult.Pending
|
?: return SyncReceiptResult.Pending
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val stream =
|
val json = file.readText(Charsets.UTF_8)
|
||||||
appContext
|
|
||||||
.contentResolver
|
|
||||||
.openInputStream(
|
|
||||||
file.uri
|
|
||||||
)
|
|
||||||
?: return SyncReceiptResult.Error(
|
|
||||||
"Lecture du reçu impossible."
|
|
||||||
)
|
|
||||||
|
|
||||||
val json =
|
|
||||||
stream.bufferedReader(
|
|
||||||
Charsets.UTF_8
|
|
||||||
)
|
|
||||||
.use {
|
|
||||||
it.readText()
|
|
||||||
}
|
|
||||||
|
|
||||||
val root =
|
val root =
|
||||||
JSONObject(json)
|
JSONObject(json)
|
||||||
|
|
@ -418,14 +330,17 @@ fun readSyncReceipt(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun savedTreeUri(): Uri? =
|
private fun directExchangeDirectory(): File? {
|
||||||
preferences
|
clearLegacyExchangeTreePreference(appContext)
|
||||||
.getString(
|
if (!hasDirectExchangePermission()) return null
|
||||||
SYNC_TREE_URI_KEY,
|
val directory = canonicalExchangeDirectory()
|
||||||
null,
|
if (!directory.exists() && !directory.mkdirs()) return null
|
||||||
)
|
return directory.takeIf { it.isDirectory && it.canRead() && it.canWrite() }
|
||||||
?.let {
|
}
|
||||||
Uri.parse(it)
|
|
||||||
}
|
private fun File.findDirectFile(name: String): File? {
|
||||||
|
require('/' !in name && '\\' !in name)
|
||||||
|
return File(this, name).takeIf(File::isFile)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,31 +22,31 @@ sealed interface SyncExportResult {
|
||||||
class SyncExporter private constructor(
|
class SyncExporter private constructor(
|
||||||
private val repository:
|
private val repository:
|
||||||
TrainlogRepository,
|
TrainlogRepository,
|
||||||
private val safPublisher: ExchangeSafPublisher,
|
private val publisher: DirectExchangePublisher,
|
||||||
) {
|
) {
|
||||||
constructor(context: Context, repository: TrainlogRepository) : this(
|
constructor(context: Context, repository: TrainlogRepository) : this(
|
||||||
repository,
|
repository,
|
||||||
ExchangeSafPublisher { persistedExchangeSafDirectory(context) },
|
DirectExchangePublisher { directExchangeDirectory(context) },
|
||||||
)
|
)
|
||||||
|
|
||||||
internal constructor(
|
internal constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
repository: TrainlogRepository,
|
repository: TrainlogRepository,
|
||||||
directoryProvider: () -> ExchangeSafDirectory?,
|
directoryProvider: () -> DirectExchangeDirectoryAccess?,
|
||||||
) : this(repository, ExchangeSafPublisher(directoryProvider))
|
) : this(repository, DirectExchangePublisher(directoryProvider))
|
||||||
|
|
||||||
/* CONTRACT: repository snapshot construction and every SAF operation are
|
/* CONTRACT: repository snapshot construction and every filesystem operation are
|
||||||
* blocking I/O work and must never execute on the Compose/main thread. */
|
* blocking I/O work and must never execute on the Compose/main thread. */
|
||||||
suspend fun exportMobileBundle(): SyncExportResult = withContext(Dispatchers.IO) {
|
suspend fun exportMobileBundle(): SyncExportResult = withContext(Dispatchers.IO) {
|
||||||
when (val opened = openSafSnapshot()) {
|
when (val opened = openStorageSnapshot()) {
|
||||||
is ExchangeSafSnapshotResult.Ready -> exportMobileBundle(opened.snapshot)
|
is DirectExchangeSnapshotResult.Ready -> exportMobileBundle(opened.snapshot)
|
||||||
is ExchangeSafSnapshotResult.Error -> SyncExportResult.Error(opened.message)
|
is DirectExchangeSnapshotResult.Error -> SyncExportResult.Error(opened.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun openSafSnapshot(): ExchangeSafSnapshotResult = safPublisher.snapshot()
|
internal fun openStorageSnapshot(): DirectExchangeSnapshotResult = publisher.snapshot()
|
||||||
|
|
||||||
internal fun exportMobileBundle(snapshot: ExchangeSafSnapshot): SyncExportResult {
|
internal fun exportMobileBundle(snapshot: DirectExchangeSnapshot): SyncExportResult {
|
||||||
if (
|
if (
|
||||||
Build.VERSION.SDK_INT <
|
Build.VERSION.SDK_INT <
|
||||||
Build.VERSION_CODES.Q
|
Build.VERSION_CODES.Q
|
||||||
|
|
@ -89,9 +89,8 @@ class SyncExporter private constructor(
|
||||||
"trainlog-training-feedback-v2.json" to feedbackJson,
|
"trainlog-training-feedback-v2.json" to feedbackJson,
|
||||||
"trainlog-exercise-profile-state-v1.json" to profileStateJson,
|
"trainlog-exercise-profile-state-v1.json" to profileStateJson,
|
||||||
)
|
)
|
||||||
/* CONTRACT: the persisted user-authorized SAF tree is the sole
|
/* CONTRACT: the fixed direct-storage directory is the sole outbound
|
||||||
* outbound directory authority. MediaStore ownership must neither hide
|
* authority. Same-directory atomic replacement preserves exact names. */
|
||||||
* a canonical document nor cause creation of a numbered conflict. */
|
|
||||||
artifacts.forEach { (displayName, json) ->
|
artifacts.forEach { (displayName, json) ->
|
||||||
snapshot.writeJson(displayName, json)?.let { error ->
|
snapshot.writeJson(displayName, json)?.let { error ->
|
||||||
return SyncExportResult.Error(error)
|
return SyncExportResult.Error(error)
|
||||||
|
|
@ -120,7 +119,7 @@ class SyncExporter private constructor(
|
||||||
associationsJson,
|
associationsJson,
|
||||||
)?.let { return SyncExportResult.Error(it) }
|
)?.let { return SyncExportResult.Error(it) }
|
||||||
return SyncExportResult.Exported(
|
return SyncExportResult.Exported(
|
||||||
displayPath = "Download/Trainlog/trainlog-mobile-export-v3.json",
|
displayPath = "Documents/Trainlog/trainlog-mobile-export-v3.json",
|
||||||
bytes = mobileJson.toByteArray(Charsets.UTF_8).size,
|
bytes = mobileJson.toByteArray(Charsets.UTF_8).size,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,24 +22,24 @@ sealed interface SyncRequestResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
class SyncRequestOutbox private constructor(
|
class SyncRequestOutbox private constructor(
|
||||||
private val safPublisher: ExchangeSafPublisher,
|
private val publisher: DirectExchangePublisher,
|
||||||
) {
|
) {
|
||||||
constructor(context: Context) : this(
|
constructor(context: Context) : this(
|
||||||
ExchangeSafPublisher { persistedExchangeSafDirectory(context) },
|
DirectExchangePublisher { directExchangeDirectory(context) },
|
||||||
)
|
)
|
||||||
|
|
||||||
internal constructor(directoryProvider: () -> ExchangeSafDirectory?) : this(
|
internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this(
|
||||||
ExchangeSafPublisher(directoryProvider),
|
DirectExchangePublisher(directoryProvider),
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
|
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||||
when (val opened = safPublisher.snapshot()) {
|
when (val opened = publisher.snapshot()) {
|
||||||
is ExchangeSafSnapshotResult.Ready -> requestSync(opened.snapshot)
|
is DirectExchangeSnapshotResult.Ready -> requestSync(opened.snapshot)
|
||||||
is ExchangeSafSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
|
is DirectExchangeSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun requestSync(snapshot: ExchangeSafSnapshot): SyncRequestResult {
|
internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult {
|
||||||
if (
|
if (
|
||||||
Build.VERSION.SDK_INT <
|
Build.VERSION.SDK_INT <
|
||||||
Build.VERSION_CODES.Q
|
Build.VERSION_CODES.Q
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import android.database.sqlite.SQLiteOpenHelper
|
||||||
import android.util.JsonReader
|
import android.util.JsonReader
|
||||||
import android.util.JsonToken
|
import android.util.JsonToken
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
|
import com.labfytools.trainlog.model.AiSessionDraft
|
||||||
import com.labfytools.trainlog.model.BodyObservationDraft
|
import com.labfytools.trainlog.model.BodyObservationDraft
|
||||||
import com.labfytools.trainlog.model.BodyObservationSummary
|
import com.labfytools.trainlog.model.BodyObservationSummary
|
||||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||||
|
|
@ -275,6 +276,19 @@ sealed interface ActiveDraftMutationResult {
|
||||||
) : ActiveDraftMutationResult
|
) : ActiveDraftMutationResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed interface AiSessionDraftImportResult {
|
||||||
|
data class Applied(val imported: Int, val skipped: Int) : AiSessionDraftImportResult
|
||||||
|
data class Invalid(val message: String) : AiSessionDraftImportResult
|
||||||
|
data object DatabaseError : AiSessionDraftImportResult
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface StartAiSessionDraftResult {
|
||||||
|
data object Started : StartAiSessionDraftResult
|
||||||
|
data object ExistingActiveDraft : StartAiSessionDraftResult
|
||||||
|
data object NotPending : StartAiSessionDraftResult
|
||||||
|
data class Error(val message: String) : StartAiSessionDraftResult
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface FinalizeActiveDraftResult {
|
sealed interface FinalizeActiveDraftResult {
|
||||||
data class Saved(
|
data class Saved(
|
||||||
val sessionId: String,
|
val sessionId: String,
|
||||||
|
|
@ -1318,7 +1332,20 @@ class TrainlogRepository(
|
||||||
if(!c.moveToFirst()) null else (0..12).map{if(c.isNull(it)) null else c.getString(it)}}
|
if(!c.moveToFirst()) null else (0..12).map{if(c.isNull(it)) null else c.getString(it)}}
|
||||||
if(row==null){if(allowPending){skipped++;continue};return ExerciseProfileStateImportResult.Invalid("Identité exercice inconnue : $id")}
|
if(row==null){if(allowPending){skipped++;continue};return ExerciseProfileStateImportResult.Invalid("Identité exercice inconnue : $id")}
|
||||||
val incomingAdj=arrayOf("load_semantics","machine_variant","machine_provenance","scientific_profile_id","science_state","legacy_equipment_id").map{key->if(x.value(key)===JSONObject.NULL)null else x.value(key) as String}
|
val incomingAdj=arrayOf("load_semantics","machine_variant","machine_provenance","scientific_profile_id","science_state","legacy_equipment_id").map{key->if(x.value(key)===JSONObject.NULL)null else x.value(key) as String}
|
||||||
if((4..9).map{row[it]}!=incomingAdj)return ExerciseProfileStateImportResult.Conflict(id)
|
val localAdj=(4..9).map{row[it]}
|
||||||
|
/* WHY: the V1 catalog intentionally omits machine/science
|
||||||
|
* identity. A fresh peer therefore creates a completely
|
||||||
|
* unresolved row before this companion can establish it.
|
||||||
|
* CONTRACT: only that all-null/unresolved bootstrap may adopt
|
||||||
|
* PC identity metadata. science_state may remain unresolved;
|
||||||
|
* any row carrying identity metadata is thereafter immutable. */
|
||||||
|
val adoptsInitialIdentity = localAdj != incomingAdj &&
|
||||||
|
localAdj == listOf<String?>(null,null,null,null,"unresolved",null)
|
||||||
|
if(localAdj!=incomingAdj && !adoptsInitialIdentity)return ExerciseProfileStateImportResult.Conflict(id)
|
||||||
|
if(adoptsInitialIdentity) db.execSQL(
|
||||||
|
"UPDATE exercises SET load_semantics=?,machine_variant=?,machine_provenance=?,scientific_profile_id=?,science_state=?,legacy_equipment_id=? WHERE id=?",
|
||||||
|
arrayOf<Any?>(*incomingAdj.toTypedArray(), row[0]!!.toLong()),
|
||||||
|
)
|
||||||
val localRevision=row[10]!!;val localParent=row[11];val localLegacy=row[12]=="1"
|
val localRevision=row[10]!!;val localParent=row[11];val localLegacy=row[12]=="1"
|
||||||
val sameProfile=row[1]==recording&&row[2]==tracking&&row[3]?.toInt()==fields
|
val sameProfile=row[1]==recording&&row[2]==tracking&&row[3]?.toInt()==fields
|
||||||
val accept = when {
|
val accept = when {
|
||||||
|
|
@ -1328,7 +1355,7 @@ class TrainlogRepository(
|
||||||
db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revision)).use{it.moveToFirst()} -> false
|
db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revision)).use{it.moveToFirst()} -> false
|
||||||
else -> return ExerciseProfileStateImportResult.Conflict(id)
|
else -> return ExerciseProfileStateImportResult.Conflict(id)
|
||||||
}
|
}
|
||||||
if(!accept){skipped++;continue}
|
if(!accept){if(adoptsInitialIdentity)updated++ else skipped++;continue}
|
||||||
val knownCount=db.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",arrayOf(row[0])).use{it.moveToFirst();it.getInt(0)}
|
val knownCount=db.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",arrayOf(row[0])).use{it.moveToFirst();it.getInt(0)}
|
||||||
val additions=revisions.count { revisionRow -> db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revisionRow[0] as String)).use{!it.moveToFirst()} }
|
val additions=revisions.count { revisionRow -> db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revisionRow[0] as String)).use{!it.moveToFirst()} }
|
||||||
if(knownCount+additions>PROFILE_HISTORY_MAX)return ExerciseProfileStateImportResult.Invalid("Historique profile-state hors limite : $id")
|
if(knownCount+additions>PROFILE_HISTORY_MAX)return ExerciseProfileStateImportResult.Invalid("Historique profile-state hors limite : $id")
|
||||||
|
|
@ -1572,6 +1599,248 @@ class TrainlogRepository(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports inert desktop proposals only after catalog/profile/equipment
|
||||||
|
* prerequisites have been reconciled by [SyncCatalogInbox].
|
||||||
|
*/
|
||||||
|
fun applyAiSessionDraftsJson(json: String): AiSessionDraftImportResult {
|
||||||
|
if (!hasStrictJsonShape(json))
|
||||||
|
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA JSON invalide.")
|
||||||
|
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||||
|
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA JSON invalide.")
|
||||||
|
}
|
||||||
|
if (!root.hasExactKeys(setOf("format", "version", "generated_at", "drafts")) ||
|
||||||
|
root.value("format") != "trainlog-ai-session-drafts" ||
|
||||||
|
!root.value("version").isExactJsonInteger(1) ||
|
||||||
|
!root.value("generated_at").isNonemptyJsonString() || root.value("drafts") !is JSONArray ||
|
||||||
|
!isUtcTimestamp(root.optString("generated_at"))) {
|
||||||
|
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA v1 non supporté.")
|
||||||
|
}
|
||||||
|
val drafts = root.getJSONArray("drafts")
|
||||||
|
if (drafts.length() > MAX_AI_DRAFTS)
|
||||||
|
return AiSessionDraftImportResult.Invalid("Trop de brouillons IA.")
|
||||||
|
|
||||||
|
val db = database.writableDatabase
|
||||||
|
var imported = 0
|
||||||
|
var skipped = 0
|
||||||
|
val seenDrafts = mutableSetOf<String>()
|
||||||
|
val seenEntries = mutableSetOf<String>()
|
||||||
|
return try {
|
||||||
|
db.beginTransaction()
|
||||||
|
for (draftIndex in 0 until drafts.length()) {
|
||||||
|
val item = drafts.opt(draftIndex) as? JSONObject
|
||||||
|
?: throw IllegalArgumentException("Brouillon IA invalide.")
|
||||||
|
val draftKeys = setOf("draft_id", "created_at", "planned_for", "session_type", "title", "notes", "entries")
|
||||||
|
if (!item.hasExactKeys(draftKeys))
|
||||||
|
throw IllegalArgumentException("Champs de brouillon IA invalides.")
|
||||||
|
val draftId = item.value("draft_id") as? String
|
||||||
|
?: throw IllegalArgumentException("draft_id invalide.")
|
||||||
|
val createdAt = item.value("created_at") as? String
|
||||||
|
?: throw IllegalArgumentException("created_at invalide.")
|
||||||
|
val entries = item.value("entries") as? JSONArray
|
||||||
|
?: throw IllegalArgumentException("Entrées de brouillon IA invalides.")
|
||||||
|
if (!draftId.matches(AI_DRAFT_ID_V4_PATTERN) || !seenDrafts.add(draftId) ||
|
||||||
|
!isUtcTimestamp(createdAt) || item.value("session_type") != "training" ||
|
||||||
|
entries.length() !in 1..MAX_AI_DRAFT_ENTRIES)
|
||||||
|
throw IllegalArgumentException("En-tête de brouillon IA invalide.")
|
||||||
|
val plannedFor = optionalJsonString(item, "planned_for", 10)
|
||||||
|
if (plannedFor != null && runCatching { LocalDate.parse(plannedFor) }.getOrNull()?.toString() != plannedFor)
|
||||||
|
throw IllegalArgumentException("planned_for invalide.")
|
||||||
|
val title = optionalJsonString(item, "title", 120)
|
||||||
|
val notes = optionalJsonString(item, "notes", 2000)
|
||||||
|
|
||||||
|
val priorState = db.rawQuery(
|
||||||
|
"SELECT state FROM ai_session_drafts WHERE draft_id=?;", arrayOf(draftId),
|
||||||
|
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||||
|
if (priorState == null) {
|
||||||
|
db.insertOrThrow("ai_session_drafts", null, ContentValues().apply {
|
||||||
|
put("draft_id", draftId); put("created_at", createdAt)
|
||||||
|
putOptionalString("planned_for", plannedFor); putOptionalString("title", title)
|
||||||
|
putOptionalString("notes", notes); put("state", "pending")
|
||||||
|
put("state_changed_at", OffsetDateTime.now(java.time.ZoneOffset.UTC).toString())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (entryIndex in 0 until entries.length()) {
|
||||||
|
val entry = entries.opt(entryIndex) as? JSONObject
|
||||||
|
?: throw IllegalArgumentException("Entrée de brouillon IA invalide.")
|
||||||
|
val base = setOf("entry_id", "position", "exercise_id", "recording_mode",
|
||||||
|
"tracking_mode", "data_fields", "equipment_id", "load_mode", "rest_seconds", "target")
|
||||||
|
if (!entry.hasExactKeys(base))
|
||||||
|
throw IllegalArgumentException("Champs d'entrée IA invalides.")
|
||||||
|
val entryId = entry.value("entry_id") as? String
|
||||||
|
?: throw IllegalArgumentException("entry_id invalide.")
|
||||||
|
val exerciseId = entry.value("exercise_id") as? String
|
||||||
|
?: throw IllegalArgumentException("exercise_id invalide.")
|
||||||
|
if (!entryId.matches(SESSION_ENTRY_ID_V4_PATTERN) || !seenEntries.add(entryId) ||
|
||||||
|
!entry.value("position").isExactJsonInteger(entryIndex) ||
|
||||||
|
!exerciseId.matches(EXERCISE_ID_V4_PATTERN) ||
|
||||||
|
entry.value("recording_mode") != "sets" ||
|
||||||
|
!entry.value("data_fields").isExactJsonInteger() ||
|
||||||
|
entry.value("tracking_mode") !in setOf("reps", "duration"))
|
||||||
|
throw IllegalArgumentException("Profil d'entrée IA invalide.")
|
||||||
|
val dataFields = (entry.value("data_fields") as Number).toInt()
|
||||||
|
if (dataFields !in 0..3)
|
||||||
|
throw IllegalArgumentException("data_fields IA invalide : $exerciseId")
|
||||||
|
val canonicalId = resolveExerciseId(db, exerciseId)
|
||||||
|
val exercise = findExerciseRow(db, "exercise_id=?", arrayOf(canonicalId))
|
||||||
|
?: throw IllegalArgumentException("Exercice IA inconnu : $exerciseId")
|
||||||
|
if (exercise.recordingMode != RecordingMode.SETS ||
|
||||||
|
exercise.trackingMode.wireValue != entry.optString("tracking_mode") || exercise.dataFields != dataFields)
|
||||||
|
throw IllegalArgumentException("Profil IA divergent : $exerciseId")
|
||||||
|
val equipmentId = optionalJsonString(entry, "equipment_id", 512)
|
||||||
|
val equipmentRowId = lookupEquipmentRowIdOrNull(db, equipmentId)
|
||||||
|
if (equipmentId != null && equipmentRowId == null)
|
||||||
|
throw IllegalArgumentException("Équipement IA inconnu : $equipmentId")
|
||||||
|
val loadMode = entry.value("load_mode") as? String
|
||||||
|
?: throw IllegalArgumentException("load_mode invalide.")
|
||||||
|
val rest = entry.value("rest_seconds")
|
||||||
|
if (loadMode !in setOf("none", "external") || !rest.isExactJsonInteger() ||
|
||||||
|
(rest as Number).toInt() !in 0..MAX_PLAN_REST_SECONDS)
|
||||||
|
throw IllegalArgumentException("Plan IA invalide.")
|
||||||
|
val target = entry.value("target") as? JSONObject
|
||||||
|
?: throw IllegalArgumentException("Cible IA manquante.")
|
||||||
|
val metric = if (exercise.trackingMode == TrackingMode.REPS) "reps" else "duration_seconds"
|
||||||
|
val targetKeys = setOf("sets", "reps", "duration_seconds", "weight_kg")
|
||||||
|
if (!target.hasExactKeys(targetKeys) ||
|
||||||
|
!target.value("sets").isExactJsonInteger() ||
|
||||||
|
(target.value("sets") as Number).toInt() !in 1..MAX_AI_TARGET_SETS ||
|
||||||
|
!target.value(metric).isExactJsonInteger() ||
|
||||||
|
(target.value(metric) as Number).toInt() !in 1..(if (metric == "reps") MAX_AI_TARGET_REPS else MAX_PLAN_DURATION_SECONDS) ||
|
||||||
|
!target.isNull(if (metric == "reps") "duration_seconds" else "reps"))
|
||||||
|
throw IllegalArgumentException("Cible IA invalide.")
|
||||||
|
val weight = if (target.isNull("weight_kg")) null else target.value("weight_kg")
|
||||||
|
if (weight != null && (!weight.isPositiveJsonNumber() || (weight as Number).toDouble() > 10000.0))
|
||||||
|
throw IllegalArgumentException("Charge cible IA invalide.")
|
||||||
|
if ((weight == null && loadMode != "none") ||
|
||||||
|
(weight != null && loadMode != "external"))
|
||||||
|
throw IllegalArgumentException("Mode de charge IA incohérent.")
|
||||||
|
if (priorState == null) {
|
||||||
|
db.insertOrThrow("ai_session_draft_entries", null, ContentValues().apply {
|
||||||
|
put("draft_id", draftId); put("entry_id", entryId); put("position", entryIndex)
|
||||||
|
put("exercise_row_id", exercise.rowId); put("recording_mode", "sets")
|
||||||
|
put("tracking_mode", exercise.trackingMode.wireValue); put("data_fields", dataFields)
|
||||||
|
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
|
||||||
|
put("load_mode", loadMode); put("rest_seconds", rest.toInt())
|
||||||
|
put("target_sets", (target.value("sets") as Number).toInt())
|
||||||
|
if (metric == "reps") { put("target_reps", (target.value(metric) as Number).toInt()); putNull("target_duration_seconds") }
|
||||||
|
else { putNull("target_reps"); put("target_duration_seconds", (target.value(metric) as Number).toInt()) }
|
||||||
|
if (weight == null) putNull("target_weight_kg") else put("target_weight_kg", weight.toDouble())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (priorState == null) imported++ else {
|
||||||
|
/* CONTRACT: validate the complete companion before replay
|
||||||
|
* suppression. INVARIANT: started/deleted identities remain
|
||||||
|
* permanent tombstones and never recreate their children. */
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.setTransactionSuccessful()
|
||||||
|
AiSessionDraftImportResult.Applied(imported, skipped)
|
||||||
|
} catch (error: IllegalArgumentException) {
|
||||||
|
AiSessionDraftImportResult.Invalid(error.message ?: "Brouillon IA invalide.")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
AiSessionDraftImportResult.DatabaseError
|
||||||
|
} finally {
|
||||||
|
if (db.inTransaction()) db.endTransaction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun listAiSessionDrafts(): List<AiSessionDraft> {
|
||||||
|
val db = database.readableDatabase
|
||||||
|
val output = mutableListOf<AiSessionDraft>()
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT draft_id,created_at,planned_for,title,notes FROM ai_session_drafts " +
|
||||||
|
"WHERE state='pending' ORDER BY COALESCE(planned_for,'9999-12-31'),created_at,draft_id;", null,
|
||||||
|
).use { drafts -> while (drafts.moveToNext()) {
|
||||||
|
val draftId = drafts.getString(0)
|
||||||
|
output += AiSessionDraft(draftId, drafts.getString(1),
|
||||||
|
if (drafts.isNull(2)) null else drafts.getString(2),
|
||||||
|
if (drafts.isNull(3)) null else drafts.getString(3),
|
||||||
|
if (drafts.isNull(4)) null else drafts.getString(4),
|
||||||
|
readAiSessionDraftEntries(db, draftId))
|
||||||
|
}}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteAiSessionDraft(draftId: String): ActiveDraftMutationResult = try {
|
||||||
|
val db = database.writableDatabase
|
||||||
|
db.beginTransaction()
|
||||||
|
try {
|
||||||
|
db.execSQL("UPDATE ai_session_drafts SET state='deleted',state_changed_at=? WHERE draft_id=? AND state='pending';",
|
||||||
|
arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), draftId))
|
||||||
|
db.delete("ai_session_draft_entries", "draft_id=?", arrayOf(draftId))
|
||||||
|
db.setTransactionSuccessful()
|
||||||
|
} finally { db.endTransaction() }
|
||||||
|
ActiveDraftMutationResult.Saved
|
||||||
|
} catch (error: Exception) {
|
||||||
|
ActiveDraftMutationResult.Error(error.message ?: "Suppression du brouillon IA impossible.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startAiSessionDraft(draftId: String): StartAiSessionDraftResult {
|
||||||
|
val db = database.writableDatabase
|
||||||
|
return try {
|
||||||
|
db.beginTransaction()
|
||||||
|
try {
|
||||||
|
val active = db.rawQuery("SELECT 1 FROM active_session_draft WHERE id=1;", null).use { it.moveToFirst() }
|
||||||
|
if (active) return StartAiSessionDraftResult.ExistingActiveDraft
|
||||||
|
val state = db.rawQuery("SELECT state FROM ai_session_drafts WHERE draft_id=?;", arrayOf(draftId))
|
||||||
|
.use { if (it.moveToFirst()) it.getString(0) else null }
|
||||||
|
if (state != "pending") return StartAiSessionDraftResult.NotPending
|
||||||
|
val entries = readAiSessionDraftEntries(db, draftId)
|
||||||
|
check(entries.isNotEmpty()) { "Brouillon IA vide." }
|
||||||
|
/* CONTRACT: copying targets and marking the proposal started is
|
||||||
|
* one transaction. No performed rows, history, MAX or feedback
|
||||||
|
* are manufactured by this transition. */
|
||||||
|
persistActiveSessionDraft(db, ActiveSessionDraft(exercises = entries, sessionType = SessionType.TRAINING))
|
||||||
|
db.execSQL("UPDATE ai_session_drafts SET state='started',state_changed_at=? WHERE draft_id=?;",
|
||||||
|
arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), draftId))
|
||||||
|
db.delete("ai_session_draft_entries", "draft_id=?", arrayOf(draftId))
|
||||||
|
db.setTransactionSuccessful()
|
||||||
|
StartAiSessionDraftResult.Started
|
||||||
|
} finally { db.endTransaction() }
|
||||||
|
} catch (error: Exception) {
|
||||||
|
StartAiSessionDraftResult.Error(error.message ?: "Démarrage du brouillon IA impossible.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAiSessionDraftEntries(db: SQLiteDatabase, draftId: String): List<SessionExerciseDraft> {
|
||||||
|
val output = mutableListOf<SessionExerciseDraft>()
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT de.entry_id,e.exercise_id,e.name,e.normalized_name,de.recording_mode,de.tracking_mode,de.data_fields," +
|
||||||
|
"eq.equipment_id,de.load_mode,de.rest_seconds,de.target_sets,de.target_reps,de.target_duration_seconds,de.target_weight_kg " +
|
||||||
|
"FROM ai_session_draft_entries de JOIN exercises e ON e.id=de.exercise_row_id " +
|
||||||
|
"LEFT JOIN equipment eq ON eq.id=de.equipment_row_id WHERE de.draft_id=? ORDER BY de.position;",
|
||||||
|
arrayOf(draftId),
|
||||||
|
).use { cursor -> while (cursor.moveToNext()) {
|
||||||
|
val profile = ExerciseProfile(cursor.getString(1), cursor.getString(2), cursor.getString(3),
|
||||||
|
recordingModeFromWire(cursor.getString(4)), trackingModeFromWire(cursor.getString(5)), cursor.getInt(6))
|
||||||
|
output += SessionExerciseDraft(entryId = cursor.getString(0), exercise = profile,
|
||||||
|
equipmentId = if (cursor.isNull(7)) null else cursor.getString(7),
|
||||||
|
plan = SessionExercisePlan(cursor.getInt(10),
|
||||||
|
reps = if (cursor.isNull(11)) null else cursor.getInt(11),
|
||||||
|
durationSeconds = if (cursor.isNull(12)) null else cursor.getInt(12),
|
||||||
|
weightKg = if (cursor.isNull(13)) null else cursor.getDouble(13),
|
||||||
|
loadMode = SessionLoadMode.fromWire(cursor.getString(8)), restSeconds = cursor.getInt(9)))
|
||||||
|
}}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun optionalJsonString(item: JSONObject, key: String, maximumLength: Int): String? {
|
||||||
|
if (!item.has(key) || item.isNull(key)) return null
|
||||||
|
val value = item.value(key) as? String ?: throw IllegalArgumentException("$key invalide.")
|
||||||
|
if (value.isEmpty() || value != value.trim() ||
|
||||||
|
value.codePointCount(0, value.length) > maximumLength)
|
||||||
|
throw IllegalArgumentException("$key invalide.")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUtcTimestamp(value: String): Boolean = try {
|
||||||
|
if (!Regex("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{1,6})?Z$").matches(value)) return false
|
||||||
|
val parsed = OffsetDateTime.parse(value)
|
||||||
|
parsed.offset.totalSeconds == 0 && value.endsWith("Z")
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
|
||||||
fun loadActiveSessionDraft():
|
fun loadActiveSessionDraft():
|
||||||
ActiveDraftLoadResult =
|
ActiveDraftLoadResult =
|
||||||
try {
|
try {
|
||||||
|
|
@ -6604,6 +6873,16 @@ private const val DESKTOP_LEG_PRESS_CANONICAL_ID =
|
||||||
private val EXERCISE_ID_V4_PATTERN = Regex(
|
private val EXERCISE_ID_V4_PATTERN = Regex(
|
||||||
"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||||
)
|
)
|
||||||
|
private val AI_DRAFT_ID_V4_PATTERN = Regex(
|
||||||
|
"^aid_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||||
|
)
|
||||||
|
private val SESSION_ENTRY_ID_V4_PATTERN = Regex(
|
||||||
|
"^sxe_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||||
|
)
|
||||||
|
private const val MAX_AI_DRAFTS = 256
|
||||||
|
private const val MAX_AI_DRAFT_ENTRIES = 64
|
||||||
|
private const val MAX_AI_TARGET_SETS = 99
|
||||||
|
private const val MAX_AI_TARGET_REPS = 999
|
||||||
|
|
||||||
private class TrainlogDatabaseHelper(
|
private class TrainlogDatabaseHelper(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
|
|
@ -6612,7 +6891,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
16,
|
17,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -6653,6 +6932,7 @@ private class TrainlogDatabaseHelper(
|
||||||
createTrainingFeedbackTables(db)
|
createTrainingFeedbackTables(db)
|
||||||
createTrainingFeedbackRevisionTables(db)
|
createTrainingFeedbackRevisionTables(db)
|
||||||
createExerciseProfileStateTable(db, seedLegacy = false)
|
createExerciseProfileStateTable(db, seedLegacy = false)
|
||||||
|
createAiSessionDraftTables(db)
|
||||||
seedEquipment(db)
|
seedEquipment(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6780,6 +7060,14 @@ private class TrainlogDatabaseHelper(
|
||||||
version = 16
|
version = 16
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 17 && newVersion >= 17) {
|
||||||
|
/* WHY: PC-authored proposals must survive replay without entering
|
||||||
|
* the singleton capture draft. CONTRACT: v17 is additive.
|
||||||
|
* INVARIANT: existing active and completed sessions are untouched. */
|
||||||
|
createAiSessionDraftTables(db)
|
||||||
|
version = 17
|
||||||
|
}
|
||||||
|
|
||||||
if (version != newVersion) {
|
if (version != newVersion) {
|
||||||
error(
|
error(
|
||||||
"Unsupported Android DB upgrade " +
|
"Unsupported Android DB upgrade " +
|
||||||
|
|
@ -6788,6 +7076,33 @@ private class TrainlogDatabaseHelper(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createAiSessionDraftTables(db: SQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +
|
||||||
|
"draft_id TEXT PRIMARY KEY,created_at TEXT NOT NULL,planned_for TEXT," +
|
||||||
|
"title TEXT,notes TEXT,state TEXT NOT NULL DEFAULT 'pending' " +
|
||||||
|
"CHECK(state IN('pending','started','deleted')),state_changed_at TEXT NOT NULL);",
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS ai_session_draft_entries(" +
|
||||||
|
"id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL REFERENCES ai_session_drafts(draft_id) ON DELETE CASCADE," +
|
||||||
|
"entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL CHECK(position>=0)," +
|
||||||
|
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT," +
|
||||||
|
"recording_mode TEXT NOT NULL CHECK(recording_mode='sets')," +
|
||||||
|
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration'))," +
|
||||||
|
"data_fields INTEGER NOT NULL CHECK(data_fields>=0 AND (data_fields & ~3)=0)," +
|
||||||
|
"equipment_row_id INTEGER REFERENCES equipment(id) ON DELETE RESTRICT," +
|
||||||
|
"load_mode TEXT NOT NULL CHECK(load_mode IN('none','external'))," +
|
||||||
|
"rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400)," +
|
||||||
|
"target_sets INTEGER NOT NULL CHECK(target_sets BETWEEN 1 AND 99)," +
|
||||||
|
"target_reps INTEGER CHECK(target_reps BETWEEN 1 AND 999)," +
|
||||||
|
"target_duration_seconds INTEGER CHECK(target_duration_seconds BETWEEN 1 AND 86400)," +
|
||||||
|
"target_weight_kg REAL CHECK(target_weight_kg>0.0)," +
|
||||||
|
"CHECK((target_reps IS NULL)!=(target_duration_seconds IS NULL))," +
|
||||||
|
"UNIQUE(draft_id,position));",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createBodyZoneTables(db: SQLiteDatabase) {
|
private fun createBodyZoneTables(db: SQLiteDatabase) {
|
||||||
/* CONTRACT: canonical zone definitions remain in the asset; SQLite
|
/* CONTRACT: canonical zone definitions remain in the asset; SQLite
|
||||||
* stores only direct exercise relations and role cardinality. */
|
* stores only direct exercise relations and role cardinality. */
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,20 @@ data class ActiveSessionDraft(
|
||||||
val updatedAt: String = "",
|
val updatedAt: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A desktop-authored proposal is inert until the user explicitly starts it.
|
||||||
|
* INVARIANT: entries contain targets only; performed values remain owned by
|
||||||
|
* [ActiveSessionDraft] after an atomic start operation.
|
||||||
|
*/
|
||||||
|
data class AiSessionDraft(
|
||||||
|
val draftId: String,
|
||||||
|
val createdAt: String,
|
||||||
|
val plannedFor: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val entries: List<SessionExerciseDraft>,
|
||||||
|
)
|
||||||
|
|
||||||
data class SessionSummary(
|
data class SessionSummary(
|
||||||
val sessionId: String,
|
val sessionId: String,
|
||||||
val startedAt: String,
|
val startedAt: String,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ sealed interface AppRoute {
|
||||||
data object Sessions : AppRoute { override val section = AppSection.SESSIONS }
|
data object Sessions : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data object SessionEditor : AppRoute { override val section = AppSection.SESSIONS }
|
data object SessionEditor : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
|
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
|
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
|
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
|
|
@ -157,6 +158,7 @@ internal fun routeTitle(route: AppRoute): String = when (route) {
|
||||||
AppRoute.Sessions -> "Séances"
|
AppRoute.Sessions -> "Séances"
|
||||||
AppRoute.SessionEditor -> "Séance en cours"
|
AppRoute.SessionEditor -> "Séance en cours"
|
||||||
AppRoute.SessionGenerator -> "Programmer une séance"
|
AppRoute.SessionGenerator -> "Programmer une séance"
|
||||||
|
AppRoute.AiSessionDrafts -> "Brouillons"
|
||||||
AppRoute.CompletedSessions -> "Séances effectuées"
|
AppRoute.CompletedSessions -> "Séances effectuées"
|
||||||
is AppRoute.SessionDetail -> "Détail de la séance"
|
is AppRoute.SessionDetail -> "Détail de la séance"
|
||||||
is AppRoute.SessionCorrection -> "Modifier la séance"
|
is AppRoute.SessionCorrection -> "Modifier la séance"
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,27 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.labfytools.trainlog.data.CreateEquipmentResult
|
import com.labfytools.trainlog.data.CreateEquipmentResult
|
||||||
import com.labfytools.trainlog.data.CatalogInboxResult
|
import com.labfytools.trainlog.data.CatalogInboxResult
|
||||||
|
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
import com.labfytools.trainlog.data.EquipmentCatalogEntry
|
import com.labfytools.trainlog.data.EquipmentCatalogEntry
|
||||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||||
import com.labfytools.trainlog.data.KnowledgeConfidence
|
import com.labfytools.trainlog.data.KnowledgeConfidence
|
||||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||||
|
import com.labfytools.trainlog.data.StartAiSessionDraftResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
|
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionsHub(
|
fun SessionsHub(
|
||||||
draft: ActiveSessionDraft?,
|
draft: ActiveSessionDraft?,
|
||||||
|
pendingAiDraftCount: Int,
|
||||||
onResume: () -> Unit,
|
onResume: () -> Unit,
|
||||||
onManual: () -> Unit,
|
onManual: () -> Unit,
|
||||||
|
onDrafts: () -> Unit,
|
||||||
onHistory: () -> Unit,
|
onHistory: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
|
|
@ -32,11 +38,94 @@ fun SessionsHub(
|
||||||
}
|
}
|
||||||
TrainlogFrame("Préparer") {
|
TrainlogFrame("Préparer") {
|
||||||
TrainlogAction("Nouvelle séance manuelle", if (draft == null) "Créer explicitement un brouillon de séance." else "Ouvrir la séance en cours sans l'écraser.", onManual)
|
TrainlogAction("Nouvelle séance manuelle", if (draft == null) "Créer explicitement un brouillon de séance." else "Ouvrir la séance en cours sans l'écraser.", onManual)
|
||||||
|
TrainlogAction(
|
||||||
|
"Brouillons",
|
||||||
|
"$pendingAiDraftCount séance(s) préparée(s) · consulter les propositions importées depuis le PC.",
|
||||||
|
onDrafts,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory)
|
TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AiSessionDraftsScreen(
|
||||||
|
repository: TrainlogRepository,
|
||||||
|
externalRevision: Int,
|
||||||
|
onPendingChanged: () -> Unit = {},
|
||||||
|
onStarted: () -> Unit,
|
||||||
|
) {
|
||||||
|
val colors = LocalTrainlogColors.current
|
||||||
|
var revision by remember { mutableStateOf(0) }
|
||||||
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
|
var pendingDeletion by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||||
|
val drafts = remember(externalRevision, revision) { repository.listAiSessionDrafts() }
|
||||||
|
TrainlogScreen("Brouillons") {
|
||||||
|
message?.let { TrainlogInfo(it) }
|
||||||
|
if (drafts.isEmpty()) TrainlogInfo("Aucun brouillon importé.")
|
||||||
|
drafts.forEach { draft ->
|
||||||
|
TrainlogFrame(draft.title ?: "Proposition de séance", active = true) {
|
||||||
|
TrainlogInfo(listOfNotNull(
|
||||||
|
draft.plannedFor?.let { "Prévue le $it" },
|
||||||
|
"${draft.entries.size} exercice(s)",
|
||||||
|
).joinToString(" · "))
|
||||||
|
draft.notes?.let { TrainlogInfo(it) }
|
||||||
|
draft.entries.forEach { entry ->
|
||||||
|
val plan = checkNotNull(entry.plan)
|
||||||
|
val metric = plan.reps?.let { "$it répétitions" }
|
||||||
|
?: "${plan.durationSeconds} secondes"
|
||||||
|
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
|
||||||
|
TrainlogInfo("${entry.exercise.name} · ${plan.sets} × $metric$weight · repos ${plan.restSeconds} s")
|
||||||
|
}
|
||||||
|
TrainlogPrimaryAction("Démarrer", "Copier cette proposition dans la séance en cours.") {
|
||||||
|
when (val result = repository.startAiSessionDraft(draft.draftId)) {
|
||||||
|
StartAiSessionDraftResult.Started -> {
|
||||||
|
onPendingChanged()
|
||||||
|
onStarted()
|
||||||
|
}
|
||||||
|
StartAiSessionDraftResult.ExistingActiveDraft ->
|
||||||
|
message = "Une séance est déjà en cours. Le brouillon importé a été conservé."
|
||||||
|
StartAiSessionDraftResult.NotPending -> {
|
||||||
|
message = "Ce brouillon n'est plus disponible."
|
||||||
|
revision++
|
||||||
|
onPendingChanged()
|
||||||
|
}
|
||||||
|
is StartAiSessionDraftResult.Error -> message = result.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrainlogAction(
|
||||||
|
"Supprimer",
|
||||||
|
"Conserver un tombstone pour empêcher sa réapparition lors d'un replay.",
|
||||||
|
onClick = {
|
||||||
|
pendingDeletion = draft.draftId to (draft.title ?: "Proposition de séance")
|
||||||
|
},
|
||||||
|
accent = colors.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingDeletion?.let { (draftId, title) ->
|
||||||
|
DestructiveConfirmationDialog(
|
||||||
|
title = "Supprimer ce brouillon ?",
|
||||||
|
detail = "Le brouillon « $title » sera supprimé.\nCette action est irréversible.",
|
||||||
|
confirmLabel = "Supprimer",
|
||||||
|
onCancel = { pendingDeletion = null },
|
||||||
|
dismissOnClickOutside = true,
|
||||||
|
) {
|
||||||
|
/* CONTRACT: dialog confirmation is the sole UI path to the existing
|
||||||
|
* tombstone mutation. Dismissal never reaches the repository. */
|
||||||
|
pendingDeletion = null
|
||||||
|
when (val result = repository.deleteAiSessionDraft(draftId)) {
|
||||||
|
ActiveDraftMutationResult.Saved -> {
|
||||||
|
revision++
|
||||||
|
onPendingChanged()
|
||||||
|
}
|
||||||
|
is ActiveDraftMutationResult.Error -> message = result.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LatestMaximaScreen(repository: TrainlogRepository) {
|
fun LatestMaximaScreen(repository: TrainlogRepository) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
|
|
@ -155,13 +244,13 @@ private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics) = when (value
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
if (uri != null) {
|
authorized = inbox.hasFolderAccess()
|
||||||
authorized = inbox.saveTreeUri(uri)
|
message = if (authorized) "Accès fichiers autorisé." else "Accès fichiers toujours requis."
|
||||||
message = if (authorized) "Dossier d'échange autorisé." else "Autorisation du dossier impossible."
|
if (authorized) {
|
||||||
if (authorized) {
|
|
||||||
when (val result = inbox.importPcCatalog()) {
|
when (val result = inbox.importPcCatalog()) {
|
||||||
is CatalogInboxResult.Imported -> {
|
is CatalogInboxResult.Imported -> {
|
||||||
message = "Dossier autorisé · ${result.imported} exercice(s) importé(s), ${result.reconciled} réconcilié(s)."
|
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é."
|
CatalogInboxResult.FolderNotAuthorized -> message = "Le dossier n'est plus autorisé."
|
||||||
is CatalogInboxResult.Error -> message = result.message
|
is CatalogInboxResult.Error -> message = result.message
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TrainlogScreen("Paramètres") {
|
TrainlogScreen("Paramètres") {
|
||||||
TrainlogFrame("DOSSIER D'ÉCHANGE") {
|
TrainlogFrame("DOSSIER D'ÉCHANGE") {
|
||||||
TrainlogInfo(if (authorized) "Téléchargements/Trainlog autorisé." else "Aucun dossier Trainlog autorisé.", if (authorized) colors.success else colors.warning)
|
TrainlogInfo(if (authorized) "Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé" else "Accès fichiers requis", if (authorized) colors.success else colors.warning)
|
||||||
TrainlogAction(if (authorized) "Changer de dossier" else "Autoriser le dossier", "Choisir le dossier d'échange avec le sélecteur Android.", { launcher.launch(null) })
|
TrainlogAction(if (authorized) "Ouvrir les réglages d'accès" else "Autoriser", "Autoriser l'accès au dossier Trainlog dans Android.", { launcher.launch(directStoragePermissionIntent(context)) })
|
||||||
if (authorized) TrainlogAction("Relire le catalogue PC", "Appliquer explicitement le catalogue présent dans le dossier autorisé.", {
|
if (authorized) TrainlogAction("Relire le catalogue PC", "Appliquer explicitement le catalogue présent dans le dossier autorisé.", {
|
||||||
when (val result = inbox.importPcCatalog()) {
|
when (val result = inbox.importPcCatalog()) {
|
||||||
is CatalogInboxResult.Imported -> { message = "Catalogue relu · ${result.imported} nouveau(x), ${result.reconciled} réconcilié(s)."; onCatalogChanged() }
|
is CatalogInboxResult.Imported -> { message = "Catalogue relu · ${result.imported} nouveau(x), ${result.reconciled} réconcilié(s)."; onCatalogChanged() }
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -18,7 +19,8 @@ import com.labfytools.trainlog.data.SyncExportResult
|
||||||
import com.labfytools.trainlog.data.SyncReceiptResult
|
import com.labfytools.trainlog.data.SyncReceiptResult
|
||||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||||
import com.labfytools.trainlog.data.SyncRequestResult
|
import com.labfytools.trainlog.data.SyncRequestResult
|
||||||
import com.labfytools.trainlog.data.logExchangeMediaStore
|
import com.labfytools.trainlog.data.logDirectExchange
|
||||||
|
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -31,19 +33,19 @@ internal suspend fun publishBundleAndRequest(
|
||||||
exporter: SyncExporter,
|
exporter: SyncExporter,
|
||||||
requestOutbox: SyncRequestOutbox,
|
requestOutbox: SyncRequestOutbox,
|
||||||
): SyncRequestResult = withContext(Dispatchers.IO) {
|
): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||||
logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.begin")
|
logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
|
||||||
when (val opened = exporter.openSafSnapshot()) {
|
when (val opened = exporter.openStorageSnapshot()) {
|
||||||
is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Error ->
|
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error ->
|
||||||
SyncRequestResult.Error("Préparation Android → PC : ${opened.message}")
|
SyncRequestResult.Error("Préparation Android → PC : ${opened.message}")
|
||||||
is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Ready ->
|
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready ->
|
||||||
when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
|
when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
|
||||||
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
|
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
|
||||||
is SyncExportResult.Error -> SyncRequestResult.Error(
|
is SyncExportResult.Error -> SyncRequestResult.Error(
|
||||||
"Préparation Android → PC : ${publication.message}",
|
"Préparation Android → PC : ${publication.message}",
|
||||||
)
|
)
|
||||||
is SyncExportResult.Exported -> {
|
is SyncExportResult.Exported -> {
|
||||||
logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.success")
|
logDirectExchange("SYNC_BUNDLE", "coordinator.export.success")
|
||||||
logExchangeMediaStore("trainlog-sync-request-v1.json", "coordinator.request.begin")
|
logDirectExchange("trainlog-sync-request-v1.json", "coordinator.request.begin")
|
||||||
requestOutbox.requestSync(opened.snapshot)
|
requestOutbox.requestSync(opened.snapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +63,7 @@ fun SyncScreen(
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
var status by
|
var status by
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -208,63 +211,32 @@ fun SyncScreen(
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val folderLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
contract =
|
contract = ActivityResultContracts.StartActivityForResult(),
|
||||||
ActivityResultContracts
|
|
||||||
.OpenDocumentTree(),
|
|
||||||
) {
|
) {
|
||||||
uri ->
|
folderAuthorized = inbox.hasFolderAccess()
|
||||||
if (uri != null) {
|
success = folderAuthorized
|
||||||
val saved =
|
status = if (folderAuthorized) {
|
||||||
inbox.saveTreeUri(
|
"Accès fichiers autorisé · Documents/Trainlog prêt."
|
||||||
uri
|
} else {
|
||||||
)
|
"Accès fichiers requis. Activez l'autorisation dans Android."
|
||||||
|
}
|
||||||
folderAuthorized =
|
if (folderAuthorized) {
|
||||||
saved
|
coroutineScope.launch {
|
||||||
|
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
|
||||||
success =
|
is CatalogInboxResult.Imported -> {
|
||||||
saved
|
status = "Accès autorisé · catalogue PC : ${result.imported} nouveau(x), " +
|
||||||
|
"${result.reconciled} réconcilié(s)."
|
||||||
status =
|
onCatalogChanged()
|
||||||
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. */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
CatalogInboxResult.FileNotFound ->
|
||||||
|
status = "Accès autorisé · aucun catalogue PC reçu."
|
||||||
|
CatalogInboxResult.FolderNotAuthorized -> folderAuthorized = false
|
||||||
|
is CatalogInboxResult.Error -> status = result.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogScreen(
|
TrainlogScreen(
|
||||||
|
|
@ -305,7 +277,7 @@ fun SyncScreen(
|
||||||
success = false
|
success = false
|
||||||
|
|
||||||
status =
|
status =
|
||||||
"Autorisez d'abord Téléchargements/Trainlog."
|
"Accès fichiers requis. Autorisez d'abord Documents/Trainlog."
|
||||||
|
|
||||||
return@TrainlogAction
|
return@TrainlogAction
|
||||||
}
|
}
|
||||||
|
|
@ -347,7 +319,7 @@ fun SyncScreen(
|
||||||
) {
|
) {
|
||||||
if (folderAuthorized) {
|
if (folderAuthorized) {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
"Téléchargements/Trainlog autorisé.",
|
"Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé",
|
||||||
color =
|
color =
|
||||||
colors.success,
|
colors.success,
|
||||||
)
|
)
|
||||||
|
|
@ -355,17 +327,11 @@ fun SyncScreen(
|
||||||
|
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label =
|
label =
|
||||||
if (folderAuthorized) {
|
if (folderAuthorized) "Ouvrir les réglages d'accès" else "Autoriser l'accès au dossier Trainlog",
|
||||||
"Changer le dossier Trainlog"
|
|
||||||
} else {
|
|
||||||
"Autoriser le dossier Trainlog"
|
|
||||||
},
|
|
||||||
description =
|
description =
|
||||||
"Choisir Téléchargements/Trainlog.",
|
"Autoriser l'accès pour gérer tous les fichiers dans Android.",
|
||||||
onClick = {
|
onClick = {
|
||||||
folderLauncher.launch(
|
permissionLauncher.launch(directStoragePermissionIntent(context))
|
||||||
null
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
accent =
|
accent =
|
||||||
colors.warning,
|
colors.warning,
|
||||||
|
|
@ -397,7 +363,7 @@ fun SyncScreen(
|
||||||
success = false
|
success = false
|
||||||
|
|
||||||
status =
|
status =
|
||||||
"Autorisez d'abord Téléchargements/Trainlog."
|
"Accès fichiers requis pour Documents/Trainlog."
|
||||||
}
|
}
|
||||||
|
|
||||||
CatalogInboxResult.FileNotFound -> {
|
CatalogInboxResult.FileNotFound -> {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
|
|
||||||
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
|
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
|
||||||
val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
||||||
|
val pendingAiDraftCount = remember(draftRevision, catalogRevision) {
|
||||||
|
repository.listAiSessionDrafts().size
|
||||||
|
}
|
||||||
|
|
||||||
fun open(route: AppRoute) { navigationController.open(route) }
|
fun open(route: AppRoute) { navigationController.open(route) }
|
||||||
fun openSection(section: AppSection) { navigationController.openSection(section) }
|
fun openSection(section: AppSection) { navigationController.openSection(section) }
|
||||||
|
|
@ -94,7 +97,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
{ open(AppRoute.Statistics) },
|
{ open(AppRoute.Statistics) },
|
||||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
||||||
)
|
)
|
||||||
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) })
|
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||||
|
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) })
|
||||||
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
|
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
|
||||||
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||||
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."
|
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."
|
||||||
|
|
@ -105,6 +109,12 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
* Back and drawer navigation. */
|
* Back and drawer navigation. */
|
||||||
openSection(AppSection.SESSIONS)
|
openSection(AppSection.SESSIONS)
|
||||||
})
|
})
|
||||||
|
AppRoute.AiSessionDrafts -> AiSessionDraftsScreen(
|
||||||
|
repository = repository,
|
||||||
|
externalRevision = catalogRevision,
|
||||||
|
onPendingChanged = { draftRevision++ },
|
||||||
|
onStarted = { open(AppRoute.SessionEditor) },
|
||||||
|
)
|
||||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
||||||
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) }
|
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) }
|
||||||
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
|
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
@ -377,14 +378,24 @@ fun DestructiveConfirmationDialog(
|
||||||
detail: String,
|
detail: String,
|
||||||
confirmLabel: String,
|
confirmLabel: String,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
|
dismissOnClickOutside: Boolean = false,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val colors = LocalTrainlogColors.current
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onCancel,
|
onDismissRequest = onCancel,
|
||||||
title = { Text(title) },
|
title = { Text(title) },
|
||||||
text = { Text(detail) },
|
text = { Text(detail) },
|
||||||
dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } },
|
dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } },
|
||||||
confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } },
|
confirmButton = {
|
||||||
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false),
|
TextButton(
|
||||||
|
onClick = onConfirm,
|
||||||
|
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
|
||||||
|
) { Text(confirmLabel) }
|
||||||
|
},
|
||||||
|
properties = DialogProperties(
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
dismissOnClickOutside = dismissOnClickOutside,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,7 +61,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(16, scalar(db, "PRAGMA user_version"))
|
assertEquals(17, scalar(db, "PRAGMA user_version"))
|
||||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(16, scalar(db, "PRAGMA user_version"))
|
assertEquals(17, scalar(db, "PRAGMA user_version"))
|
||||||
/* v13 intentionally adds the frozen machine identities and
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -323,7 +323,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(16, cursor.getInt(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(17, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -125,7 +125,7 @@ class RealAndroidV12MachineExerciseMigrationTest {
|
||||||
|
|
||||||
private fun assertValidVersionThirteen(databasePath: File) {
|
private fun assertValidVersionThirteen(databasePath: File) {
|
||||||
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
||||||
assertEquals(16, scalarInt(database, "PRAGMA user_version;"))
|
assertEquals(17, scalarInt(database, "PRAGMA user_version;"))
|
||||||
assertEquals("ok", scalarString(database, "PRAGMA integrity_check;"))
|
assertEquals("ok", scalarString(database, "PRAGMA integrity_check;"))
|
||||||
database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
|
database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class RealAndroidV14FeedbackRevisionMigrationTest {
|
||||||
}
|
}
|
||||||
TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() }
|
TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() }
|
||||||
SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db->
|
SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db->
|
||||||
assertEquals(16,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())}
|
assertEquals(17,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())}
|
||||||
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_state WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
|
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_state WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
|
||||||
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_revisions WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
|
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_revisions WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
|
||||||
assertEquals(before.first,values(db,"SELECT entry_id FROM draft_session_exercises ORDER BY entry_id"));assertEquals(before.second,values(db,"SELECT feedback_id||'|'||observed_at||'|'||raw_text FROM draft_exercise_feedback ORDER BY feedback_id"))
|
assertEquals(before.first,values(db,"SELECT entry_id FROM draft_session_exercises ORDER BY entry_id"));assertEquals(before.second,values(db,"SELECT feedback_id||'|'||observed_at||'|'||raw_text FROM draft_exercise_feedback ORDER BY feedback_id"))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package com.labfytools.trainlog.data
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.BodyObservationDraft
|
import com.labfytools.trainlog.model.BodyObservationDraft
|
||||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||||
|
|
@ -1669,7 +1668,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(16, cursor.getInt(0))
|
assertEquals(17, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -1796,7 +1795,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(16, cursor.getInt(0))
|
assertEquals(17, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
@ -2287,12 +2286,11 @@ class TrainlogRepositoryDraftTest {
|
||||||
v3File.writeText(malformedV3.toString())
|
v3File.writeText(malformedV3.toString())
|
||||||
File(directory, "trainlog-pc-mobile-export-v2.json").writeText(validV2.toString())
|
File(directory, "trainlog-pc-mobile-export-v2.json").writeText(validV2.toString())
|
||||||
val inbox = SyncCatalogInbox(context, destination)
|
val inbox = SyncCatalogInbox(context, destination)
|
||||||
val documentDirectory = DocumentFile.fromFile(directory)
|
val error = inbox.importPcSessionsFromDirectoryForTest(directory)
|
||||||
val error = inbox.importPcSessionsFromDirectoryForTest(documentDirectory)
|
|
||||||
assertTrue(error!!.contains("started_at"))
|
assertTrue(error!!.contains("started_at"))
|
||||||
assertTrue(destination.listSessions().isEmpty())
|
assertTrue(destination.listSessions().isEmpty())
|
||||||
assertTrue(v3File.delete())
|
assertTrue(v3File.delete())
|
||||||
assertEquals(null, inbox.importPcSessionsFromDirectoryForTest(documentDirectory))
|
assertEquals(null, inbox.importPcSessionsFromDirectoryForTest(directory))
|
||||||
assertEquals(1, destination.listSessions().size)
|
assertEquals(1, destination.listSessions().size)
|
||||||
} finally {
|
} finally {
|
||||||
directory.deleteRecursively()
|
directory.deleteRecursively()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.labfytools.trainlog.ui
|
package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import com.labfytools.trainlog.data.SaveFeedbackResult
|
import com.labfytools.trainlog.data.SaveFeedbackResult
|
||||||
import com.labfytools.trainlog.data.SaveSessionResult
|
import com.labfytools.trainlog.data.SaveSessionResult
|
||||||
|
|
@ -9,21 +8,23 @@ import com.labfytools.trainlog.data.SyncExporter
|
||||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||||
import com.labfytools.trainlog.data.SyncRequestResult
|
import com.labfytools.trainlog.data.SyncRequestResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.data.ExchangeSafDirectory
|
import com.labfytools.trainlog.data.DirectExchangeDirectoryAccess
|
||||||
import com.labfytools.trainlog.data.ExchangeSafDocument
|
import com.labfytools.trainlog.data.DirectExchangeDocument
|
||||||
import com.labfytools.trainlog.data.EXCHANGE_MEDIASTORE_LOG_TAG
|
import com.labfytools.trainlog.data.DIRECT_EXCHANGE_LOG_TAG
|
||||||
import com.labfytools.trainlog.data.ExchangeSafPublisher
|
import com.labfytools.trainlog.data.DirectExchangePublisher
|
||||||
import com.labfytools.trainlog.data.ExchangeSafSnapshotResult
|
import com.labfytools.trainlog.data.DirectExchangeSnapshotResult
|
||||||
import com.labfytools.trainlog.model.SessionDraft
|
import com.labfytools.trainlog.model.SessionDraft
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.io.File
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -134,27 +135,20 @@ class SyncBundlePublicationTest {
|
||||||
val writesBeforeSecondClick = directory.writeCounts.toMap()
|
val writesBeforeSecondClick = directory.writeCounts.toMap()
|
||||||
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
|
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
|
||||||
assertEquals(3, directory.enumerationCount)
|
assertEquals(3, directory.enumerationCount)
|
||||||
val phases = ShadowLog.getLogsForTag(EXCHANGE_MEDIASTORE_LOG_TAG).map { it.msg }
|
val phases = ShadowLog.getLogsForTag(DIRECT_EXCHANGE_LOG_TAG).map { it.msg }
|
||||||
assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") })
|
assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") })
|
||||||
val snapshotPhases = phases.filter { it.startsWith("SAF_TREE snapshot.success ") }
|
val snapshotPhases = phases.filter { it.startsWith("DIRECT_STORAGE snapshot.success ") }
|
||||||
assertEquals(1, snapshotPhases.size)
|
assertEquals(1, snapshotPhases.size)
|
||||||
assertTrue("SAF snapshot ran on main: $snapshotPhases",
|
assertTrue("SAF snapshot ran on main: $snapshotPhases",
|
||||||
snapshotPhases.none { it.contains("thread=main") })
|
snapshotPhases.none { it.contains("thread=main") })
|
||||||
val writeBegins = phases.filter { it.contains(" saf.write.begin ") }
|
val writeBegins = phases.filter { it.contains(" direct.write.success") }
|
||||||
assertEquals(required.size, writeBegins.size)
|
assertEquals(required.size, writeBegins.size)
|
||||||
assertTrue("SAF write ran on main: $writeBegins",
|
assertTrue("SAF write ran on main: $writeBegins",
|
||||||
writeBegins.none { it.contains("thread=main") })
|
writeBegins.none { it.contains("thread=main") })
|
||||||
val bodyResolve = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.resolve.existing ") }
|
val bodyWritten = phases.indexOfFirst { it.startsWith("BODY_ZONES direct.write.success") }
|
||||||
val bodyWrite = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.begin ") }
|
|
||||||
val bodyWritten = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.success ") }
|
|
||||||
val requestStart = phases.indexOfFirst { it == "SYNC_REQUEST coordinator.request.begin" }
|
val requestStart = phases.indexOfFirst { it == "SYNC_REQUEST coordinator.request.begin" }
|
||||||
assertTrue("production coordinator did not resolve existing BODY ZONES", bodyResolve >= 0)
|
assertTrue("BODY ZONES write did not complete", bodyWritten >= 0)
|
||||||
assertTrue("BODY ZONES must open only after exact resolution", bodyWrite > bodyResolve)
|
|
||||||
assertTrue("BODY ZONES write did not complete", bodyWritten > bodyWrite)
|
|
||||||
assertTrue("request must be published after the complete export", requestStart > bodyWritten)
|
assertTrue("request must be published after the complete export", requestStart > bodyWritten)
|
||||||
val beforeBodyOpen = phases.subList(bodyResolve + 1, bodyWrite)
|
|
||||||
assertTrue("existing BODY ZONES unexpectedly created: $beforeBodyOpen",
|
|
||||||
beforeBodyOpen.none { it.startsWith("BODY_ZONES saf.create.") })
|
|
||||||
assertTrue("outbound path must not call MediaStore insert/update/pending: $phases",
|
assertTrue("outbound path must not call MediaStore insert/update/pending: $phases",
|
||||||
phases.none { it.contains(" insert.") || it.contains(" publish.") || it.contains("IS_PENDING") })
|
phases.none { it.contains(" insert.") || it.contains(" publish.") || it.contains("IS_PENDING") })
|
||||||
assertEquals(bodyCreatesBefore, directory.createCount("trainlog-exercise-body-zones-v1.json"))
|
assertEquals(bodyCreatesBefore, directory.createCount("trainlog-exercise-body-zones-v1.json"))
|
||||||
|
|
@ -173,8 +167,8 @@ class SyncBundlePublicationTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun `created exact document updates transaction index without rescan`() {
|
@Test fun `created exact document updates transaction index without rescan`() {
|
||||||
val publisher = ExchangeSafPublisher { directory }
|
val publisher = DirectExchangePublisher { directory }
|
||||||
val opened = publisher.snapshot() as ExchangeSafSnapshotResult.Ready
|
val opened = publisher.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||||
assertEquals(1, directory.enumerationCount)
|
assertEquals(1, directory.enumerationCount)
|
||||||
assertEquals(null, opened.snapshot.writeJson("foo.json", "first"))
|
assertEquals(null, opened.snapshot.writeJson("foo.json", "first"))
|
||||||
assertEquals(null, opened.snapshot.writeJson("foo.json", "second"))
|
assertEquals(null, opened.snapshot.writeJson("foo.json", "second"))
|
||||||
|
|
@ -185,34 +179,135 @@ class SyncBundlePublicationTest {
|
||||||
assertEquals(0, directory.canonicalCount("foo (1).json"))
|
assertEquals(0, directory.canonicalCount("foo (1).json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun `empty directory creates exact canonical name and final content`() {
|
||||||
|
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||||
|
|
||||||
|
assertEquals(null, opened.snapshot.writeJson("artifact.json", "new export"))
|
||||||
|
|
||||||
|
assertEquals(1, directory.canonicalCount("artifact.json"))
|
||||||
|
assertEquals("new export", directory.read("artifact.json"))
|
||||||
|
assertFalse(directory.names().any { it.matches(Regex("artifact \\(\\d+\\)\\.json")) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `ten publications rewrite one canonical document without numbered copies`() {
|
||||||
|
repeat(10) { publication ->
|
||||||
|
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||||
|
assertEquals(null, opened.snapshot.writeJson("artifact.json", "export-$publication"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, directory.createCount("artifact.json"))
|
||||||
|
assertEquals(10, directory.writeCount("artifact.json"))
|
||||||
|
assertEquals("export-9", directory.read("artifact.json"))
|
||||||
|
assertEquals(setOf("artifact.json"), directory.names())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `historical numbered copies are never selected instead of canonical`() {
|
||||||
|
directory.seed("artifact (1).json", "history one")
|
||||||
|
directory.seed("artifact (2).json", "history two")
|
||||||
|
directory.seed("artifact.json", "old canonical")
|
||||||
|
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||||
|
|
||||||
|
assertEquals(null, opened.snapshot.writeJson("artifact.json", "new canonical"))
|
||||||
|
|
||||||
|
assertEquals("new canonical", directory.read("artifact.json"))
|
||||||
|
assertEquals("history one", directory.read("artifact (1).json"))
|
||||||
|
assertEquals("history two", directory.read("artifact (2).json"))
|
||||||
|
assertEquals(0, directory.writeCount("artifact (1).json"))
|
||||||
|
assertEquals(0, directory.writeCount("artifact (2).json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `historical numbered copy alone does not become canonical target`() {
|
||||||
|
directory.seed("artifact (1).json", "history")
|
||||||
|
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||||
|
|
||||||
|
assertEquals(null, opened.snapshot.writeJson("artifact.json", "canonical"))
|
||||||
|
|
||||||
|
assertEquals("canonical", directory.read("artifact.json"))
|
||||||
|
assertEquals("history", directory.read("artifact (1).json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `provider rename fails explicitly before writing conflict copy`() {
|
||||||
|
directory.createdNameOverride = "artifact (1).json"
|
||||||
|
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"DIRECT_STORAGE_CANONICAL_NAME_CONFLICT: attendu=artifact.json obtenu=artifact (1).json",
|
||||||
|
opened.snapshot.writeJson("artifact.json", "must not be written"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(0, directory.writeCount("artifact (1).json"))
|
||||||
|
assertEquals(0, directory.canonicalCount("artifact.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `raw exact lookup recovers canonical omitted from ordinary discovery`() {
|
||||||
|
directory.seedRawOnly("artifact.json", "old canonical")
|
||||||
|
directory.seed("artifact (1).json", "history")
|
||||||
|
repeat(10) { publication ->
|
||||||
|
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||||
|
assertEquals(null, opened.snapshot.writeJson("artifact.json", "export-$publication"))
|
||||||
|
}
|
||||||
|
assertEquals(0, directory.createCount("artifact.json"))
|
||||||
|
assertEquals(10, directory.writeCount("artifact.json"))
|
||||||
|
assertEquals("export-9", directory.read("artifact.json"))
|
||||||
|
assertEquals("history", directory.read("artifact (1).json"))
|
||||||
|
assertFalse(directory.names().contains("artifact (2).json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `multiple raw exact canonical rows fail deterministically`() {
|
||||||
|
directory.seedRawOnly("artifact.json", "one")
|
||||||
|
directory.rawExactDuplicates = 2
|
||||||
|
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||||
|
assertEquals(
|
||||||
|
"DIRECT_STORAGE_DUPLICATE_CANONICAL: nom=artifact.json count=2",
|
||||||
|
opened.snapshot.writeJson("artifact.json", "new"),
|
||||||
|
)
|
||||||
|
assertEquals(0, directory.writeCount("artifact.json"))
|
||||||
|
}
|
||||||
|
|
||||||
private fun readCanonical(name: String): String = directory.read(name)
|
private fun readCanonical(name: String): String = directory.read(name)
|
||||||
|
|
||||||
private class FakeSafDirectory : ExchangeSafDirectory {
|
private class FakeSafDirectory : DirectExchangeDirectoryAccess {
|
||||||
private val contents = linkedMapOf<String, ByteArray>()
|
private val contents = linkedMapOf<String, ByteArray>()
|
||||||
private val creates = mutableMapOf<String, Int>()
|
private val creates = mutableMapOf<String, Int>()
|
||||||
val writeCounts = mutableMapOf<String, Int>()
|
val writeCounts = mutableMapOf<String, Int>()
|
||||||
var enumerationCount = 0
|
var enumerationCount = 0
|
||||||
private set
|
private set
|
||||||
|
var createdNameOverride: String? = null
|
||||||
|
var rawExactDuplicates = 1
|
||||||
|
private val discoveryHidden = mutableSetOf<String>()
|
||||||
|
|
||||||
fun seed(name: String, value: String) { contents[name] = value.toByteArray() }
|
fun seed(name: String, value: String) { contents[name] = value.toByteArray() }
|
||||||
|
fun seedRawOnly(name: String, value: String) {
|
||||||
|
seed(name, value)
|
||||||
|
discoveryHidden += name
|
||||||
|
}
|
||||||
fun read(name: String): String = contents.getValue(name).toString(Charsets.UTF_8)
|
fun read(name: String): String = contents.getValue(name).toString(Charsets.UTF_8)
|
||||||
fun canonicalCount(name: String): Int = if (contents.containsKey(name)) 1 else 0
|
fun canonicalCount(name: String): Int = if (contents.containsKey(name)) 1 else 0
|
||||||
fun createCount(name: String): Int = creates[name] ?: 0
|
fun createCount(name: String): Int = creates[name] ?: 0
|
||||||
fun writeCount(name: String): Int = writeCounts[name] ?: 0
|
fun writeCount(name: String): Int = writeCounts[name] ?: 0
|
||||||
|
fun names(): Set<String> = contents.keys
|
||||||
|
|
||||||
override fun listDirectChildren(): List<ExchangeSafDocument> {
|
override fun listDirectChildren(): List<DirectExchangeDocument> {
|
||||||
enumerationCount++
|
enumerationCount++
|
||||||
return contents.keys.map(::document)
|
return contents.keys.filterNot(discoveryHidden::contains).map(::document)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createJson(displayName: String): ExchangeSafDocument {
|
override fun findExact(displayName: String): List<DirectExchangeDocument> =
|
||||||
check(!contents.containsKey(displayName))
|
if (contents.containsKey(displayName)) {
|
||||||
|
List(rawExactDuplicates) { document(displayName) }
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createJson(displayName: String): DirectExchangeDocument {
|
||||||
|
val actualName = createdNameOverride ?: displayName
|
||||||
|
check(!contents.containsKey(actualName))
|
||||||
creates[displayName] = createCount(displayName) + 1
|
creates[displayName] = createCount(displayName) + 1
|
||||||
contents[displayName] = byteArrayOf()
|
contents[actualName] = byteArrayOf()
|
||||||
return document(displayName)
|
return document(actualName)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun openForRewrite(document: ExchangeSafDocument): OutputStream =
|
override fun openForRewrite(document: DirectExchangeDocument): OutputStream =
|
||||||
object : ByteArrayOutputStream() {
|
object : ByteArrayOutputStream() {
|
||||||
override fun close() {
|
override fun close() {
|
||||||
super.close()
|
super.close()
|
||||||
|
|
@ -221,8 +316,8 @@ class SyncBundlePublicationTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun document(name: String) = ExchangeSafDocument(
|
private fun document(name: String) = DirectExchangeDocument(
|
||||||
Uri.parse("content://trainlog-test/tree/Download%2FTrainlog/document/$name"),
|
File("/storage/emulated/0/Documents/Trainlog", name),
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,23 +25,51 @@ historical occurrence values; later local edits and peer reconciliation advance
|
||||||
lineage explicitly inside the same repository transaction as the current-profile
|
lineage explicitly inside the same repository transaction as the current-profile
|
||||||
change. The insert trigger owns only initial root creation.
|
change. The insert trigger owns only initial root creation.
|
||||||
|
|
||||||
|
Android schema v17 adds the separate `trainlog-ai-session-drafts` V1 proposal
|
||||||
|
collection. It is not a second active workout: the existing
|
||||||
|
`active_session_draft(id=1)` remains the exactly-one durable capture draft.
|
||||||
|
The companion is imported only after definitions, aliases, profile state,
|
||||||
|
catalogue, sessions, and equipment associations have been reconciled. It
|
||||||
|
accepts at most 256 strict, idempotent `aid_<uuid-v4>` proposals; each has at
|
||||||
|
most 64 contiguous ordered SETS entries, target sets 1..99 and REPS targets
|
||||||
|
1..999 (or the profile-required duration target). Entries must match the
|
||||||
|
resolved exercise profile and known equipment.
|
||||||
|
|
||||||
|
Pending proposals can be explicitly started or deleted. Start is one local
|
||||||
|
transaction: it refuses when the singleton active draft exists, copies targets
|
||||||
|
only, marks the proposal `started`, and removes its proposal entries. Delete
|
||||||
|
is gated by a destructive confirmation naming the proposal; Cancel, Back and
|
||||||
|
outside dismissal perform no mutation. Only explicit confirmation invokes the
|
||||||
|
existing repository operation, which marks it `deleted` and removes entries.
|
||||||
|
Both retained state rows are tombstones,
|
||||||
|
so an unchanged companion replay is a skip and cannot resurrect a started or
|
||||||
|
deleted proposal. Neither action manufactures performed sets, completed history,
|
||||||
|
MAX, or feedback.
|
||||||
|
|
||||||
The manual synchronization action publishes one prepared Android→PC bundle
|
The manual synchronization action publishes one prepared Android→PC bundle
|
||||||
before it exposes `trainlog-sync-request-v1.json`. The bundle contains mobile
|
before it exposes `trainlog-sync-request-v1.json`. The bundle contains mobile
|
||||||
V3, mobile equipment definitions, equipment associations V2, exercise aliases
|
V3, mobile equipment definitions, equipment associations V2, exercise aliases
|
||||||
V1, BODY ZONES V1, Training Feedback V2, and Exercise Profile State V1. The
|
V1, BODY ZONES V1, Training Feedback V2, and Exercise Profile State V1. The
|
||||||
request is not written if any required publication fails. The persisted,
|
request is not written if any required publication fails. After the user
|
||||||
user-authorized `Download/Trainlog` SAF tree is the canonical authority for
|
enables `MANAGE_EXTERNAL_STORAGE` in Android settings, the fixed direct path
|
||||||
both inbound and outbound exchange: MediaStore ownership and app-scoped row
|
`/storage/emulated/0/Documents/Trainlog` is the sole authority for inbound and
|
||||||
visibility are not consulted. Exact display names are rewritten through their
|
outbound exchange. Trainlog creates it if absent and requires a readable,
|
||||||
SAF document URI; absence creates the exact name once, and multiple exact names
|
writable directory. No tree URI, persisted URI permission, directory picker,
|
||||||
fail conservatively. Conflict copies such as `name (1).json` are historical
|
`DocumentFile`, `DocumentsContract`, or MediaStore row participates.
|
||||||
objects, never aliases of `name.json`, and are neither selected nor deleted.
|
|
||||||
One synchronization enumerates the direct SAF children once, builds a bounded
|
Each JSON payload is written to a uniquely named temporary file in that same
|
||||||
exact-name multimap, and shares that transaction-local snapshot across all
|
directory, flushed and fsync'd, then moved over the exact canonical filename
|
||||||
outbound artifacts and the request. Newly created documents update that map.
|
with atomic replacement where the filesystem supports it. This prevents SAF
|
||||||
The next synchronization enumerates again so PC/MTP changes are never hidden by
|
provider suffixes. Existing `name (N).json` files are unrelated historical
|
||||||
a long-lived cache. Snapshot, create and stream operations run on the I/O
|
objects and are never selected, overwritten, moved, or deleted. The next sync
|
||||||
dispatcher; Compose only launches the suspend coordinator and applies results.
|
enumerates again so PC/MTP changes are not hidden by a long-lived cache.
|
||||||
|
|
||||||
|
When permission is absent, synchronization fails before publication and the UI
|
||||||
|
offers **Autoriser l'accès au dossier Trainlog**. This opens the package-scoped
|
||||||
|
all-files settings screen, falling back to the global all-files screen only if
|
||||||
|
the OEM exposes no package activity. Android itself grants the permission; the
|
||||||
|
application never does. Legacy SAF preferences are ignored and removed without
|
||||||
|
touching `Download/Trainlog` or its recovery backup.
|
||||||
|
|
||||||
V3 preserves ordinary plan metadata atomically with occurrence identity,
|
V3 preserves ordinary plan metadata atomically with occurrence identity,
|
||||||
equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are
|
equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are
|
||||||
|
|
@ -475,7 +503,7 @@ bo_<uuid-v4>
|
||||||
Android maintains:
|
Android maintains:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Download/Trainlog/trainlog-mobile-export-v3.json
|
Documents/Trainlog/trainlog-mobile-export-v3.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The V3 snapshot is refreshed after relevant local changes, including exercise,
|
The V3 snapshot is refreshed after relevant local changes, including exercise,
|
||||||
|
|
@ -511,16 +539,17 @@ exact transferred completed rows are exported after finalization.
|
||||||
|
|
||||||
## 10. PC catalog access
|
## 10. PC catalog access
|
||||||
|
|
||||||
PC-created files are accessed through a persistent Storage Access Framework
|
PC-created files are accessed directly after the user enables Android's
|
||||||
grant.
|
all-files access for Trainlog.
|
||||||
|
|
||||||
The selected folder must be:
|
The selected folder must be:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Download/Trainlog
|
Documents/Trainlog
|
||||||
```
|
```
|
||||||
|
|
||||||
The Sync screen always permits changing the stored folder selection.
|
The Sync screen always exposes the system authorization settings. There is no
|
||||||
|
stored folder selection.
|
||||||
|
|
||||||
No application-data reset is required to fix a wrong folder choice.
|
No application-data reset is required to fix a wrong folder choice.
|
||||||
|
|
||||||
|
|
@ -538,10 +567,9 @@ Android writes:
|
||||||
trainlog-sync-request-v1.json
|
trainlog-sync-request-v1.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The request is resolved and rewritten through the same persisted SAF tree as
|
The request is replaced through the same direct `Documents/Trainlog` directory
|
||||||
the bundle. Android does not create a numbered request sibling merely because
|
as the bundle. No document provider can manufacture a numbered request sibling.
|
||||||
MediaStore cannot expose an MTP- or peer-created canonical object. The stable
|
The stable `request_id` remains the replay boundary.
|
||||||
`request_id` remains the replay boundary.
|
|
||||||
|
|
||||||
The application-start catalog import does not publish an outbound bundle.
|
The application-start catalog import does not publish an outbound bundle.
|
||||||
Exactly one explicit `Synchroniser maintenant` action invokes one export and
|
Exactly one explicit `Synchroniser maintenant` action invokes one export and
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ Android capture client
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
Android shared storage
|
Android shared storage
|
||||||
Download/Trainlog
|
Documents/Trainlog
|
||||||
|
|
|
|
||||||
| direct MTP
|
| direct MTP
|
||||||
v
|
v
|
||||||
|
|
@ -168,7 +168,7 @@ silently rendered as unclassified.
|
||||||
|
|
||||||
### Desktop
|
### Desktop
|
||||||
|
|
||||||
Desktop SQLite schema v17 is canonical long-term history. Its v15 -> v16
|
Desktop SQLite schema v18 is canonical long-term history. Its v15 -> v16
|
||||||
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
|
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
|
||||||
one-time backfill copies the linked catalogue mode without changing child rows
|
one-time backfill copies the linked catalogue mode without changing child rows
|
||||||
or stable identities. The additive v16 -> v17 migration adds deterministic
|
or stable identities. The additive v16 -> v17 migration adds deterministic
|
||||||
|
|
@ -182,6 +182,19 @@ their strictly-positive contracts. `session_exercises`
|
||||||
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
|
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
|
||||||
occur more than once in one session without identity fusion.
|
occur more than once in one session without identity fusion.
|
||||||
|
|
||||||
|
The additive v17 -> v18 migration adds a desktop-private AI proposal inbox:
|
||||||
|
imported headers, ordered target-only entries, an immutable `(aid_ draft_id,
|
||||||
|
payload digest)` import identity, and a nullable successful-publication cursor.
|
||||||
|
The cursor drains deterministic batches without deleting replay identity. It is
|
||||||
|
intentionally not the desktop TUI's
|
||||||
|
in-progress session state and it never creates actual sets. The separate
|
||||||
|
Android v17 migration adds a pending AI-proposal collection and permanent
|
||||||
|
`started`/`deleted` tombstones; the pre-existing
|
||||||
|
`active_session_draft(id=1)` remains the one capture draft. An explicit start
|
||||||
|
transaction copies a pending proposal's targets into that singleton and
|
||||||
|
tombstones the proposal; a delete transaction tombstones it. This keeps replay
|
||||||
|
idempotent without silently resurrecting user-dismissed work.
|
||||||
|
|
||||||
The additive v10 -> v11 migration creates direct exercise/body-zone relations
|
The additive v10 -> v11 migration creates direct exercise/body-zone relations
|
||||||
and a private synchronization baseline, then seeds only stable-ID mappings
|
and a private synchronization baseline, then seeds only stable-ID mappings
|
||||||
whose decision evidence is recorded in the manifest. It never rewrites an
|
whose decision evidence is recorded in the manifest. It never rewrites an
|
||||||
|
|
@ -336,7 +349,7 @@ No GVFS/FUSE mount is required.
|
||||||
Canonical exchange directory:
|
Canonical exchange directory:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Download/Trainlog
|
Documents/Trainlog
|
||||||
```
|
```
|
||||||
|
|
||||||
## 7. Shared synchronization engine
|
## 7. Shared synchronization engine
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ GATE_1_TRAINLOG_FORMAT_V1=PASS/FROZEN
|
||||||
GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
|
GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
|
||||||
|
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
TRAINLOG_AI_EXPORT_V1=PASS
|
||||||
|
TRAINLOG_AI_GDRIVE_SYNC_V1=PASS
|
||||||
|
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V11=PASS
|
DESKTOP_SCHEMA_V11=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V10=PASS
|
ANDROID_LOCAL_DATABASE_V10=PASS
|
||||||
|
|
@ -81,7 +84,7 @@ Implemented:
|
||||||
F7 actions, and restored focus after overlays/routes);
|
F7 actions, and restored focus after overlays/routes);
|
||||||
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
|
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
|
||||||
minimum terminal;
|
minimum terminal;
|
||||||
- SQLite schema v17, with stable ordered `session_exercises.entry_id`, an
|
- SQLite schema v18, with stable ordered `session_exercises.entry_id`, an
|
||||||
immutable occurrence-owned `tracking_mode` snapshot,
|
immutable occurrence-owned `tracking_mode` snapshot,
|
||||||
bounded durable current-profile revision ancestry,
|
bounded durable current-profile revision ancestry,
|
||||||
occurrence-level equipment identity, and desktop-local custom-equipment
|
occurrence-level equipment identity, and desktop-local custom-equipment
|
||||||
|
|
@ -110,6 +113,35 @@ Implemented:
|
||||||
- manual Android -> PC, PC -> Android, and bidirectional synchronization;
|
- manual Android -> PC, PC -> Android, and bidirectional synchronization;
|
||||||
- structured synchronization history and detail.
|
- structured synchronization history and detail.
|
||||||
|
|
||||||
|
`TRAINLOG_AI_SESSION_DRAFT_V1` is implemented but awaits one real external
|
||||||
|
Drive/Android-triggered bidirectional smoke test. The Drive source is the
|
||||||
|
single strict `TRAINLOG_AI_SESSION_DRAFT` V1 object at
|
||||||
|
`TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json`; after a
|
||||||
|
transactional, idempotent desktop import its exact fetched bytes are copied to
|
||||||
|
the archive by stable `aid_` ID. The inbox object remains available for logical
|
||||||
|
replay and archive/status convergence.
|
||||||
|
Desktop exports the separate `trainlog-ai-session-drafts-v1.json` companion to
|
||||||
|
Android in deterministic batches of at most 256 unpublished proposals. It
|
||||||
|
marks exactly a batch successfully published over MTP and retries an unchanged
|
||||||
|
batch after publication failure; source replay identities remain permanent.
|
||||||
|
The Android pending proposal collection is not the durable singleton
|
||||||
|
active capture draft: explicit start copies targets only and tombstones the
|
||||||
|
proposal in one transaction; explicit deletion tombstones it too. The 64-entry,
|
||||||
|
99-set, 999-rep and 256-proposal limits, profile compatibility and replay rules
|
||||||
|
are enforced. The always-visible **Brouillons** action reports the pending
|
||||||
|
proposal count, and a successful catalogue/companion import invalidates the
|
||||||
|
pending-screen query so newly imported proposals appear without restarting the
|
||||||
|
application. Inbound `NONE`, `IMPORTED`, `ALREADY_IMPORTED`, `REJECTED`,
|
||||||
|
`DRIVE_FAIL`, and `ARCHIVE_FAIL` outcomes remain visible in successful sync
|
||||||
|
history. A transport/archive failure never rolls back a committed import.
|
||||||
|
|
||||||
|
The September 2026 connected-device diagnosis found that the installed APK was
|
||||||
|
the older 2026-09-13 build: its copied local database was still schema v16 and
|
||||||
|
had no `ai_session_drafts` table. That APK therefore could not have imported the
|
||||||
|
Android companion. After a current APK is installed, one new synchronization is
|
||||||
|
required to import the still-pending companion. This diagnosis is not the real
|
||||||
|
Drive/Android acceptance smoke test, which remains pending.
|
||||||
|
|
||||||
Primary navigation is now shared with Android:
|
Primary navigation is now shared with Android:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -130,7 +162,7 @@ status can advance.
|
||||||
Implemented:
|
Implemented:
|
||||||
|
|
||||||
- native Kotlin/Compose application;
|
- native Kotlin/Compose application;
|
||||||
- local SQLite database v11, with non-destructive v3 -> v11 migration;
|
- local SQLite database v17, with non-destructive migrations through v17;
|
||||||
- one durable active-session draft, Home resume and raw-form restoration;
|
- one durable active-session draft, Home resume and raw-form restoration;
|
||||||
- `BODY_FOCUS_HOME_V1`: a compact accessible Home front/back BODY ZONES map,
|
- `BODY_FOCUS_HOME_V1`: a compact accessible Home front/back BODY ZONES map,
|
||||||
textual top-three exposure ranking and Catalogue/Statistics drill-through;
|
textual top-three exposure ranking and Catalogue/Statistics drill-through;
|
||||||
|
|
@ -151,7 +183,8 @@ Implemented:
|
||||||
- local session history/detail;
|
- local session history/detail;
|
||||||
- body measurements;
|
- body measurements;
|
||||||
- automatic mobile snapshot maintenance;
|
- automatic mobile snapshot maintenance;
|
||||||
- PC catalog application through a persistent SAF folder grant;
|
- PC catalog application through user-enabled direct all-files access to the
|
||||||
|
fixed `Documents/Trainlog` directory;
|
||||||
- Android-triggered synchronization request;
|
- Android-triggered synchronization request;
|
||||||
- synchronization receipt handling.
|
- synchronization receipt handling.
|
||||||
|
|
||||||
|
|
@ -189,7 +222,7 @@ The implemented read-only training-knowledge layer loads shared versioned JSON
|
||||||
catalogs as the sole authored scientific source, generates the immutable C
|
catalogs as the sole authored scientific source, generates the immutable C
|
||||||
catalog representation, and loads the same assets on Android. It has no
|
catalog representation, and loads the same assets on Android. It has no
|
||||||
database migration, no auto-seeding, and no synchronization artifact. The
|
database migration, no auto-seeding, and no synchronization artifact. The
|
||||||
desktop database is schema v17 and Android is schema v16. Schema v13 is additive: exercise
|
desktop database is schema v18 and Android is schema v17. Schema v13 is additive: exercise
|
||||||
rows own machine-facing load semantics, optional legacy equipment provenance,
|
rows own machine-facing load semantics, optional legacy equipment provenance,
|
||||||
an optional scientific profile, and an explicit resolved/unresolved science
|
an optional scientific profile, and an explicit resolved/unresolved science
|
||||||
state. Legacy equipment tables and occurrence columns remain intact.
|
state. Legacy equipment tables and occurrence columns remain intact.
|
||||||
|
|
@ -217,7 +250,7 @@ and a C role-only query mismatch (HIGH). One bounded repair chain resolved all
|
||||||
four findings; independent repair verification returned
|
four findings; independent repair verification returned
|
||||||
`FINAL_REVIEW_REPAIR_VERIFICATION=PASS` and
|
`FINAL_REVIEW_REPAIR_VERIFICATION=PASS` and
|
||||||
`TRAINING_KNOWLEDGE_V1_ENGINEERING_REVIEW=PASS`. Fresh final validation passed:
|
`TRAINING_KNOWLEDGE_V1_ENGINEERING_REVIEW=PASS`. Fresh final validation passed:
|
||||||
strict build, 42 Meson tests, eight knowledge and four temporal Python tests,
|
strict build, 56 Meson tests, eight knowledge and four temporal Python tests,
|
||||||
knowledge/JSON/import validators, three C17 headers, affected C knowledge and
|
knowledge/JSON/import validators, three C17 headers, affected C knowledge and
|
||||||
context tests under ASan/UBSan plus Python timestamp validation, normal and
|
context tests under ASan/UBSan plus Python timestamp validation, normal and
|
||||||
sanitized 12-form temporal probes, and Android 56 tests with zero failures or
|
sanitized 12-form temporal probes, and Android 56 tests with zero failures or
|
||||||
|
|
@ -237,7 +270,7 @@ visual validation or manual MTP validation is claimed for this tranche.
|
||||||
Canonical exchange directory:
|
Canonical exchange directory:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Download/Trainlog
|
Documents/Trainlog
|
||||||
```
|
```
|
||||||
|
|
||||||
Artifacts:
|
Artifacts:
|
||||||
|
|
@ -268,6 +301,15 @@ The desktop TUI and `trainlog-syncd` share `trainlog_sync_run()`. Its explicit
|
||||||
inbound-then-outbound respectively. Each direction reconciles equipment
|
inbound-then-outbound respectively. Each direction reconciles equipment
|
||||||
definitions V1 before V2 artifacts that reference them.
|
definitions V1 before V2 artifacts that reference them.
|
||||||
|
|
||||||
|
After the shared engine has reached a definitive successful result (including
|
||||||
|
publication of an Android-request receipt when applicable), the desktop runs
|
||||||
|
`TRAINLOG_AI_EXPORT_V1` and then the external `rclone copyto` publication to
|
||||||
|
`TrainLog Gdrive:Trainlog/AI/trainlog_ai_export_v1.json`. The TUI summary and
|
||||||
|
`trainlog-syncd` log expose `SYNC`, `AI_EXPORT`, and `GDRIVE_UPLOAD` separately.
|
||||||
|
An export or Drive failure never retroactively changes the successful Trainlog
|
||||||
|
sync result. Android does not execute `rclone`; all rclone/OAuth configuration
|
||||||
|
remains external under the user's normal `~/.config/rclone/` directory.
|
||||||
|
|
||||||
Each structured local sync-history run persists its selected `a`, `p`, or `b`
|
Each structured local sync-history run persists its selected `a`, `p`, or `b`
|
||||||
direction. `direction inconnue` is reserved for legacy history rows whose
|
direction. `direction inconnue` is reserved for legacy history rows whose
|
||||||
direction was not recorded; it is never used for a current run.
|
direction was not recorded; it is never used for a current run.
|
||||||
|
|
@ -428,13 +470,20 @@ unchanged. Live sync runs `sy_0b00dc46-d899-4865-8718-c95085a380b2` and
|
||||||
second run reported zero additions/reconciliations, both stores retained the
|
second run reported zero additions/reconciliations, both stores retained the
|
||||||
same 32-relation stable-ID hash, and Android application tables plus companion
|
same 32-relation stable-ID hash, and Android application tables plus companion
|
||||||
exercise states compared equal to the first pass. Android now resolves the
|
exercise states compared equal to the first pass. Android now resolves the
|
||||||
exact request and every outbound artifact through the persisted authorized SAF
|
exact request and every outbound artifact through direct filesystem access to
|
||||||
tree, independently of MediaStore ownership visibility. Historical numbered
|
`Documents/Trainlog`. Historical numbered copies remain untouched. One
|
||||||
copies remain untouched. One transaction-local exact-name snapshot is shared
|
transaction-local exact-name snapshot is shared
|
||||||
by the complete export and request on the I/O dispatcher; each later sync takes
|
by the complete export and request on the I/O dispatcher; each later sync takes
|
||||||
a fresh snapshot. The redundant application-start export was removed, and
|
a fresh snapshot. The redundant application-start export was removed, and
|
||||||
request-ID replay protection remains authoritative.
|
request-ID replay protection remains authoritative.
|
||||||
|
|
||||||
|
`TRAINLOG_ANDROID_DIRECT_STORAGE_V1=PASS`. Android no longer uses SAF for
|
||||||
|
exchange artifacts. User-enabled all-files access gates the fixed
|
||||||
|
`/storage/emulated/0/Documents/Trainlog` directory; direct temporary-file
|
||||||
|
replacement preserves canonical names. A physical Samsung Android 16 one-sync
|
||||||
|
and its identical second run completed successfully without suffixes, and the
|
||||||
|
recovered `Dos + biceps` proposal remained visible after replay.
|
||||||
|
|
||||||
## Current implementation cursor
|
## Current implementation cursor
|
||||||
|
|
||||||
Body Zones V1, exercise reconciliation, definition-first V2 synchronization
|
Body Zones V1, exercise reconciliation, definition-first V2 synchronization
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ is a later, separately versioned migration.
|
||||||
## 1. Status
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION=17
|
TRAINLOG_DATABASE_SCHEMA_VERSION=18
|
||||||
DATABASE_SCHEMA_V17=PASS
|
DATABASE_SCHEMA_V18=IMPLEMENTED_VALIDATION_PENDING
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ PRAGMA user_version;
|
||||||
Current value:
|
Current value:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
12
|
18
|
||||||
```
|
```
|
||||||
|
|
||||||
The independent actual-set loads documented in the current desktop, Android
|
The independent actual-set loads documented in the current desktop, Android
|
||||||
|
|
@ -90,6 +90,25 @@ direct zones, and rejects profile or primary-zone conflicts before mutation.
|
||||||
A schema fixture must represent the real historical structure. Rewriting only
|
A schema fixture must represent the real historical structure. Rewriting only
|
||||||
`user_version` is not an acceptable migration test.
|
`user_version` is not an acceptable migration test.
|
||||||
|
|
||||||
|
Version 18 is additive and transactional. It creates `ai_session_drafts`,
|
||||||
|
`ai_session_draft_entries`, and `ai_session_draft_imports` for the strict
|
||||||
|
`TRAINLOG_AI_SESSION_DRAFT_V1` Drive inbox. A desktop row retains the source
|
||||||
|
`aid_<uuid-v4>` ID, canonical payload SHA-256 digest, source exercise identity,
|
||||||
|
archive status/error, and nullable successful-publication timestamp separately
|
||||||
|
from target-only plan values. Entry rows do not store notes. The
|
||||||
|
`(draft_id, digest)` identity makes an exact replay a skip and makes a changed
|
||||||
|
payload under the same identity an explicit conflict. A failed child insert
|
||||||
|
rolls back header, entries, and identity together. No completed session,
|
||||||
|
performed set, MAX, feedback, active draft, or frozen exchange artifact is
|
||||||
|
rewritten.
|
||||||
|
|
||||||
|
Android schema v17 is likewise additive but has different local ownership:
|
||||||
|
its `ai_session_drafts` pending collection and entries coexist with—not replace—
|
||||||
|
the exactly-one `active_session_draft`. `started` and `deleted` state rows are
|
||||||
|
durable tombstones. Starting or deleting a pending proposal atomically removes
|
||||||
|
its entries while retaining that state row, so companion replay cannot recreate
|
||||||
|
it.
|
||||||
|
|
||||||
Schema v14 adds `exercise_feedback` and `session_followups` without rewriting
|
Schema v14 adds `exercise_feedback` and `session_followups` without rewriting
|
||||||
v13 facts. Both use stable immutable IDs, exact timestamps, non-empty bounded
|
v13 facts. Both use stable immutable IDs, exact timestamps, non-empty bounded
|
||||||
text at API boundaries, and parent-owned cascading foreign keys to
|
text at API boundaries, and parent-owned cascading foreign keys to
|
||||||
|
|
@ -447,7 +466,7 @@ distance km
|
||||||
|
|
||||||
The Android SQLite database is independent.
|
The Android SQLite database is independent.
|
||||||
|
|
||||||
Current Android-local version: **10**. The explicit migration chain adds the
|
Current Android-local version: **17**. The explicit migration chain adds the
|
||||||
durable draft in v4, equipment references in v5, per-set load in v6, occurrence
|
durable draft in v4, equipment references in v5, per-set load in v6, occurrence
|
||||||
identity/multi-occurrence support in v7, and the widened custom-equipment
|
identity/multi-occurrence support in v7, and the widened custom-equipment
|
||||||
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw
|
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,65 @@
|
||||||
# Trainlog Exchange Format v1
|
# Trainlog Exchange Format v1
|
||||||
|
|
||||||
|
## TRAINLOG_AI_SESSION_DRAFT_V1
|
||||||
|
|
||||||
|
`TRAINLOG_AI_SESSION_DRAFT_V1` is a separately versioned AI-proposal contract,
|
||||||
|
not an extension of frozen `TRAINLOG_FORMAT_V1`. Its one-object Drive source is
|
||||||
|
`TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json` with exact root
|
||||||
|
`{"format":"TRAINLOG_AI_SESSION_DRAFT","version":1,"draft":...}`.
|
||||||
|
The source `draft_id` is an `aid_<uuid-v4>` identity, `created_at` is canonical
|
||||||
|
UTC, `session_type` is exactly `training`, and all object keys are exact.
|
||||||
|
|
||||||
|
Text is optional only where stated and, when present, nonempty and trimmed:
|
||||||
|
title is limited to 120 characters and draft notes to 2,000. Entries have no
|
||||||
|
notes field; it is rejected as an unknown key. A source has 1..64 contiguous
|
||||||
|
ordered entries. Each entry resolves a
|
||||||
|
known exercise (or explicit alias) and is a target-only `SETS` plan: 1..99
|
||||||
|
sets, 1..999 reps for REPS tracking or a 1..86,400-second duration for DURATION
|
||||||
|
tracking, optional positive weight no greater than 10,000, and 0..86,400 rest.
|
||||||
|
Continuous exercises and a target metric inconsistent with the resolved
|
||||||
|
profile are rejected. This contract cannot encode performed sets.
|
||||||
|
|
||||||
|
Desktop records the source digest with its `aid_` identity. The exact same
|
||||||
|
payload is an idempotent skip; changed content with that identity is a conflict.
|
||||||
|
After a successful import commit only, the exact validated local snapshot is
|
||||||
|
copied to `TrainLog Gdrive:Trainlog/AI/archive/<aid>.json`. The mutable inbox
|
||||||
|
source is not moved or deleted; logical replay makes archive copy and local
|
||||||
|
status failures retryable without undoing the import. The outbound
|
||||||
|
`trainlog-ai-session-drafts` V1 companion is a distinct strict object for
|
||||||
|
Android, with at most 256 unpublished drafts per publication. Only after that
|
||||||
|
artifact is published successfully does desktop transactionally mark its exact
|
||||||
|
IDs published; a failed publication resends the same deterministic batch, and
|
||||||
|
later normal syncs deliver the remainder. The permanent source import ledger is
|
||||||
|
not pruned by publication. Android's durable `started` and `deleted`
|
||||||
|
tombstones prevent an unchanged companion replay from recreating a proposal.
|
||||||
|
The required real Drive/Android-triggered smoke validation is still pending.
|
||||||
|
|
||||||
|
## TRAINLOG_AI_EXPORT_V1
|
||||||
|
|
||||||
|
`TRAINLOG_AI_EXPORT_V1` is a local, user-controlled JSON export of raw desktop
|
||||||
|
history for manual external analysis. It is not a synchronization format and
|
||||||
|
contains no recommendation, progression, or inferred attribution.
|
||||||
|
|
||||||
|
Create `trainlog_ai_export_v1.json` in the current directory with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/export_ai_history.py --database ~/.local/share/trainlog/trainlog.db
|
||||||
|
```
|
||||||
|
|
||||||
|
An explicit destination may be supplied as the final argument. The exporter
|
||||||
|
opens SQLite with `mode=ro` and `PRAGMA query_only=ON`; it performs no migration
|
||||||
|
and no persistent write. Format `TRAINLOG_AI_EXPORT`, version `1`, uses
|
||||||
|
`generated_at` as an RFC 3339 UTC instant at whole-second precision, rendered
|
||||||
|
as `YYYY-MM-DDTHH:MM:SSZ`. All arrays have explicit deterministic ordering;
|
||||||
|
only `generated_at` varies between runs.
|
||||||
|
|
||||||
|
Immediate raw feedback is nested under the exact occurrence identified by
|
||||||
|
`session_id` (the owning session), `entry_id`, and `exercise_id`. Session
|
||||||
|
follow-ups remain in `session_followups` at session scope. The exporter never
|
||||||
|
infers that an occurrence caused a next-day observation. The current schema
|
||||||
|
stores revisioned free text, not structured soreness, intensity, body-zone, or
|
||||||
|
suspected-exercise fields, so those absent facts are not invented in V1.
|
||||||
|
|
||||||
## 1. Status
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,21 @@ Historical implementation detail belongs in Git history and `docs/reviews`.
|
||||||
The published Trainlog JSON v1 compatibility boundary remains frozen unless a
|
The published Trainlog JSON v1 compatibility boundary remains frozen unless a
|
||||||
future feature explicitly introduces a new version.
|
future feature explicitly introduces a new version.
|
||||||
|
|
||||||
|
## TRAINLOG_AI_SESSION_DRAFT_V1 — implementation validation pending
|
||||||
|
|
||||||
|
The implemented proposal path is deliberately separate from completed-session
|
||||||
|
exchange and the singleton capture draft. A strict single Drive source,
|
||||||
|
`TRAINLOG_AI_SESSION_DRAFT` V1, imports target-only SETS proposals into desktop
|
||||||
|
schema v18, archive-copies the exact fetched snapshot only after commit while
|
||||||
|
retaining the inbox object for logical replay, then produces the bounded
|
||||||
|
`trainlog-ai-session-drafts` V1 Android companion in deterministic batches of
|
||||||
|
at most 256 unpublished proposals. Only successful MTP publication advances
|
||||||
|
the desktop cursor; replay identities remain permanent. Android v17 retains a
|
||||||
|
pending collection, while explicit start/delete creates durable tombstones so
|
||||||
|
replay is idempotent and cannot resurrect proposals. The remaining gate is one
|
||||||
|
real Drive plus Android-triggered bidirectional synchronization smoke test;
|
||||||
|
therefore this item is `VALIDATION_PENDING` and is not PASS.
|
||||||
|
|
||||||
## Completed foundation
|
## Completed foundation
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Machine-exercise Phase 1 compatibility
|
## Machine-exercise Phase 1 compatibility
|
||||||
|
|
||||||
Desktop schema v17 and Android schema v16 retain mobile export V3, readable V1/V2 imports,
|
Desktop schema v18 and Android schema v17 retain mobile export V3, readable V1/V2 imports,
|
||||||
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
||||||
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
||||||
silently added to a frozen artifact: stable exercise IDs and canonical names
|
silently added to a frozen artifact: stable exercise IDs and canonical names
|
||||||
|
|
@ -43,6 +43,7 @@ EXPLICIT_MAX_RESULTS_V2=PASS
|
||||||
SESSION_GENERATOR_V1=PASS
|
SESSION_GENERATOR_V1=PASS
|
||||||
BODY_ZONE_SYNC_V1=PASS
|
BODY_ZONE_SYNC_V1=PASS
|
||||||
BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS
|
BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS
|
||||||
|
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
|
||||||
|
|
||||||
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
|
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
|
||||||
```
|
```
|
||||||
|
|
@ -63,13 +64,22 @@ v1 format.
|
||||||
Canonical Android shared-storage directory:
|
Canonical Android shared-storage directory:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Download/Trainlog
|
Documents/Trainlog
|
||||||
```
|
```
|
||||||
|
|
||||||
Desktop accesses this directory through direct MTP.
|
Desktop accesses this directory through direct MTP.
|
||||||
|
|
||||||
Android accesses PC-created artifacts through a persistent Storage Access
|
Android accesses this exact public directory directly after the user enables
|
||||||
Framework folder grant.
|
`MANAGE_EXTERNAL_STORAGE` in Android settings. It checks
|
||||||
|
`Environment.isExternalStorageManager()`, creates the directory when necessary,
|
||||||
|
and never uses a SAF tree URI, directory picker, `DocumentFile`, or
|
||||||
|
`DocumentsContract` for Trainlog artifacts.
|
||||||
|
|
||||||
|
The desktop resolves `Documents` and its direct `Trainlog` child for the whole
|
||||||
|
run. All outbound publications target that folder exclusively. A historical
|
||||||
|
`Download/Trainlog` tree may remain on the device for manual recovery and may
|
||||||
|
be inspected by explicitly bounded legacy tooling, but the active sync engine
|
||||||
|
does not publish to it or let it override an artifact in the new endpoint.
|
||||||
|
|
||||||
## 3. Artifact table
|
## 3. Artifact table
|
||||||
|
|
||||||
|
|
@ -87,6 +97,7 @@ Framework folder grant.
|
||||||
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
||||||
| PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
|
| PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
|
||||||
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
|
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
|
||||||
|
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1 companion |
|
||||||
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||||
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
|
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
|
||||||
|
|
||||||
|
|
@ -115,7 +126,8 @@ falls back to an older candidate when the newest one is malformed. Definitions
|
||||||
reconcile before the V2 snapshot, so a valid custom `equipment_id` is known
|
reconcile before the V2 snapshot, so a valid custom `equipment_id` is known
|
||||||
before a session may reference it. This prevents an older canonical object from
|
before a session may reference it. This prevents an older canonical object from
|
||||||
being mistaken for Android's current data while preserving every file in
|
being mistaken for Android's current data while preserving every file in
|
||||||
`Download/Trainlog`.
|
the historical `Download/Trainlog` tree without selecting it as the active
|
||||||
|
publication endpoint.
|
||||||
|
|
||||||
V2 is a separate format: every session entry has an `entry_id`, `position`,
|
V2 is a separate format: every session entry has an `entry_id`, `position`,
|
||||||
metrics, actual loads and optional equipment identity. This permits two
|
metrics, actual loads and optional equipment identity. This permits two
|
||||||
|
|
@ -451,6 +463,25 @@ PC -> Android catalog count
|
||||||
Android accepts a receipt only when its `request_id` matches the pending
|
Android accepts a receipt only when its `request_id` matches the pending
|
||||||
request.
|
request.
|
||||||
|
|
||||||
|
For `TRAINLOG_AI_SESSION_DRAFT_V1`, Drive—not `Documents/Trainlog`—is the
|
||||||
|
single inbound source: `TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json`.
|
||||||
|
The desktop fetches it shell-free, parses the strict one-draft source, and
|
||||||
|
commits it idempotently before copying the exact validated local snapshot with
|
||||||
|
shell-free `rclone copyto` to
|
||||||
|
`TrainLog Gdrive:Trainlog/AI/archive/<aid>.json`. The mutable inbox object is
|
||||||
|
never moved or deleted, so same-name replacement cannot alter the archived
|
||||||
|
bytes or be removed by archiving. A missing source, transport failure, or
|
||||||
|
archive failure is reported explicitly; logical inbox replay retains a
|
||||||
|
retryable desktop status and never undoes the committed import. The desktop
|
||||||
|
outbound companion selects at most 256 not-yet-published proposals in stable
|
||||||
|
creation/identity order. Successful MTP publication transactionally marks only
|
||||||
|
that exact batch; failure leaves it unchanged for retry, while later normal
|
||||||
|
syncs drain further batches. The permanent Drive import ledger is independent.
|
||||||
|
The inbound outcome (`NONE`, `IMPORTED`, `ALREADY_IMPORTED`, `REJECTED`,
|
||||||
|
`DRIVE_FAIL`, or `ARCHIVE_FAIL`) is retained in the successful sync report and
|
||||||
|
history; fetched-invalid, Drive, and archive outcomes do not fail an otherwise
|
||||||
|
successful Android/PC synchronization.
|
||||||
|
|
||||||
## 10. Shared desktop engine
|
## 10. Shared desktop engine
|
||||||
|
|
||||||
Canonical implementation:
|
Canonical implementation:
|
||||||
|
|
@ -472,16 +503,37 @@ Android-triggered path:
|
||||||
Android request
|
Android request
|
||||||
-> trainlog-syncd
|
-> trainlog-syncd
|
||||||
-> shared engine
|
-> shared engine
|
||||||
|
-> Drive AI draft fetch/import/archive attempt
|
||||||
|
-> PC -> Android AI draft companion publication
|
||||||
-> receipt
|
-> receipt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Only after either path has fully succeeded, the same desktop engine runs:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SYNC=PASS
|
||||||
|
-> tools/export_ai_history.py
|
||||||
|
-> AI_EXPORT=PASS
|
||||||
|
-> rclone copyto trainlog_ai_export_v1.json
|
||||||
|
"TrainLog Gdrive:Trainlog/AI/trainlog_ai_export_v1.json"
|
||||||
|
-> GDRIVE_UPLOAD=PASS|FAIL
|
||||||
|
```
|
||||||
|
|
||||||
|
`AI_EXPORT=FAIL` skips Drive publication so an older artifact cannot be
|
||||||
|
uploaded as the result of the new synchronization. Export and Drive are
|
||||||
|
best-effort post-sync operations: either may fail visibly while the completed
|
||||||
|
Trainlog synchronization remains successful. Android never executes rclone,
|
||||||
|
and Trainlog stores neither its configuration nor Google OAuth secrets.
|
||||||
|
|
||||||
The engine has three explicit modes:
|
The engine has three explicit modes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
a Android -> PC: definition V1 -> mobile V3 -> body zones V1 -> association V2; no publish
|
a Android -> PC: definition V1 -> mobile V3 -> body zones V1 -> association V2
|
||||||
p PC -> Android: definition V1 -> catalog V1 -> body zones V1 -> mobile V3
|
-> Drive AI draft midpoint; no Android publish
|
||||||
(including bodies) -> association V2; no receive
|
p PC -> Android: Drive AI draft midpoint -> AI-drafts companion -> profile
|
||||||
b bidirectional: complete inbound sequence, then complete outbound sequence
|
state/aliases/feedback -> definition V1 -> catalog V1 -> body zones V1 ->
|
||||||
|
mobile V3 (including bodies) -> association V2; no Android receive
|
||||||
|
b bidirectional: complete inbound sequence -> Drive AI draft midpoint -> complete outbound sequence
|
||||||
```
|
```
|
||||||
|
|
||||||
In both directions, definitions are reconciled before V2 artifacts that may
|
In both directions, definitions are reconciled before V2 artifacts that may
|
||||||
|
|
@ -731,3 +783,12 @@ the importer prepares `UPDATE exercises`; the persisted guarded profile trigger
|
||||||
therefore remains valid even when the authoritative incoming tip makes its body
|
therefore remains valid even when the authoritative incoming tip makes its body
|
||||||
a no-op. Replaying the same artifact neither changes the current revision nor
|
a no-op. Replaying the same artifact neither changes the current revision nor
|
||||||
adds history rows.
|
adds history rows.
|
||||||
|
|
||||||
|
Desktop Python companions, including the alias importer, receive the explicit
|
||||||
|
XDG-resolved database path. Their scripts resolve from the Meson-configured
|
||||||
|
repository tools directory rather than from the installed executable's parent
|
||||||
|
layout; `TRAINLOG_TOOLS_DIR` may explicitly override that directory for a
|
||||||
|
packaged user-session deployment. Before every invocation the bounded combined
|
||||||
|
stdout/stderr result is truncated. Resolution, launch, argparse, exception,
|
||||||
|
signal, and non-zero-exit diagnostics therefore describe only the current run
|
||||||
|
and are retained in the synchronization error/history when the run fails.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
# Tests and validation
|
# Tests and validation
|
||||||
|
|
||||||
|
The `ai_history_export` test covers an empty database, a simple session,
|
||||||
|
distinct multi-occurrences of one exercise, ordered sets, exact occurrence
|
||||||
|
feedback, session-owned follow-up feedback, JSON escaping/UTF-8, MAX history,
|
||||||
|
body measurements, deterministic content, and byte-for-byte database
|
||||||
|
immutability.
|
||||||
|
|
||||||
## 1. Principle
|
## 1. Principle
|
||||||
|
|
||||||
A Trainlog feature is not complete without relevant validation.
|
A Trainlog feature is not complete without relevant validation.
|
||||||
|
|
@ -115,7 +121,7 @@ app_shell
|
||||||
```
|
```
|
||||||
|
|
||||||
The current desktop suite, including APP_SHELL_V1 production-transition
|
The current desktop suite, including APP_SHELL_V1 production-transition
|
||||||
coverage, is 48/48. The following generator-specific checkpoint counts remain
|
coverage and the AI-draft exchange regressions, is 56/56. The following generator-specific checkpoint counts remain
|
||||||
historical evidence:
|
historical evidence:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -388,6 +394,43 @@ reprocessing one request as a new one.
|
||||||
The TUI also invokes the same engine manually and exposes structured run
|
The TUI also invokes the same engine manually and exposes structured run
|
||||||
details.
|
details.
|
||||||
|
|
||||||
|
`post_sync_ai_drive` validates strict export-before-upload ordering, skips
|
||||||
|
upload after export failure, and reports unavailable/failing rclone. The
|
||||||
|
production sync wiring test exercises successful TUI and Android triggers
|
||||||
|
through `trainlog_sync_run()`, uses an isolated fake rclone, and proves that
|
||||||
|
failed sync runs do not invoke Drive publication. Post-sync failures are
|
||||||
|
non-fatal to the already successful Trainlog sync by contract.
|
||||||
|
|
||||||
|
`ai_session_draft` validates the strict desktop source parser and schema v18
|
||||||
|
transaction: exact/unknown-key rejection, `aid_` identity, alias resolution,
|
||||||
|
target-only SETS compatibility, 64-entry/99-set/999-rep bounds, exact replay
|
||||||
|
skip, changed-payload conflict, deterministic 256-at-a-time publication across
|
||||||
|
257 drafts, failed-publication retry, permanent import ledger, and full rollback on
|
||||||
|
an injected child failure. Its fake-rclone seam also verifies that the exact
|
||||||
|
fetched bytes are archive-copied only after import, same-name inbox replacement
|
||||||
|
is preserved, stale temporary content cannot import, and archive/status failure
|
||||||
|
converges through idempotent logical replay. Production orchestration
|
||||||
|
tests retain `DRIVE_FAIL`, fetched-invalid `REJECTED`, and `ARCHIVE_FAIL` in a
|
||||||
|
successful report/history. Android repository tests cover the 256-proposal
|
||||||
|
companion limit, full validation before replay skip, strict optional-text
|
||||||
|
trimming, replay idempotence, start/delete
|
||||||
|
tombstones, target-only copy to the unchanged singleton active draft, and
|
||||||
|
catalog/profile/equipment prerequisite ordering. Focused Android UI wiring tests
|
||||||
|
also cover pending-only deterministic listing, started/deleted exclusion, the
|
||||||
|
always-visible **Brouillons** count without losing manual/history actions,
|
||||||
|
proposal title/target details, inert display with no automatic active draft,
|
||||||
|
external-revision refresh after companion import, and the existing transactional
|
||||||
|
start path. These are automated fake
|
||||||
|
transport tests; no real Drive automated test exists. The final real Drive plus
|
||||||
|
Android-triggered bidirectional smoke test remains manual and
|
||||||
|
`TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING`.
|
||||||
|
|
||||||
|
A September 2026 connected-device inspection established that the previously
|
||||||
|
installed 2026-09-13 APK still owned a schema-v16 database without
|
||||||
|
`ai_session_drafts`; it could not import the companion. Installing the current
|
||||||
|
APK and synchronizing once is therefore a prerequisite for the remaining real
|
||||||
|
acceptance test, not evidence that the acceptance test has passed.
|
||||||
|
|
||||||
## 9. Sanitizers
|
## 9. Sanitizers
|
||||||
|
|
||||||
For meaningful C checkpoints:
|
For meaningful C checkpoints:
|
||||||
|
|
@ -462,7 +505,7 @@ Coverage proves:
|
||||||
and footer F6/F7 single-occurrence output at 120x35, 100x30, 80x24, and
|
and footer F6/F7 single-occurrence output at 120x35, 100x30, 80x24, and
|
||||||
72x20.
|
72x20.
|
||||||
|
|
||||||
Historical validation checkpoint (the current desktop suite is 48/48):
|
Historical validation checkpoint (the current desktop suite is 56/56):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
39/39 Meson tests PASS
|
39/39 Meson tests PASS
|
||||||
|
|
@ -496,12 +539,17 @@ Historical validation checkpoint (the current desktop suite is 48/48):
|
||||||
## 13. Android session draft v1
|
## 13. Android session draft v1
|
||||||
|
|
||||||
Android schema v4 introduced one durable active draft; the current additive
|
Android schema v4 introduced one durable active draft; the current additive
|
||||||
chain reaches schema v13 without clearing completed history or the draft. The
|
chain reaches schema v17 without clearing completed history or the draft. The
|
||||||
explicit v10 -> v11 migration adds optional planning metadata while preserving
|
explicit v10 -> v11 migration adds optional planning metadata while preserving
|
||||||
existing rows with `load_mode=none`, zero rest and NULL targets. The
|
existing rows with `load_mode=none`, zero rest and NULL targets. The
|
||||||
v11 -> v12 migration adds the durable flattened exercise-alias table. Its exact
|
v11 -> v12 migration adds the durable flattened exercise-alias table. Its exact
|
||||||
physical-v11 fixture compares every pre-existing table cell before and after
|
physical-v11 fixture compares every pre-existing table cell before and after
|
||||||
migration, requires the new alias table to be empty, and checks foreign keys.
|
migration, requires the new alias table to be empty, and checks foreign keys.
|
||||||
|
The dedicated physical v16 -> v17 fixture preserves representative completed
|
||||||
|
history and the active draft, adds empty AI proposal tables, and checks SQLite
|
||||||
|
integrity and foreign keys. AI companion parser coverage accepts title/notes
|
||||||
|
at their 120/2,000 Unicode-code-point limits (including non-BMP characters)
|
||||||
|
and rejects 121/2,001 code points atomically.
|
||||||
Host coverage includes exercise shapes and raw partial text, fresh repository
|
Host coverage includes exercise shapes and raw partial text, fresh repository
|
||||||
restore, remove/discard, atomic
|
restore, remove/discard, atomic
|
||||||
finalization and repeated-finalize rejection, rollback, catalog reconciliation,
|
finalization and repeated-finalize rejection, rollback, catalog reconciliation,
|
||||||
|
|
@ -728,23 +776,48 @@ import with equal and evolved tracking/recording/data-field profiles, strict
|
||||||
snapshot-local payload rejection, unknown identity rejection, unchanged
|
snapshot-local payload rejection, unknown identity rejection, unchanged
|
||||||
historical values, unchanged current profile history, and idempotent replay.
|
historical values, unchanged current profile history, and idempotent replay.
|
||||||
`SyncBundlePublicationTest` exercises the production request coordinator and a
|
`SyncBundlePublicationTest` exercises the production request coordinator and a
|
||||||
synthetic user-authorized SAF directory whose exact BODY ZONES canonical is
|
synthetic direct exchange directory. The exact BODY ZONES file is rewritten
|
||||||
deliberately unavailable through MediaStore. The exact SAF object is rewritten
|
beside 31 preserved historical copies, no `(32)` copy occurs, all seven
|
||||||
beside 31 preserved historical copies, no MediaStore insert/pending publication
|
Android-origin companions precede the request signal, and replay reuses
|
||||||
or `(32)` copy occurs, all seven Android-origin companions precede the request
|
canonical names without stable-feedback churn.
|
||||||
signal, and replay reuses canonical objects without stable-feedback churn.
|
|
||||||
It also requires one direct-child enumeration for the entire export-plus-request
|
It also requires one direct-child enumeration for the entire export-plus-request
|
||||||
transaction, one additional enumeration for each later synchronization, index
|
transaction, one additional enumeration for each later synchronization, index
|
||||||
update after a create without rescan, one write per canonical artifact, and
|
update after a create without rescan, one write per canonical artifact, and
|
||||||
non-main dispatcher ownership for snapshot and stream operations.
|
non-main dispatcher ownership for snapshot and stream operations. Focused
|
||||||
|
publisher cases additionally cover an empty directory, exact rewrite,
|
||||||
|
ten successive publications without numbered copies, historical numbered
|
||||||
|
copies alone and beside a canonical object, exact final content, and an
|
||||||
|
explicit canonical-name conflict without writing a wrongly named target.
|
||||||
|
`DirectExchangeStorageTest` verifies the manifest permission and settings
|
||||||
|
action, refusal without an authorized backend, directory creation, real
|
||||||
|
temporary-file replacement and truncation, ten publications without suffixes,
|
||||||
|
and preservation of legacy Download and recovery fixtures.
|
||||||
|
`AiSessionDraftUiWiringTest` additionally requires the delete action to expose
|
||||||
|
the titled confirmation before mutation, verifies Cancel and Back dismissal,
|
||||||
|
then proves that only confirmation removes the pending proposal while retaining
|
||||||
|
its replay-blocking deleted tombstone. Its existing Start coverage remains.
|
||||||
Native `sync_body_zone_wiring` verifies the
|
Native `sync_body_zone_wiring` verifies the
|
||||||
profile pre-pass before mobile import, strict post-pass gating, replay, legacy
|
profile pre-pass before mobile import, strict post-pass gating, replay, legacy
|
||||||
peer compatibility, and the explicit missing-companion diagnostic even when a
|
peer compatibility, and the explicit missing-companion diagnostic even when a
|
||||||
stale local temporary profile artifact exists.
|
stale local temporary profile artifact exists. It also receives a valid alias
|
||||||
Every Android outbound artifact and the request use the shared exact-name SAF
|
V1 companion through the production MTP orchestration, imports it into an
|
||||||
resolver; app-scoped MediaStore visibility is not part of canonical selection.
|
isolated database through the production Python argv path, and proves that the
|
||||||
|
sync continues. Negative cases cover an unresolved helper, argparse failure,
|
||||||
|
Python exception/stderr propagation, sync-history visibility, and rejection of
|
||||||
|
a stale result-file `PASS`; the successful flow exercises the other shared
|
||||||
|
Python helper callers as well.
|
||||||
|
Every Android outbound artifact and the request use the shared direct-storage
|
||||||
|
exact-name resolver; app-scoped MediaStore visibility is irrelevant.
|
||||||
`profile_state_sync` includes the production persisted update-trigger dependency
|
`profile_state_sync` includes the production persisted update-trigger dependency
|
||||||
on `trainlog_profile_revision`, imports through the same Python connection path
|
on `trainlog_profile_revision`, imports through the same Python connection path
|
||||||
used by the PC sync worker, and requires first import plus identical replay to
|
used by the PC sync worker, and requires first import plus identical replay to
|
||||||
pass without revision-state or history-count churn. It also checks that the
|
pass without revision-state or history-count churn. It also checks that the
|
||||||
central factory installs both profile-revision schema functions.
|
central factory installs both profile-revision schema functions.
|
||||||
|
|
||||||
|
`TRAINLOG_ANDROID_DIRECT_STORAGE_V1` coverage fixes Android storage at
|
||||||
|
`/storage/emulated/0/Documents/Trainlog`, preserves canonical rewrite/no-suffix
|
||||||
|
behavior, ignores legacy Download trees, and gives the MTP fixture both
|
||||||
|
Documents and Download roots while failing on any legacy access.
|
||||||
|
Android unit tests and any instrumented tests run on an emulator; the primary
|
||||||
|
personal phone is reserved for manual, non-destructive install-and-sync smoke
|
||||||
|
validation and must not run `connectedDebugAndroidTest`.
|
||||||
|
|
|
||||||
44
format/trainlog-ai-session-draft-v1.schema.json
Normal file
44
format/trainlog-ai-session-draft-v1.schema.json
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "trainlog-ai-session-draft-v1.schema.json",
|
||||||
|
"title": "TRAINLOG_AI_SESSION_DRAFT_V1 source inbox",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["format", "version", "draft"],
|
||||||
|
"properties": {
|
||||||
|
"format": {"const": "TRAINLOG_AI_SESSION_DRAFT"},
|
||||||
|
"version": {"const": 1},
|
||||||
|
"draft": {"$ref": "#/$defs/draft"}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"nullableText": {"type": ["string", "null"], "minLength": 1},
|
||||||
|
"entry": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["position", "exercise_id", "target_sets", "target_reps", "target_duration_seconds", "target_weight_kg", "rest_seconds"],
|
||||||
|
"properties": {
|
||||||
|
"position": {"type": "integer", "minimum": 0, "maximum": 63},
|
||||||
|
"exercise_id": {"type": "string", "pattern": "^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},
|
||||||
|
"target_sets": {"type": "integer", "minimum": 1, "maximum": 99},
|
||||||
|
"target_reps": {"type": ["integer", "null"], "minimum": 1, "maximum": 999},
|
||||||
|
"target_duration_seconds": {"type": ["integer", "null"], "minimum": 1, "maximum": 86400},
|
||||||
|
"target_weight_kg": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 10000},
|
||||||
|
"rest_seconds": {"type": "integer", "minimum": 0, "maximum": 86400}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"draft": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["draft_id", "created_at", "planned_for", "session_type", "title", "notes", "entries"],
|
||||||
|
"properties": {
|
||||||
|
"draft_id": {"type": "string", "pattern": "^aid_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},
|
||||||
|
"created_at": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,6})?Z$"},
|
||||||
|
"planned_for": {"type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"},
|
||||||
|
"session_type": {"const": "training"},
|
||||||
|
"title": {"type": ["string", "null"], "minLength": 1, "maxLength": 120},
|
||||||
|
"notes": {"type": ["string", "null"], "minLength": 1, "maxLength": 2000},
|
||||||
|
"entries": {"type": "array", "minItems": 1, "maxItems": 64, "items": {"$ref": "#/$defs/entry"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
format/trainlog-ai-session-drafts-v1.schema.json
Normal file
55
format/trainlog-ai-session-drafts-v1.schema.json
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "trainlog-ai-session-drafts-v1.schema.json",
|
||||||
|
"title": "PC to Android AI session drafts companion",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["format", "version", "generated_at", "drafts"],
|
||||||
|
"properties": {
|
||||||
|
"format": {"const": "trainlog-ai-session-drafts"},
|
||||||
|
"version": {"const": 1},
|
||||||
|
"generated_at": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"},
|
||||||
|
"drafts": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/draft"}}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"target": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["sets", "reps", "duration_seconds", "weight_kg"],
|
||||||
|
"properties": {
|
||||||
|
"sets": {"type": "integer", "minimum": 1, "maximum": 99},
|
||||||
|
"reps": {"type": ["integer", "null"], "minimum": 1, "maximum": 999},
|
||||||
|
"duration_seconds": {"type": ["integer", "null"], "minimum": 1, "maximum": 86400},
|
||||||
|
"weight_kg": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 10000}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entry": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["entry_id", "position", "exercise_id", "recording_mode", "tracking_mode", "data_fields", "equipment_id", "load_mode", "rest_seconds", "target"],
|
||||||
|
"properties": {
|
||||||
|
"entry_id": {"type": "string", "pattern": "^sxe_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},
|
||||||
|
"position": {"type": "integer", "minimum": 0, "maximum": 63},
|
||||||
|
"exercise_id": {"type": "string", "pattern": "^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},
|
||||||
|
"recording_mode": {"const": "sets"},
|
||||||
|
"tracking_mode": {"enum": ["reps", "duration"]},
|
||||||
|
"data_fields": {"type": "integer", "minimum": 0, "maximum": 3},
|
||||||
|
"equipment_id": {"type": ["string", "null"]},
|
||||||
|
"load_mode": {"enum": ["none", "external"]},
|
||||||
|
"rest_seconds": {"type": "integer", "minimum": 0, "maximum": 86400},
|
||||||
|
"target": {"$ref": "#/$defs/target"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"draft": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["draft_id", "created_at", "planned_for", "session_type", "title", "notes", "entries"],
|
||||||
|
"properties": {
|
||||||
|
"draft_id": {"type": "string", "pattern": "^aid_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},
|
||||||
|
"created_at": {"type": "string"},
|
||||||
|
"planned_for": {"type": ["string", "null"]},
|
||||||
|
"session_type": {"const": "training"},
|
||||||
|
"title": {"type": ["string", "null"], "maxLength": 120},
|
||||||
|
"notes": {"type": ["string", "null"], "maxLength": 2000},
|
||||||
|
"entries": {"type": "array", "minItems": 1, "maxItems": 64, "items": {"$ref": "#/$defs/entry"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
tests/test_ai_history_export.py
Normal file
158
tests/test_ai_history_export.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression tests for the read-only TRAINLOG_AI_EXPORT_V1 artifact."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
TOOL = ROOT / "tools" / "export_ai_history.py"
|
||||||
|
SPECIAL_TEXT = 'Très dur "aujourd\'hui".\nDos OK.\n\\ test UTF-8 : éàùç'
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
PRAGMA user_version=17;
|
||||||
|
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT,name TEXT,
|
||||||
|
normalized_name TEXT,tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER);
|
||||||
|
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT,started_at TEXT,
|
||||||
|
ended_at TEXT,session_type TEXT,notes TEXT);
|
||||||
|
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT,
|
||||||
|
session_row_id INTEGER,exercise_row_id INTEGER,recording_mode TEXT,
|
||||||
|
data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER,
|
||||||
|
target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,
|
||||||
|
target_weight_kg REAL,equipment_id TEXT,notes TEXT,tracking_mode TEXT);
|
||||||
|
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,
|
||||||
|
position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);
|
||||||
|
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,
|
||||||
|
duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);
|
||||||
|
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL);
|
||||||
|
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT,
|
||||||
|
observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,
|
||||||
|
shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,
|
||||||
|
right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,
|
||||||
|
right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);
|
||||||
|
CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT,
|
||||||
|
session_exercise_row_id INTEGER,observed_at TEXT,raw_text TEXT);
|
||||||
|
CREATE TABLE session_followups(id INTEGER PRIMARY KEY,followup_id TEXT,
|
||||||
|
session_row_id INTEGER,observed_at TEXT,raw_text TEXT);
|
||||||
|
CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,
|
||||||
|
feedback_id TEXT,created_at TEXT,raw_text TEXT);
|
||||||
|
CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,
|
||||||
|
followup_id TEXT,created_at TEXT,raw_text TEXT);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class AiHistoryExportTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.directory = Path(self.temporary.name)
|
||||||
|
self.database = self.directory / "trainlog.db"
|
||||||
|
with sqlite3.connect(self.database) as connection:
|
||||||
|
connection.executescript(SCHEMA)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def export(self, name="export.json"):
|
||||||
|
output = self.directory / name
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, str(TOOL), "--database", str(self.database),
|
||||||
|
str(output)], check=True, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return output, json.loads(output.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def populate(self):
|
||||||
|
with sqlite3.connect(self.database) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO exercises VALUES(1,'ex_same','Tirage dos','tirage dos',"
|
||||||
|
"'reps','sets',0)"
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO sessions VALUES(1,'se_one','2026-09-12T10:00:00Z',"
|
||||||
|
"'2026-09-12T11:00:00Z','training','Séance dos')"
|
||||||
|
)
|
||||||
|
for row_id, entry_id, position in ((1, "sxe_a", 0), (2, "sxe_b", 1)):
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO session_exercises VALUES(?,?,1,1,'sets',0,?,"
|
||||||
|
"'none',0,NULL,NULL,NULL,NULL,NULL,NULL,'reps')",
|
||||||
|
(row_id, entry_id, position),
|
||||||
|
)
|
||||||
|
connection.executemany(
|
||||||
|
"INSERT INTO performed_sets VALUES(?,?,?,?,NULL,?)",
|
||||||
|
((1, 1, 0, 8, 48.0), (2, 1, 1, 7, 48.0),
|
||||||
|
(3, 2, 0, 10, 42.5)),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO exercise_feedback VALUES(1,'fb_one',2,"
|
||||||
|
"'2026-09-12T10:45:00Z',?)", (SPECIAL_TEXT,)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO exercise_feedback_revisions VALUES("
|
||||||
|
"'fr_one','fb_one','2026-09-12T10:45:00Z',?)", (SPECIAL_TEXT,)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO session_followups VALUES(1,'fu_one',1,"
|
||||||
|
"'2026-09-13T09:00:00Z','Courbatures dos')"
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO session_followup_revisions VALUES("
|
||||||
|
"'fur_one','fu_one','2026-09-13T09:00:00Z','Courbatures dos')"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_export_is_valid_and_has_required_root(self):
|
||||||
|
_, payload = self.export()
|
||||||
|
self.assertEqual("TRAINLOG_AI_EXPORT", payload["format"])
|
||||||
|
self.assertEqual(1, payload["version"])
|
||||||
|
self.assertRegex(payload["generated_at"], r"Z$")
|
||||||
|
self.assertEqual([], payload["sessions"])
|
||||||
|
self.assertEqual([], payload["max_history"])
|
||||||
|
self.assertEqual([], payload["measurements"])
|
||||||
|
|
||||||
|
def test_session_multi_occurrence_feedback_text_and_followup_ownership(self):
|
||||||
|
self.populate()
|
||||||
|
output, payload = self.export()
|
||||||
|
# Parsing the actual file proves quotes, backslashes, newlines and UTF-8
|
||||||
|
# are encoded as JSON rather than interpolated into it.
|
||||||
|
json.loads(output.read_text(encoding="utf-8"))
|
||||||
|
session = payload["sessions"][0]
|
||||||
|
self.assertEqual(["sxe_a", "sxe_b"],
|
||||||
|
[entry["entry_id"] for entry in session["entries"]])
|
||||||
|
self.assertEqual([8, 7],
|
||||||
|
[item["reps"] for item in session["entries"][0]["sets"]])
|
||||||
|
self.assertEqual([], session["entries"][0]["immediate_feedback"])
|
||||||
|
feedback = session["entries"][1]["immediate_feedback"][0]
|
||||||
|
self.assertEqual(SPECIAL_TEXT, feedback["revisions"][0]["raw_text"])
|
||||||
|
self.assertNotIn("session_followups", session["entries"][1])
|
||||||
|
self.assertEqual("fu_one", session["session_followups"][0]["followup_id"])
|
||||||
|
|
||||||
|
def test_max_measurements_determinism_and_read_only_database(self):
|
||||||
|
self.populate()
|
||||||
|
with sqlite3.connect(self.database) as connection:
|
||||||
|
connection.execute("INSERT INTO max_results VALUES(1,62.5)")
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO body_observations(id,observation_id,observed_at,"
|
||||||
|
"body_weight_kg,waist_cm,notes) VALUES("
|
||||||
|
"1,'bo_one','2026-09-13T08:00:00Z',75.2,82.0,'Matin')"
|
||||||
|
)
|
||||||
|
before = hashlib.sha256(self.database.read_bytes()).digest()
|
||||||
|
_, first = self.export("first.json")
|
||||||
|
after = hashlib.sha256(self.database.read_bytes()).digest()
|
||||||
|
_, second = self.export("second.json")
|
||||||
|
first.pop("generated_at")
|
||||||
|
second.pop("generated_at")
|
||||||
|
self.assertEqual(before, after)
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertEqual("sxe_a", first["max_history"][0]["entry_id"])
|
||||||
|
self.assertEqual(62.5, first["max_history"][0]["max_weight_kg"])
|
||||||
|
self.assertEqual(75.2, first["measurements"][0]["body_weight_kg"])
|
||||||
|
self.assertEqual(82.0, first["measurements"][0]["waist_cm"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
371
tests/test_ai_session_draft.py
Normal file
371
tests/test_ai_session_draft.py
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Focused contract tests for the desktop AI session-draft inbox."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import contextlib
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import stat
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
from import_ai_session_draft import ImportFailure, import_payload, load # noqa: E402
|
||||||
|
from export_ai_session_drafts import build_export, mark_published # noqa: E402
|
||||||
|
|
||||||
|
_drive_spec = importlib.util.spec_from_file_location(
|
||||||
|
"sync_ai_session_draft_drive", ROOT / "tools/sync_ai_session_draft_drive.py"
|
||||||
|
)
|
||||||
|
drive = importlib.util.module_from_spec(_drive_spec)
|
||||||
|
_drive_spec.loader.exec_module(drive)
|
||||||
|
|
||||||
|
EX_REPS = "ex_11111111-1111-4111-8111-111111111111"
|
||||||
|
EX_DURATION = "ex_22222222-2222-4222-8222-222222222222"
|
||||||
|
EX_CONTINUOUS = "ex_33333333-3333-4333-8333-333333333333"
|
||||||
|
EX_ALIAS = "ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
|
DRAFT_ID = "aid_44444444-4444-4444-8444-444444444444"
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER NOT NULL DEFAULT 0);
|
||||||
|
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT);
|
||||||
|
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY);
|
||||||
|
CREATE TABLE ai_session_drafts(id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL,planned_for TEXT,session_type TEXT NOT NULL,title TEXT,notes TEXT,archive_status TEXT NOT NULL DEFAULT 'pending',archived_at TEXT,archive_error TEXT,published_at TEXT);
|
||||||
|
CREATE TABLE ai_session_draft_entries(id INTEGER PRIMARY KEY,draft_row_id INTEGER NOT NULL REFERENCES ai_session_drafts(id) ON DELETE CASCADE,entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),source_exercise_id TEXT NOT NULL,recording_mode TEXT NOT NULL,tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,equipment_id TEXT,load_mode TEXT NOT NULL,target_sets INTEGER NOT NULL,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,rest_seconds INTEGER NOT NULL,UNIQUE(draft_row_id,position));
|
||||||
|
CREATE TABLE ai_session_draft_imports(draft_row_id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,payload_sha256 TEXT NOT NULL UNIQUE,imported_at TEXT NOT NULL);
|
||||||
|
CREATE TRIGGER ai_session_draft_import_identity_guard BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;
|
||||||
|
PRAGMA user_version=18;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def artifact():
|
||||||
|
return {"format": "TRAINLOG_AI_SESSION_DRAFT", "version": 1, "draft": {
|
||||||
|
"draft_id": DRAFT_ID, "created_at": "2026-09-14T08:30:00Z",
|
||||||
|
"planned_for": "2026-09-15", "session_type": "training",
|
||||||
|
"title": "Séance IA", "notes": None, "entries": [
|
||||||
|
{"position": 0, "exercise_id": EX_ALIAS, "target_sets": 4,
|
||||||
|
"target_reps": 8, "target_duration_seconds": None,
|
||||||
|
"target_weight_kg": 42.5, "rest_seconds": 90},
|
||||||
|
{"position": 1, "exercise_id": EX_DURATION, "target_sets": 3,
|
||||||
|
"target_reps": None, "target_duration_seconds": 45,
|
||||||
|
"target_weight_kg": None, "rest_seconds": 30},
|
||||||
|
]}}
|
||||||
|
|
||||||
|
|
||||||
|
class DraftTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.database = Path(self.temp.name) / "trainlog.db"
|
||||||
|
con = sqlite3.connect(self.database)
|
||||||
|
con.executescript(SCHEMA)
|
||||||
|
con.executemany("INSERT INTO exercises(exercise_id,recording_mode,tracking_mode,data_fields) VALUES(?,?,?,?)", [
|
||||||
|
(EX_REPS, "sets", "reps", 3), (EX_DURATION, "sets", "duration", 0),
|
||||||
|
(EX_CONTINUOUS, "continuous", "duration", 0)])
|
||||||
|
con.execute("INSERT INTO exercise_aliases VALUES(?,?)", (EX_ALIAS, EX_REPS))
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
con = sqlite3.connect(self.database)
|
||||||
|
con.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return con
|
||||||
|
|
||||||
|
def test_import_alias_targets_only_idempotence_conflict_and_export_shape(self):
|
||||||
|
con = self.connect()
|
||||||
|
self.assertEqual("imported", import_payload(con, artifact(), "2026-09-14T09:00:00Z"))
|
||||||
|
self.assertEqual("skipped", import_payload(con, artifact(), "2026-09-14T09:01:00Z"))
|
||||||
|
self.assertEqual(0, con.execute("SELECT count(*) FROM performed_sets").fetchone()[0])
|
||||||
|
stored = con.execute(
|
||||||
|
"SELECT de.entry_id,de.source_exercise_id,e.exercise_id,de.recording_mode,de.tracking_mode,de.load_mode,de.target_reps,de.target_duration_seconds "
|
||||||
|
"FROM ai_session_draft_entries de JOIN exercises e ON e.id=de.exercise_row_id WHERE position=0"
|
||||||
|
).fetchone()
|
||||||
|
self.assertRegex(stored[0], r"^sxe_[0-9a-f-]{36}$")
|
||||||
|
self.assertEqual((EX_ALIAS, EX_REPS, "sets", "reps", "external", 8, None), stored[1:])
|
||||||
|
changed = artifact(); changed["draft"]["title"] = "Autre"
|
||||||
|
with self.assertRaisesRegex(ImportFailure, "conflit"):
|
||||||
|
import_payload(con, changed)
|
||||||
|
exported = build_export(con, "2026-09-14T09:02:00Z")
|
||||||
|
self.assertEqual({"format", "version", "generated_at", "drafts"}, set(exported))
|
||||||
|
self.assertEqual("trainlog-ai-session-drafts", exported["format"])
|
||||||
|
outbound = exported["drafts"][0]["entries"][0]
|
||||||
|
self.assertEqual(EX_REPS, outbound["exercise_id"])
|
||||||
|
self.assertEqual(3, outbound["data_fields"])
|
||||||
|
self.assertEqual({"entry_id", "position", "exercise_id", "recording_mode", "tracking_mode", "data_fields", "equipment_id", "load_mode", "rest_seconds", "target"}, set(outbound))
|
||||||
|
self.assertEqual({"sets": 4, "reps": 8, "duration_seconds": None, "weight_kg": 42.5}, outbound["target"])
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
def test_existing_v18_database_gets_publication_cursor(self):
|
||||||
|
con = self.connect()
|
||||||
|
con.execute("ALTER TABLE ai_session_drafts DROP COLUMN published_at")
|
||||||
|
con.commit()
|
||||||
|
self.assertEqual([], build_export(con, "2026-09-14T09:02:00Z")["drafts"])
|
||||||
|
self.assertIn("published_at", {row[1] for row in con.execute(
|
||||||
|
"PRAGMA table_info(ai_session_drafts)")})
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
def test_target_sets_99_and_bounded_publication_lifecycle(self):
|
||||||
|
value = artifact()
|
||||||
|
value["draft"]["entries"][0]["target_sets"] = 99
|
||||||
|
con = self.connect()
|
||||||
|
self.assertEqual("imported", import_payload(con, value))
|
||||||
|
self.assertEqual(99, con.execute(
|
||||||
|
"SELECT target_sets FROM ai_session_draft_entries WHERE position=0"
|
||||||
|
).fetchone()[0])
|
||||||
|
base = con.execute(
|
||||||
|
"SELECT created_at,planned_for,session_type,title,notes FROM ai_session_drafts WHERE draft_id=?",
|
||||||
|
(DRAFT_ID,),
|
||||||
|
).fetchone()
|
||||||
|
for index in range(1, 256):
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes) VALUES(?,?,?,?,?,?)",
|
||||||
|
(f"aid_00000000-0000-4000-8000-{index:012d}",) + base,
|
||||||
|
)
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes) VALUES(?,?,?,?,?,?)",
|
||||||
|
("aid_00000000-0000-4000-8000-000000000256",) + base,
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
first = build_export(con, "2026-09-14T09:02:00Z")
|
||||||
|
self.assertEqual(256, len(first["drafts"]))
|
||||||
|
# Failed publication performs no mark: deterministic retry is identical.
|
||||||
|
self.assertEqual([d["draft_id"] for d in first["drafts"]],
|
||||||
|
[d["draft_id"] for d in build_export(con, "2026-09-14T09:03:00Z")["drafts"]])
|
||||||
|
self.assertEqual(256, mark_published(con, first, "2026-09-14T09:04:00Z"))
|
||||||
|
second = build_export(con, "2026-09-14T09:05:00Z")
|
||||||
|
self.assertEqual(1, len(second["drafts"]))
|
||||||
|
self.assertEqual(DRAFT_ID, second["drafts"][0]["draft_id"])
|
||||||
|
self.assertEqual(1, mark_published(con, second, "2026-09-14T09:06:00Z"))
|
||||||
|
self.assertEqual([], build_export(con, "2026-09-14T09:07:00Z")["drafts"])
|
||||||
|
self.assertEqual(1, con.execute("SELECT count(*) FROM ai_session_draft_imports").fetchone()[0])
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
def test_strict_failures(self):
|
||||||
|
mutations = []
|
||||||
|
value = artifact(); value["extra"] = 1; mutations.append(value)
|
||||||
|
value = artifact(); value["draft"]["created_at"] = "2026-09-14T08:30:00+00:00"; mutations.append(value)
|
||||||
|
value = artifact(); value["draft"]["entries"][1]["position"] = 2; mutations.append(value)
|
||||||
|
value = artifact(); value["draft"]["entries"][0]["target_duration_seconds"] = 10; mutations.append(value)
|
||||||
|
value = artifact(); value["draft"]["entries"][0]["target_weight_kg"] = float("inf"); mutations.append(value)
|
||||||
|
value = artifact(); value["draft"]["entries"][0]["notes"] = None; mutations.append(value)
|
||||||
|
value = artifact(); value["draft"]["entries"][0]["exercise_id"] = EX_CONTINUOUS; value["draft"]["entries"][0]["target_reps"] = None; value["draft"]["entries"][0]["target_duration_seconds"] = 20; mutations.append(value)
|
||||||
|
con = self.connect()
|
||||||
|
for value in mutations:
|
||||||
|
with self.assertRaises(ImportFailure):
|
||||||
|
import_payload(con, value)
|
||||||
|
self.assertEqual(0, con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0])
|
||||||
|
con.close()
|
||||||
|
duplicate = Path(self.temp.name) / "duplicate.json"
|
||||||
|
duplicate.write_text('{"format":"TRAINLOG_AI_SESSION_DRAFT","format":"x","version":1,"draft":{}}', encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ImportFailure, "dupliquée"):
|
||||||
|
load(duplicate)
|
||||||
|
|
||||||
|
def test_forced_child_failure_rolls_back_every_row(self):
|
||||||
|
con = self.connect()
|
||||||
|
con.execute("CREATE TRIGGER reject_second BEFORE INSERT ON ai_session_draft_entries WHEN NEW.position=1 BEGIN SELECT RAISE(ABORT,'forced'); END")
|
||||||
|
with self.assertRaises(sqlite3.Error):
|
||||||
|
import_payload(con, artifact())
|
||||||
|
self.assertEqual((0, 0, 0), (
|
||||||
|
con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0],
|
||||||
|
con.execute("SELECT count(*) FROM ai_session_draft_entries").fetchone()[0],
|
||||||
|
con.execute("SELECT count(*) FROM ai_session_draft_imports").fetchone()[0]))
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
def test_fake_rclone_listing_failure_and_archive_failure_are_nonfatal(self):
|
||||||
|
missing_calls = []
|
||||||
|
def missing_runner(argv, **kwargs):
|
||||||
|
missing_calls.append(argv)
|
||||||
|
return subprocess.CompletedProcess(argv, 4, "", "not found")
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, missing_runner))
|
||||||
|
self.assertEqual("rclone", missing_calls[0][0])
|
||||||
|
|
||||||
|
source = artifact(); calls = []
|
||||||
|
def archive_fails(argv, **kwargs):
|
||||||
|
calls.append(argv)
|
||||||
|
if argv[1] == "lsf":
|
||||||
|
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
|
||||||
|
if argv[1] == "copyto" and argv[2] == drive.SOURCE:
|
||||||
|
Path(argv[3]).write_text(json.dumps(source), encoding="utf-8")
|
||||||
|
return subprocess.CompletedProcess(argv, 0, "", "")
|
||||||
|
return subprocess.CompletedProcess(argv, 9, "", "offline")
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, archive_fails))
|
||||||
|
con = self.connect()
|
||||||
|
self.assertEqual(("failed", "offline"), con.execute(
|
||||||
|
"SELECT archive_status,archive_error FROM ai_session_drafts WHERE draft_id=?", (DRAFT_ID,)).fetchone())
|
||||||
|
self.assertEqual(1, con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0])
|
||||||
|
con.close()
|
||||||
|
self.assertEqual(["rclone", "lsf"], calls[0][:2])
|
||||||
|
self.assertEqual(["rclone", "copyto"], calls[1][:2])
|
||||||
|
self.assertEqual(["rclone", "copyto"], calls[2][:2])
|
||||||
|
self.assertNotEqual(drive.SOURCE, calls[2][2])
|
||||||
|
self.assertTrue(calls[2][2].endswith("/draft.json"))
|
||||||
|
self.assertEqual(f"{drive.ARCHIVE_ROOT}/{DRAFT_ID}.json", calls[2][3])
|
||||||
|
|
||||||
|
def test_inbound_destination_is_private_during_and_after_copy(self):
|
||||||
|
source = artifact()
|
||||||
|
observed = []
|
||||||
|
|
||||||
|
def successful_runner(argv, **kwargs):
|
||||||
|
if argv[1] == "lsf":
|
||||||
|
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
|
||||||
|
if argv[2] == drive.SOURCE:
|
||||||
|
destination = Path(argv[3])
|
||||||
|
self.assertTrue(destination.exists())
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(destination.stat().st_mode))
|
||||||
|
destination.write_text(json.dumps(source), encoding="utf-8")
|
||||||
|
observed.append(stat.S_IMODE(destination.stat().st_mode))
|
||||||
|
return subprocess.CompletedProcess(argv, 0, "", "")
|
||||||
|
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, successful_runner))
|
||||||
|
self.assertEqual([0o600], observed)
|
||||||
|
|
||||||
|
def test_source_replacement_after_fetch_archives_imported_bytes_without_removing_replacement(self):
|
||||||
|
original = artifact()
|
||||||
|
replacement = artifact()
|
||||||
|
replacement["draft"]["draft_id"] = "aid_55555555-5555-4555-8555-555555555555"
|
||||||
|
remote = {drive.SOURCE: json.dumps(original)}
|
||||||
|
archived = {}
|
||||||
|
|
||||||
|
def replacing_runner(argv, **kwargs):
|
||||||
|
if argv[1] == "lsf":
|
||||||
|
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
|
||||||
|
if argv[2] == drive.SOURCE:
|
||||||
|
Path(argv[3]).write_text(remote[drive.SOURCE], encoding="utf-8")
|
||||||
|
remote[drive.SOURCE] = json.dumps(replacement)
|
||||||
|
else:
|
||||||
|
archived[argv[3]] = Path(argv[2]).read_text(encoding="utf-8")
|
||||||
|
return subprocess.CompletedProcess(argv, 0, "", "")
|
||||||
|
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, replacing_runner))
|
||||||
|
target = f"{drive.ARCHIVE_ROOT}/{DRAFT_ID}.json"
|
||||||
|
self.assertEqual(original, json.loads(archived[target]))
|
||||||
|
self.assertEqual(replacement, json.loads(remote[drive.SOURCE]))
|
||||||
|
|
||||||
|
def test_archive_status_update_failure_converges_on_replay(self):
|
||||||
|
source = artifact()
|
||||||
|
archive_copies = []
|
||||||
|
|
||||||
|
def successful_runner(argv, **kwargs):
|
||||||
|
if argv[1] == "lsf":
|
||||||
|
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
|
||||||
|
if argv[2] == drive.SOURCE:
|
||||||
|
Path(argv[3]).write_text(json.dumps(source), encoding="utf-8")
|
||||||
|
else:
|
||||||
|
archive_copies.append(Path(argv[2]).read_text(encoding="utf-8"))
|
||||||
|
return subprocess.CompletedProcess(argv, 0, "", "")
|
||||||
|
|
||||||
|
original_update = drive.try_update_archive
|
||||||
|
updates = 0
|
||||||
|
def fail_once(*args, **kwargs):
|
||||||
|
nonlocal updates
|
||||||
|
updates += 1
|
||||||
|
if updates == 1:
|
||||||
|
return "synthetic update failure"
|
||||||
|
return original_update(*args, **kwargs)
|
||||||
|
drive.try_update_archive = fail_once
|
||||||
|
try:
|
||||||
|
first = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(first):
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, successful_runner))
|
||||||
|
self.assertIn("AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL", first.getvalue())
|
||||||
|
second = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(second):
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, successful_runner))
|
||||||
|
self.assertIn("AI_SESSION_DRAFT_INBOUND=ALREADY_IMPORTED", second.getvalue())
|
||||||
|
finally:
|
||||||
|
drive.try_update_archive = original_update
|
||||||
|
self.assertEqual(2, len(archive_copies))
|
||||||
|
con = self.connect()
|
||||||
|
self.assertEqual("archived", con.execute(
|
||||||
|
"SELECT archive_status FROM ai_session_drafts WHERE draft_id=?", (DRAFT_ID,)
|
||||||
|
).fetchone()[0])
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
def test_helper_reports_imported_then_already_imported(self):
|
||||||
|
source = artifact()
|
||||||
|
|
||||||
|
def successful_runner(argv, **kwargs):
|
||||||
|
if argv[1] == "lsf":
|
||||||
|
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
|
||||||
|
if argv[1] == "copyto" and argv[2] == drive.SOURCE:
|
||||||
|
Path(argv[3]).write_text(json.dumps(source), encoding="utf-8")
|
||||||
|
return subprocess.CompletedProcess(argv, 0, "", "")
|
||||||
|
|
||||||
|
first = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(first):
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, successful_runner))
|
||||||
|
self.assertIn("AI_SESSION_DRAFT_INBOUND=IMPORTED", first.getvalue())
|
||||||
|
second = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(second):
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, successful_runner))
|
||||||
|
self.assertIn("AI_SESSION_DRAFT_INBOUND=ALREADY_IMPORTED", second.getvalue())
|
||||||
|
|
||||||
|
def test_listing_distinguishes_empty_and_transport_failure(self):
|
||||||
|
def empty_runner(argv, **kwargs):
|
||||||
|
self.assertEqual("lsf", argv[1])
|
||||||
|
self.assertEqual(drive.INBOX_ROOT, argv[2])
|
||||||
|
self.assertEqual(["--files-only", "--max-depth", "1", "--include",
|
||||||
|
drive.ARTIFACT_NAME], argv[3:])
|
||||||
|
return subprocess.CompletedProcess(argv, 0, "other.json\n", "")
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, empty_runner))
|
||||||
|
self.assertIn("AI_SESSION_DRAFT_INBOUND=NONE", out.getvalue())
|
||||||
|
|
||||||
|
def unavailable_runner(argv, **kwargs):
|
||||||
|
raise OSError("authentication unavailable")
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, unavailable_runner))
|
||||||
|
self.assertIn("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", out.getvalue())
|
||||||
|
|
||||||
|
def test_present_file_copy_failure_or_timeout_is_drive_failure(self):
|
||||||
|
def runner(argv, **kwargs):
|
||||||
|
if argv[1] == "lsf":
|
||||||
|
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
|
||||||
|
raise subprocess.TimeoutExpired(argv, drive.RCLONE_TIMEOUT_SECONDS)
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, runner))
|
||||||
|
self.assertIn("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", out.getvalue())
|
||||||
|
self.assertNotIn("AI_SESSION_DRAFT_INBOUND=NONE", out.getvalue())
|
||||||
|
|
||||||
|
def test_disappearing_source_and_unwritten_temp_are_drive_failures(self):
|
||||||
|
stale = Path(self.temp.name) / "draft.json"
|
||||||
|
stale.write_text(json.dumps(artifact()), encoding="utf-8")
|
||||||
|
for returncode in (7, 0):
|
||||||
|
def runner(argv, **kwargs):
|
||||||
|
if argv[1] == "lsf":
|
||||||
|
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
|
||||||
|
return subprocess.CompletedProcess(argv, returncode, "", "source disappeared")
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, runner))
|
||||||
|
self.assertIn("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", out.getvalue())
|
||||||
|
con = self.connect()
|
||||||
|
self.assertEqual(0, con.execute("SELECT count(*) FROM ai_session_drafts").fetchone()[0])
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
def test_present_invalid_json_is_rejected(self):
|
||||||
|
def runner(argv, **kwargs):
|
||||||
|
if argv[1] == "lsf":
|
||||||
|
return subprocess.CompletedProcess(argv, 0, drive.ARTIFACT_NAME + "\n", "")
|
||||||
|
if argv[1] == "copyto":
|
||||||
|
Path(argv[3]).write_text("{invalid", encoding="utf-8")
|
||||||
|
return subprocess.CompletedProcess(argv, 0, "", "")
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
self.assertEqual(0, drive.synchronize(self.database, runner))
|
||||||
|
self.assertIn("AI_SESSION_DRAFT_INBOUND=REJECTED", out.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
82
tests/test_post_sync_ai_drive.py
Normal file
82
tests/test_post_sync_ai_drive.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Contract tests for ordered, non-secret post-sync Drive publication."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
TOOL = ROOT / "tools" / "post_sync_ai_drive.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_tool():
|
||||||
|
spec = importlib.util.spec_from_file_location("post_sync_ai_drive", TOOL)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def run_case(results):
|
||||||
|
module = load_tool()
|
||||||
|
output = io.StringIO()
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
database = Path(directory) / "trainlog.db"
|
||||||
|
destination = Path(directory) / "export.json"
|
||||||
|
with mock.patch.object(module.subprocess, "run", side_effect=results) as run:
|
||||||
|
with contextlib.redirect_stdout(output):
|
||||||
|
status = module.publish(database, destination)
|
||||||
|
return module, status, output.getvalue(), run.call_args_list
|
||||||
|
|
||||||
|
|
||||||
|
def completed(argv, returncode, stdout="", stderr=""):
|
||||||
|
return subprocess.CompletedProcess(argv, returncode, stdout, stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
module, status, text, calls = run_case([
|
||||||
|
completed(["export"], 0, "TRAINLOG_AI_EXPORT_V1=PASS\n"),
|
||||||
|
completed(["rclone"], 0),
|
||||||
|
])
|
||||||
|
assert status == 0
|
||||||
|
assert text == "AI_EXPORT=PASS\nGDRIVE_UPLOAD=PASS\n"
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert calls[0].args[0][1].endswith("tools/export_ai_history.py")
|
||||||
|
assert calls[1].args[0][0:2] == ["rclone", "copyto"]
|
||||||
|
assert calls[1].args[0][-1] == module.DRIVE_TARGET
|
||||||
|
|
||||||
|
_, status, text, calls = run_case([
|
||||||
|
completed(["export"], 1, "TRAINLOG_AI_EXPORT_V1=FAIL database locked\n"),
|
||||||
|
])
|
||||||
|
assert status == 1
|
||||||
|
assert "AI_EXPORT=FAIL" in text
|
||||||
|
assert "GDRIVE_UPLOAD=SKIPPED" in text
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
_, status, text, calls = run_case([
|
||||||
|
completed(["export"], 0, "TRAINLOG_AI_EXPORT_V1=PASS\n"),
|
||||||
|
FileNotFoundError("rclone unavailable"),
|
||||||
|
])
|
||||||
|
assert status == 1
|
||||||
|
assert "AI_EXPORT=PASS" in text
|
||||||
|
assert "GDRIVE_UPLOAD=FAIL rclone unavailable" in text
|
||||||
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
_, status, text, _ = run_case([
|
||||||
|
completed(["export"], 0, "TRAINLOG_AI_EXPORT_V1=PASS\n"),
|
||||||
|
completed(["rclone"], 9, stderr="remote upload failed\n"),
|
||||||
|
])
|
||||||
|
assert status == 1
|
||||||
|
assert "GDRIVE_UPLOAD=FAIL remote upload failed" in text
|
||||||
|
print("PASS post-sync AI export and Drive ordering")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
253
tools/export_ai_history.py
Normal file
253
tools/export_ai_history.py
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Export raw desktop history as the read-only TRAINLOG_AI_EXPORT_V1 artifact."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from trainlog_sqlite import configure_connection
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18)
|
||||||
|
MEASUREMENT_FIELDS = (
|
||||||
|
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||||
|
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||||
|
"right_forearm_cm", "left_thigh_cm", "right_thigh_cm", "left_calf_cm",
|
||||||
|
"right_calf_cm",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_database():
|
||||||
|
return Path(os.environ.get(
|
||||||
|
"XDG_DATA_HOME", str(Path.home() / ".local/share")
|
||||||
|
)) / "trainlog" / "trainlog.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _readonly_connection(database):
|
||||||
|
"""Open SQLite without any path capable of changing persistent state.
|
||||||
|
|
||||||
|
CONTRACT: mode=ro rejects writes at SQLite's file-opening boundary and
|
||||||
|
query_only is a second guard against accidental write SQL added later.
|
||||||
|
INVARIANT: exporting cannot migrate, create, replace, or update DB content.
|
||||||
|
"""
|
||||||
|
uri = database.resolve().as_uri() + "?mode=ro"
|
||||||
|
connection = configure_connection(sqlite3.connect(uri, uri=True))
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA query_only=ON")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def _revisions(connection, table, owner_column, owner_id):
|
||||||
|
rows = connection.execute(
|
||||||
|
f"SELECT revision_id,created_at,raw_text FROM {table} "
|
||||||
|
f"WHERE {owner_column}=? "
|
||||||
|
"ORDER BY created_at COLLATE BINARY,revision_id COLLATE BINARY",
|
||||||
|
(owner_id,),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _feedback(connection, session_row_id):
|
||||||
|
result = {}
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT f.feedback_id,se.entry_id,f.observed_at "
|
||||||
|
"FROM exercise_feedback f "
|
||||||
|
"JOIN session_exercises se ON se.id=f.session_exercise_row_id "
|
||||||
|
"WHERE se.session_row_id=? "
|
||||||
|
"ORDER BY se.position,f.observed_at COLLATE BINARY,"
|
||||||
|
"f.feedback_id COLLATE BINARY",
|
||||||
|
(session_row_id,),
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
item = {
|
||||||
|
"feedback_id": row["feedback_id"],
|
||||||
|
"observed_at": row["observed_at"],
|
||||||
|
"revisions": _revisions(
|
||||||
|
connection, "exercise_feedback_revisions", "feedback_id",
|
||||||
|
row["feedback_id"],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
result.setdefault(row["entry_id"], []).append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _followups(connection, session_row_id):
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT followup_id,observed_at FROM session_followups "
|
||||||
|
"WHERE session_row_id=? "
|
||||||
|
"ORDER BY observed_at COLLATE BINARY,followup_id COLLATE BINARY",
|
||||||
|
(session_row_id,),
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"followup_id": row["followup_id"],
|
||||||
|
"observed_at": row["observed_at"],
|
||||||
|
"revisions": _revisions(
|
||||||
|
connection, "session_followup_revisions", "followup_id",
|
||||||
|
row["followup_id"],
|
||||||
|
),
|
||||||
|
} for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_result(connection, entry):
|
||||||
|
maximum = connection.execute(
|
||||||
|
"SELECT max_weight_kg FROM max_results "
|
||||||
|
"WHERE session_exercise_row_id=?", (entry["row_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if maximum is not None:
|
||||||
|
return "max_weight_kg", maximum["max_weight_kg"]
|
||||||
|
if entry["recording_mode"] == "continuous":
|
||||||
|
activity = connection.execute(
|
||||||
|
"SELECT duration_seconds,speed_kmh,distance_km "
|
||||||
|
"FROM continuous_activity WHERE session_exercise_row_id=?",
|
||||||
|
(entry["row_id"],),
|
||||||
|
).fetchone()
|
||||||
|
return "continuous_activity", (
|
||||||
|
None if activity is None else
|
||||||
|
{key: activity[key] for key in activity.keys()
|
||||||
|
if activity[key] is not None}
|
||||||
|
)
|
||||||
|
sets = connection.execute(
|
||||||
|
"SELECT position,reps,duration_seconds,weight_kg FROM performed_sets "
|
||||||
|
"WHERE session_exercise_row_id=? "
|
||||||
|
"ORDER BY position,id", (entry["row_id"],),
|
||||||
|
)
|
||||||
|
return "sets", [
|
||||||
|
{key: row[key] for key in row.keys() if row[key] is not None}
|
||||||
|
for row in sets
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_export(connection, generated_at):
|
||||||
|
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||||
|
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||||
|
raise ValueError("schema desktop v15-v18 requis")
|
||||||
|
|
||||||
|
root = {
|
||||||
|
"format": "TRAINLOG_AI_EXPORT",
|
||||||
|
"version": 1,
|
||||||
|
"generated_at": generated_at,
|
||||||
|
"sessions": [],
|
||||||
|
"max_history": [],
|
||||||
|
"measurements": [],
|
||||||
|
}
|
||||||
|
tracking = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode"
|
||||||
|
sessions = connection.execute(
|
||||||
|
"SELECT id,session_id,started_at,ended_at,session_type,notes "
|
||||||
|
"FROM sessions "
|
||||||
|
"ORDER BY started_at COLLATE BINARY,session_id COLLATE BINARY"
|
||||||
|
)
|
||||||
|
for session in sessions:
|
||||||
|
immediate = _feedback(connection, session["id"])
|
||||||
|
exported_session = {
|
||||||
|
key: session[key] for key in (
|
||||||
|
"session_id", "started_at", "ended_at", "session_type", "notes"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
exported_session["entries"] = []
|
||||||
|
entries = connection.execute(
|
||||||
|
"SELECT se.id AS row_id,se.entry_id,se.position,e.exercise_id,"
|
||||||
|
"e.name AS exercise_name,se.recording_mode," + tracking +
|
||||||
|
" AS tracking_mode,se.data_fields,se.equipment_id,se.notes "
|
||||||
|
"FROM session_exercises se "
|
||||||
|
"JOIN exercises e ON e.id=se.exercise_row_id "
|
||||||
|
"WHERE se.session_row_id=? "
|
||||||
|
"ORDER BY se.position,se.entry_id COLLATE BINARY",
|
||||||
|
(session["id"],),
|
||||||
|
)
|
||||||
|
for entry in entries:
|
||||||
|
item = {key: entry[key] for key in (
|
||||||
|
"entry_id", "exercise_id", "exercise_name", "position",
|
||||||
|
"recording_mode", "tracking_mode", "data_fields",
|
||||||
|
"equipment_id", "notes",
|
||||||
|
)}
|
||||||
|
result_name, result = _entry_result(connection, entry)
|
||||||
|
item[result_name] = result
|
||||||
|
item["immediate_feedback"] = immediate.get(entry["entry_id"], [])
|
||||||
|
exported_session["entries"].append(item)
|
||||||
|
# CONTRACT: follow-ups belong to the session only. The exporter never
|
||||||
|
# infers an exercise cause from time, text, or occurrence proximity.
|
||||||
|
exported_session["session_followups"] = _followups(
|
||||||
|
connection, session["id"]
|
||||||
|
)
|
||||||
|
root["sessions"].append(exported_session)
|
||||||
|
|
||||||
|
maxima = connection.execute(
|
||||||
|
"SELECT s.session_id,s.started_at,se.entry_id,e.exercise_id,"
|
||||||
|
"e.name AS exercise_name,m.max_weight_kg "
|
||||||
|
"FROM max_results m "
|
||||||
|
"JOIN session_exercises se ON se.id=m.session_exercise_row_id "
|
||||||
|
"JOIN sessions s ON s.id=se.session_row_id "
|
||||||
|
"JOIN exercises e ON e.id=se.exercise_row_id "
|
||||||
|
"ORDER BY s.started_at COLLATE BINARY,s.session_id COLLATE BINARY,"
|
||||||
|
"se.position,se.entry_id COLLATE BINARY"
|
||||||
|
)
|
||||||
|
root["max_history"] = [dict(row) for row in maxima]
|
||||||
|
|
||||||
|
columns = ",".join(("b.observation_id", "b.observed_at", "s.session_id") +
|
||||||
|
tuple("b." + name for name in MEASUREMENT_FIELDS) +
|
||||||
|
("b.notes",))
|
||||||
|
measurements = connection.execute(
|
||||||
|
f"SELECT {columns} FROM body_observations b "
|
||||||
|
"LEFT JOIN sessions s ON s.id=b.session_row_id "
|
||||||
|
"ORDER BY b.observed_at COLLATE BINARY,b.observation_id COLLATE BINARY"
|
||||||
|
)
|
||||||
|
root["measurements"] = [
|
||||||
|
{key: row[key] for key in row.keys() if row[key] is not None}
|
||||||
|
for row in measurements
|
||||||
|
]
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _write_atomic(output, payload):
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor, temporary = tempfile.mkstemp(
|
||||||
|
prefix=output.name + ".", suffix=".tmp", dir=output.parent
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
||||||
|
json.dump(payload, stream, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
stream.write("\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, output)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(temporary)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Export local Trainlog history for external analysis."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"output", type=Path, nargs="?", default=Path("trainlog_ai_export_v1.json")
|
||||||
|
)
|
||||||
|
parser.add_argument("--database", type=Path, default=default_database())
|
||||||
|
args = parser.parse_args()
|
||||||
|
generated_at = datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
).replace("+00:00", "Z")
|
||||||
|
connection = _readonly_connection(args.database)
|
||||||
|
try:
|
||||||
|
payload = build_export(connection, generated_at)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
_write_atomic(args.output, payload)
|
||||||
|
print("TRAINLOG_AI_EXPORT_V1=PASS")
|
||||||
|
print("output=" + str(args.output.resolve()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as error:
|
||||||
|
print("TRAINLOG_AI_EXPORT_V1=FAIL " + str(error))
|
||||||
|
raise SystemExit(1)
|
||||||
150
tools/export_ai_session_drafts.py
Normal file
150
tools/export_ai_session_drafts.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Export the PC→Android trainlog-ai-session-drafts v1 companion."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
MAX_DRAFTS = 256
|
||||||
|
ID_PREFIX = "aid_"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_publication_state(connection):
|
||||||
|
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||||
|
owned_transaction = not connection.in_transaction
|
||||||
|
if connection.execute("PRAGMA user_version").fetchone()[0] != 18:
|
||||||
|
raise ValueError("schema desktop v18 requis")
|
||||||
|
columns = {row[1] for row in connection.execute(
|
||||||
|
"PRAGMA table_info(ai_session_drafts)")}
|
||||||
|
if "published_at" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE ai_session_drafts ADD COLUMN published_at TEXT")
|
||||||
|
# INVARIANT: publication never weakens the permanent Drive replay ledger,
|
||||||
|
# including on an older v18 database whose FK was declared cascading.
|
||||||
|
connection.execute(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS ai_session_draft_import_identity_guard "
|
||||||
|
"BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM "
|
||||||
|
"ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN "
|
||||||
|
"SELECT RAISE(ABORT,'AI draft import identity is permanent');END;"
|
||||||
|
)
|
||||||
|
if owned_transaction:
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def build_export(connection, generated_at):
|
||||||
|
ensure_publication_state(connection)
|
||||||
|
drafts = []
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT id,draft_id,created_at,planned_for,session_type,title,notes "
|
||||||
|
"FROM ai_session_drafts WHERE published_at IS NULL "
|
||||||
|
"ORDER BY created_at COLLATE BINARY,draft_id COLLATE BINARY LIMIT ?",
|
||||||
|
(MAX_DRAFTS,),
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
entries = connection.execute(
|
||||||
|
"SELECT de.entry_id,de.position,e.exercise_id,de.recording_mode,de.tracking_mode,"
|
||||||
|
"de.data_fields,de.equipment_id,de.load_mode,de.rest_seconds,de.target_sets,"
|
||||||
|
"de.target_reps,de.target_duration_seconds,de.target_weight_kg "
|
||||||
|
"FROM ai_session_draft_entries de JOIN exercises e ON e.id=de.exercise_row_id "
|
||||||
|
"WHERE de.draft_row_id=? ORDER BY de.position",
|
||||||
|
(row[0],),
|
||||||
|
).fetchall()
|
||||||
|
drafts.append({
|
||||||
|
"draft_id": row[1], "created_at": row[2], "planned_for": row[3],
|
||||||
|
"session_type": row[4], "title": row[5], "notes": row[6],
|
||||||
|
"entries": [{
|
||||||
|
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
|
||||||
|
"recording_mode": entry[3], "tracking_mode": entry[4],
|
||||||
|
"data_fields": entry[5], "equipment_id": entry[6],
|
||||||
|
"load_mode": entry[7], "rest_seconds": entry[8],
|
||||||
|
"target": {"sets": entry[9], "reps": entry[10],
|
||||||
|
"duration_seconds": entry[11], "weight_kg": entry[12]},
|
||||||
|
} for entry in entries],
|
||||||
|
})
|
||||||
|
# CONTRACT: this companion is separate from every mobile-export version.
|
||||||
|
# INVARIANT: archive bookkeeping is desktop-private and is never published.
|
||||||
|
return {"format": "trainlog-ai-session-drafts", "version": 1,
|
||||||
|
"generated_at": generated_at, "drafts": drafts}
|
||||||
|
|
||||||
|
|
||||||
|
def mark_published(connection, payload, published_at):
|
||||||
|
"""Mark exactly one successfully transported outbound batch.
|
||||||
|
|
||||||
|
CONTRACT: callers invoke this only after the existing MTP publication
|
||||||
|
operation succeeds. INVARIANT: a failed or stale batch marks no row, so a
|
||||||
|
retry exports the same deterministic IDs and the import ledger is retained.
|
||||||
|
"""
|
||||||
|
ensure_publication_state(connection)
|
||||||
|
if (not isinstance(payload, dict) or set(payload) != {"format", "version", "generated_at", "drafts"}
|
||||||
|
or payload["format"] != "trainlog-ai-session-drafts" or payload["version"] != 1
|
||||||
|
or not isinstance(payload["drafts"], list) or len(payload["drafts"]) > MAX_DRAFTS):
|
||||||
|
raise ValueError("lot de brouillons IA publié invalide")
|
||||||
|
draft_ids = []
|
||||||
|
for draft in payload["drafts"]:
|
||||||
|
draft_id = draft.get("draft_id") if isinstance(draft, dict) else None
|
||||||
|
if not isinstance(draft_id, str) or not draft_id.startswith(ID_PREFIX) or draft_id in draft_ids:
|
||||||
|
raise ValueError("identité de brouillon IA publiée invalide")
|
||||||
|
draft_ids.append(draft_id)
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
for draft_id in draft_ids:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"UPDATE ai_session_drafts SET published_at=? WHERE draft_id=? AND published_at IS NULL",
|
||||||
|
(published_at, draft_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise ValueError(f"lot publié obsolète ou inconnu: {draft_id}")
|
||||||
|
connection.commit()
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
return len(draft_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def write_atomic(output, payload):
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, temporary = tempfile.mkstemp(prefix=output.name + ".", dir=output.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||||
|
json.dump(payload, stream, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, output)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(temporary)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("output", type=Path)
|
||||||
|
parser.add_argument("--database", required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
connection = connect_database(args.database)
|
||||||
|
try:
|
||||||
|
generated_at = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
payload = build_export(connection, generated_at)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
write_atomic(args.output, payload)
|
||||||
|
print(f"AI_SESSION_DRAFT_EXPORT=PASS drafts={len(payload['drafts'])}")
|
||||||
|
return 0
|
||||||
|
except (OSError, sqlite3.Error, ValueError) as error:
|
||||||
|
print(f"AI_SESSION_DRAFT_EXPORT=FAIL {error}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -28,7 +28,7 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||||
raise ValueError("schema desktop v8 à v16 requis")
|
raise ValueError("schema desktop v8 à v16 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ def main():
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||||
raise ValueError("schema desktop v8 à v16 requis")
|
raise ValueError("schema desktop v8 à v16 requis")
|
||||||
equipment = [dict(row) for row in connection.execute(
|
equipment = [dict(row) for row in connection.execute(
|
||||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17):
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18):
|
||||||
raise ValueError("schema desktop v12-v16 requis")
|
raise ValueError("schema desktop v12-v16 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ def main():
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18):
|
||||||
raise ValueError("schema desktop v11-v16 requis")
|
raise ValueError("schema desktop v11-v16 requis")
|
||||||
exercises = []
|
exercises = []
|
||||||
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
|
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def main():
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version != 17:
|
if version not in (17, 18):
|
||||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||||
rows = con.execute("""
|
rows = con.execute("""
|
||||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ def main():
|
||||||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ def main():
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if schema_version not in (11, 12, 13, 14, 15, 16, 17) and not (args.version == 2 and schema_version == 10):
|
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18) and not (args.version == 2 and schema_version == 10):
|
||||||
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||||
known_equipment = supplied_equipment_ids()
|
known_equipment = supplied_equipment_ids()
|
||||||
canonical_names = load_exercise_names()
|
canonical_names = load_exercise_names()
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ def main():
|
||||||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||||
db=connect_database(args.database)
|
db=connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17): raise ValueError("schema desktop v15/v16/v17 requis")
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18): raise ValueError("schema desktop v15-v18 requis")
|
||||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||||
if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations")
|
if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations")
|
||||||
|
|
|
||||||
256
tools/import_ai_session_draft.py
Normal file
256
tools/import_ai_session_draft.py
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Strict transactional importer for TRAINLOG_AI_SESSION_DRAFT_V1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
|
||||||
|
FORMAT = "TRAINLOG_AI_SESSION_DRAFT"
|
||||||
|
MAX_ENTRIES = 64
|
||||||
|
MAX_TITLE = 120
|
||||||
|
MAX_NOTES = 2000
|
||||||
|
ID_RE = re.compile(
|
||||||
|
r"^aid_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||||
|
)
|
||||||
|
EXERCISE_ID_RE = re.compile(
|
||||||
|
r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||||
|
)
|
||||||
|
UTC_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$")
|
||||||
|
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||||
|
|
||||||
|
|
||||||
|
class ImportFailure(ValueError):
|
||||||
|
"""A stable, user-actionable contract failure."""
|
||||||
|
|
||||||
|
|
||||||
|
def reject_constant(value: str):
|
||||||
|
raise ImportFailure(f"nombre non fini interdit: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
def unique_object(pairs):
|
||||||
|
result = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
raise ImportFailure(f"clé JSON dupliquée: {key}")
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def exact(value, keys, label):
|
||||||
|
if not isinstance(value, dict) or set(value) != keys:
|
||||||
|
raise ImportFailure(f"clés {label} invalides")
|
||||||
|
|
||||||
|
|
||||||
|
def integer(value, minimum, maximum, label):
|
||||||
|
if type(value) is not int or value < minimum or value > maximum:
|
||||||
|
raise ImportFailure(f"{label} hors limites")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def optional_text(value, maximum, label):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str) or not value or value != value.strip() or len(value) > maximum:
|
||||||
|
raise ImportFailure(f"{label} invalide")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def utc_timestamp(value, label):
|
||||||
|
if not isinstance(value, str) or UTC_RE.fullmatch(value) is None:
|
||||||
|
raise ImportFailure(f"{label} doit être un instant UTC canonique")
|
||||||
|
try:
|
||||||
|
parsed = dt.datetime.fromisoformat(value[:-1] + "+00:00")
|
||||||
|
except ValueError as error:
|
||||||
|
raise ImportFailure(f"{label} invalide") from error
|
||||||
|
if parsed.utcoffset() != dt.timedelta(0):
|
||||||
|
raise ImportFailure(f"{label} non UTC")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def optional_date(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str) or DATE_RE.fullmatch(value) is None:
|
||||||
|
raise ImportFailure("planned_for invalide")
|
||||||
|
try:
|
||||||
|
dt.date.fromisoformat(value)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ImportFailure("planned_for invalide") from error
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def optional_weight(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise ImportFailure("target_weight_kg invalide")
|
||||||
|
result = float(value)
|
||||||
|
if not math.isfinite(result) or result <= 0.0 or result > 10000.0:
|
||||||
|
raise ImportFailure("target_weight_kg hors limites")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_exercise(connection, source_id):
|
||||||
|
direct = connection.execute(
|
||||||
|
"SELECT id,recording_mode,tracking_mode,data_fields FROM exercises WHERE exercise_id=?",
|
||||||
|
(source_id,),
|
||||||
|
).fetchone()
|
||||||
|
if direct is not None:
|
||||||
|
return direct
|
||||||
|
alias = connection.execute(
|
||||||
|
"SELECT e.id,e.recording_mode,e.tracking_mode,e.data_fields FROM exercise_aliases a "
|
||||||
|
"JOIN exercises e ON e.exercise_id=a.canonical_exercise_id "
|
||||||
|
"WHERE a.source_exercise_id=?",
|
||||||
|
(source_id,),
|
||||||
|
).fetchone()
|
||||||
|
if alias is None:
|
||||||
|
raise ImportFailure(f"exercise_id inconnu ou sans alias explicite: {source_id}")
|
||||||
|
return alias
|
||||||
|
|
||||||
|
|
||||||
|
def validate(payload, connection):
|
||||||
|
exact(payload, {"format", "version", "draft"}, "racine")
|
||||||
|
if payload["format"] != FORMAT or type(payload["version"]) is not int or payload["version"] != 1:
|
||||||
|
raise ImportFailure("format/version AI draft non pris en charge")
|
||||||
|
draft = payload["draft"]
|
||||||
|
exact(draft, {"draft_id", "created_at", "planned_for", "session_type", "title", "notes", "entries"}, "draft")
|
||||||
|
if not isinstance(draft["draft_id"], str) or ID_RE.fullmatch(draft["draft_id"]) is None:
|
||||||
|
raise ImportFailure("draft_id aid_<uuid-v4> invalide")
|
||||||
|
utc_timestamp(draft["created_at"], "created_at")
|
||||||
|
optional_date(draft["planned_for"])
|
||||||
|
if draft["session_type"] != "training":
|
||||||
|
raise ImportFailure("session_type doit valoir training")
|
||||||
|
optional_text(draft["title"], MAX_TITLE, "title")
|
||||||
|
optional_text(draft["notes"], MAX_NOTES, "notes")
|
||||||
|
entries = draft["entries"]
|
||||||
|
if not isinstance(entries, list) or not 1 <= len(entries) <= MAX_ENTRIES:
|
||||||
|
raise ImportFailure("entries doit contenir 1 à 64 éléments")
|
||||||
|
|
||||||
|
validated = []
|
||||||
|
for expected_position, entry in enumerate(entries):
|
||||||
|
exact(entry, {"position", "exercise_id", "target_sets", "target_reps", "target_duration_seconds", "target_weight_kg", "rest_seconds"}, f"entries[{expected_position}]")
|
||||||
|
position = integer(entry["position"], 0, MAX_ENTRIES - 1, "position")
|
||||||
|
if position != expected_position:
|
||||||
|
raise ImportFailure("positions entries non contiguës ou hors ordre")
|
||||||
|
source_id = entry["exercise_id"]
|
||||||
|
if not isinstance(source_id, str) or EXERCISE_ID_RE.fullmatch(source_id) is None:
|
||||||
|
raise ImportFailure("exercise_id ex_<uuid-v4> invalide")
|
||||||
|
exercise_row_id, recording_mode, tracking_mode, data_fields = resolve_exercise(connection, source_id)
|
||||||
|
if recording_mode != "sets":
|
||||||
|
raise ImportFailure(f"exercice CONTINUOUS interdit dans un plan SETS: {source_id}")
|
||||||
|
target_sets = integer(entry["target_sets"], 1, 99, "target_sets")
|
||||||
|
target_reps = entry["target_reps"]
|
||||||
|
target_duration = entry["target_duration_seconds"]
|
||||||
|
if tracking_mode == "reps":
|
||||||
|
target_reps = integer(target_reps, 1, 999, "target_reps")
|
||||||
|
if target_duration is not None:
|
||||||
|
raise ImportFailure("target_duration_seconds interdit pour tracking reps")
|
||||||
|
elif tracking_mode == "duration":
|
||||||
|
target_duration = integer(target_duration, 1, 86400, "target_duration_seconds")
|
||||||
|
if target_reps is not None:
|
||||||
|
raise ImportFailure("target_reps interdit pour tracking duration")
|
||||||
|
else:
|
||||||
|
raise ImportFailure("profil exercice corrompu")
|
||||||
|
weight = optional_weight(entry["target_weight_kg"])
|
||||||
|
validated.append((
|
||||||
|
position, exercise_row_id, source_id, recording_mode, tracking_mode,
|
||||||
|
data_fields, None, "external" if weight is not None else "none", target_sets,
|
||||||
|
target_reps, target_duration, weight,
|
||||||
|
integer(entry["rest_seconds"], 0, 86400, "rest_seconds"),
|
||||||
|
))
|
||||||
|
return draft, validated
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_digest(payload):
|
||||||
|
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def entry_id(draft_id, position):
|
||||||
|
"""Return a stable UUIDv4-shaped creator ID for one immutable source slot."""
|
||||||
|
raw = bytearray(hashlib.sha256(f"{draft_id}\n{position}".encode("ascii")).digest()[:16])
|
||||||
|
raw[6] = (raw[6] & 0x0F) | 0x40
|
||||||
|
raw[8] = (raw[8] & 0x3F) | 0x80
|
||||||
|
value = raw.hex()
|
||||||
|
return f"sxe_{value[:8]}-{value[8:12]}-{value[12:16]}-{value[16:20]}-{value[20:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def import_payload(connection, payload, imported_at=None):
|
||||||
|
"""Validate fully, then atomically insert or return ``skipped``.
|
||||||
|
|
||||||
|
CONTRACT: successful rows represent target-only pending SETS plans.
|
||||||
|
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||||
|
any failure leaves no draft, child, or import-identity row behind.
|
||||||
|
"""
|
||||||
|
if connection.execute("PRAGMA user_version").fetchone()[0] != 18:
|
||||||
|
raise ImportFailure("schema desktop v18 requis")
|
||||||
|
draft, entries = validate(payload, connection)
|
||||||
|
digest = canonical_digest(payload)
|
||||||
|
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT payload_sha256 FROM ai_session_draft_imports WHERE draft_id=?",
|
||||||
|
(draft["draft_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
if existing[0] != digest:
|
||||||
|
raise ImportFailure("conflit: draft_id déjà importé avec un contenu différent")
|
||||||
|
connection.commit()
|
||||||
|
return "skipped"
|
||||||
|
cursor = connection.execute(
|
||||||
|
"INSERT INTO ai_session_drafts(draft_id,created_at,planned_for,session_type,title,notes) VALUES(?,?,?,?,?,?)",
|
||||||
|
(draft["draft_id"], draft["created_at"], draft["planned_for"], draft["session_type"], draft["title"], draft["notes"]),
|
||||||
|
)
|
||||||
|
draft_row_id = cursor.lastrowid
|
||||||
|
connection.executemany(
|
||||||
|
"INSERT INTO ai_session_draft_entries(draft_row_id,entry_id,position,exercise_row_id,source_exercise_id,recording_mode,tracking_mode,data_fields,equipment_id,load_mode,target_sets,target_reps,target_duration_seconds,target_weight_kg,rest_seconds) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
[(draft_row_id, entry_id(draft["draft_id"], row[0])) + row for row in entries],
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO ai_session_draft_imports(draft_row_id,draft_id,payload_sha256,imported_at) VALUES(?,?,?,?)",
|
||||||
|
(draft_row_id, draft["draft_id"], digest, imported_at),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
return "imported"
|
||||||
|
|
||||||
|
|
||||||
|
def load(path):
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object, parse_constant=reject_constant)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("artifact", type=Path)
|
||||||
|
parser.add_argument("--database", required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
payload = load(args.artifact)
|
||||||
|
connection = connect_database(args.database)
|
||||||
|
try:
|
||||||
|
result = import_payload(connection, payload)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
print(f"AI_SESSION_DRAFT_IMPORT=PASS result={result} draft_id={payload['draft']['draft_id']}")
|
||||||
|
return 0
|
||||||
|
except (OSError, json.JSONDecodeError, sqlite3.Error, ImportFailure) as error:
|
||||||
|
print(f"AI_SESSION_DRAFT_IMPORT=FAIL {error}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -173,7 +173,7 @@ def main():
|
||||||
fail("clés extension équipement invalides")
|
fail("clés extension équipement invalides")
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||||
fail("schema desktop v8 à v16 requis")
|
fail("schema desktop v8 à v16 requis")
|
||||||
known = load_catalog(args.catalog)
|
known = load_catalog(args.catalog)
|
||||||
known.update(row[0] for row in connection.execute(
|
known.update(row[0] for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ def main():
|
||||||
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||||
fail("schema desktop v8 à v16 requis")
|
fail("schema desktop v8 à v16 requis")
|
||||||
imported = skipped = 0
|
imported = skipped = 0
|
||||||
# Validate every same-ID row before inserting any definition.
|
# Validate every same-ID row before inserting any definition.
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ def main():
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
con.execute("PRAGMA foreign_keys=ON")
|
con.execute("PRAGMA foreign_keys=ON")
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17):
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18):
|
||||||
fail("schema desktop v12-v16 requis")
|
fail("schema desktop v12-v16 requis")
|
||||||
con.execute("BEGIN IMMEDIATE")
|
con.execute("BEGIN IMMEDIATE")
|
||||||
for source, canonical in aliases:
|
for source, canonical in aliases:
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ def main():
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
updated = skipped = kept_local = 0
|
updated = skipped = kept_local = 0
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18):
|
||||||
raise ImportFailure("schema desktop v11-v16 requis")
|
raise ImportFailure("schema desktop v11-v16 requis")
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ def main():
|
||||||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||||
parse_timestamp(root["generated_at"], "generated_at")
|
parse_timestamp(root["generated_at"], "generated_at")
|
||||||
con=connect_database(a.database);con.row_factory=sqlite3.Row
|
con=connect_database(a.database);con.row_factory=sqlite3.Row
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0]!=17: fail("schema desktop v17 requis")
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18): fail("schema desktop v17/v18 requis")
|
||||||
applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE")
|
applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE")
|
||||||
for item in root["exercises"]:
|
for item in root["exercises"]:
|
||||||
validate(item);eid=item["exercise_id"]
|
validate(item);eid=item["exercise_id"]
|
||||||
|
|
|
||||||
|
|
@ -813,9 +813,9 @@ def require_supported_schema(connection):
|
||||||
|
|
||||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# readable for legacy artifacts and are never made to fake that table.
|
||||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"base desktop schema v5 à v17 attendue, version trouvée: {version}"
|
f"base desktop schema v5 à v18 attendue, version trouvée: {version}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ def main():
|
||||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||||
try:
|
try:
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
db.execute("PRAGMA foreign_keys=ON")
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17):fail("schema desktop v15/v16/v17 requis")
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18):fail("schema desktop v15-v18 requis")
|
||||||
db.execute("BEGIN IMMEDIATE");seen=set()
|
db.execute("BEGIN IMMEDIATE");seen=set()
|
||||||
for index,item in enumerate(root["exercise_feedback"]):
|
for index,item in enumerate(root["exercise_feedback"]):
|
||||||
keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"}
|
keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"}
|
||||||
|
|
|
||||||
38
tools/mark_ai_session_drafts_published.py
Normal file
38
tools/mark_ai_session_drafts_published.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Commit the lifecycle cursor for one already-published AI draft batch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from export_ai_session_drafts import mark_published
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("artifact", type=Path)
|
||||||
|
parser.add_argument("--database", required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||||
|
connection = connect_database(args.database)
|
||||||
|
try:
|
||||||
|
published_at = dt.datetime.now(dt.timezone.utc).isoformat(
|
||||||
|
timespec="seconds").replace("+00:00", "Z")
|
||||||
|
count = mark_published(connection, payload, published_at)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
print(f"AI_SESSION_DRAFT_PUBLISH_MARK=PASS drafts={count}")
|
||||||
|
return 0
|
||||||
|
except (OSError, sqlite3.Error, ValueError, json.JSONDecodeError) as error:
|
||||||
|
print(f"AI_SESSION_DRAFT_PUBLISH_MARK=FAIL {error}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
93
tools/post_sync_ai_drive.py
Normal file
93
tools/post_sync_ai_drive.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish the post-sync TRAINLOG_AI_EXPORT_V1 artifact to Google Drive."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DRIVE_TARGET = "TrainLog Gdrive:Trainlog/AI/trainlog_ai_export_v1.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _detail(result: subprocess.CompletedProcess[str]) -> str:
|
||||||
|
"""Return one bounded, useful external-command diagnostic."""
|
||||||
|
text = (result.stderr or result.stdout or "").strip()
|
||||||
|
if not text:
|
||||||
|
return f"exit={result.returncode}"
|
||||||
|
return text.splitlines()[-1][:1000]
|
||||||
|
|
||||||
|
|
||||||
|
def publish(database: Path, output: Path) -> int:
|
||||||
|
"""Export first, then upload only the successfully replaced artifact.
|
||||||
|
|
||||||
|
CONTRACT: the exporter remains the sole owner of TRAINLOG_AI_EXPORT_V1
|
||||||
|
generation. rclone receives only file and remote arguments; Trainlog never
|
||||||
|
reads, stores, or forwards rclone/OAuth configuration. INVARIANT: an export
|
||||||
|
failure cannot upload an older file left at the destination path.
|
||||||
|
"""
|
||||||
|
project_root = Path(__file__).resolve().parent.parent
|
||||||
|
resolved_output = output if output.is_absolute() else project_root / output
|
||||||
|
exporter = project_root / "tools" / "export_ai_history.py"
|
||||||
|
|
||||||
|
try:
|
||||||
|
export = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(exporter),
|
||||||
|
str(resolved_output),
|
||||||
|
"--database",
|
||||||
|
str(database),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
print(f"AI_EXPORT=FAIL {error}")
|
||||||
|
print("GDRIVE_UPLOAD=SKIPPED")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if export.returncode != 0 or "TRAINLOG_AI_EXPORT_V1=PASS" not in export.stdout:
|
||||||
|
print(f"AI_EXPORT=FAIL {_detail(export)}")
|
||||||
|
print("GDRIVE_UPLOAD=SKIPPED")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("AI_EXPORT=PASS")
|
||||||
|
|
||||||
|
try:
|
||||||
|
upload = subprocess.run(
|
||||||
|
["rclone", "copyto", str(resolved_output), DRIVE_TARGET],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
print(f"GDRIVE_UPLOAD=FAIL {error}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if upload.returncode != 0:
|
||||||
|
print(f"GDRIVE_UPLOAD=FAIL {_detail(upload)}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("GDRIVE_UPLOAD=PASS")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"output",
|
||||||
|
type=Path,
|
||||||
|
nargs="?",
|
||||||
|
default=Path("trainlog_ai_export_v1.json"),
|
||||||
|
)
|
||||||
|
parser.add_argument("--database", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
return publish(args.database, args.output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
193
tools/sync_ai_session_draft_drive.py
Normal file
193
tools/sync_ai_session_draft_drive.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fetch, import, and archive-copy the single AI session-draft inbox artifact."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from import_ai_session_draft import ImportFailure, import_payload, load
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
|
||||||
|
INBOX_ROOT = "TrainLog Gdrive:Trainlog/AI/inbox"
|
||||||
|
ARTIFACT_NAME = "trainlog_ai_session_draft_v1.json"
|
||||||
|
SOURCE = f"{INBOX_ROOT}/{ARTIFACT_NAME}"
|
||||||
|
ARCHIVE_ROOT = "TrainLog Gdrive:Trainlog/AI/archive"
|
||||||
|
RCLONE_TIMEOUT_SECONDS = 15
|
||||||
|
DIAGNOSTIC_LIMIT = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def detail(result):
|
||||||
|
value = ((result.stderr or "")[:DIAGNOSTIC_LIMIT] or
|
||||||
|
(result.stdout or "")[:DIAGNOSTIC_LIMIT]).strip()
|
||||||
|
return (value.splitlines()[-1] if value else f"exit={result.returncode}")[:DIAGNOSTIC_LIMIT]
|
||||||
|
|
||||||
|
|
||||||
|
def run_rclone(runner, argv):
|
||||||
|
"""Execute a bounded, shell-free rclone command."""
|
||||||
|
return runner(argv, capture_output=True, text=True, check=False,
|
||||||
|
timeout=RCLONE_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def inbox_contains_artifact(runner):
|
||||||
|
"""Return whether the exact canonical filename is present in the inbox."""
|
||||||
|
try:
|
||||||
|
listed = run_rclone(runner, ["rclone", "lsf", INBOX_ROOT,
|
||||||
|
"--files-only", "--max-depth", "1",
|
||||||
|
"--include", ARTIFACT_NAME])
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||||||
|
return None, str(error)[:DIAGNOSTIC_LIMIT]
|
||||||
|
if listed.returncode != 0:
|
||||||
|
return None, detail(listed)
|
||||||
|
names = {(line.strip().rstrip("/")) for line in
|
||||||
|
(listed.stdout or "")[:DIAGNOSTIC_LIMIT].splitlines()}
|
||||||
|
return ARTIFACT_NAME in names, None
|
||||||
|
|
||||||
|
|
||||||
|
def update_archive(database, draft_id, status, error=None):
|
||||||
|
connection = connect_database(database)
|
||||||
|
try:
|
||||||
|
archived_at = (dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
if status == "archived" else None)
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE ai_session_drafts SET archive_status=?,archived_at=?,archive_error=? WHERE draft_id=?",
|
||||||
|
(status, archived_at, error, draft_id),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def try_update_archive(database, draft_id, status, error=None):
|
||||||
|
try:
|
||||||
|
update_archive(database, draft_id, status, error)
|
||||||
|
return None
|
||||||
|
except (OSError, sqlite3.Error) as update_error:
|
||||||
|
return str(update_error)[:1000]
|
||||||
|
|
||||||
|
|
||||||
|
def secure_local_draft(directory):
|
||||||
|
"""Create the inbound destination and verify its private permissions."""
|
||||||
|
directory_path = Path(directory)
|
||||||
|
os.chmod(directory_path, 0o700)
|
||||||
|
if stat.S_IMODE(directory_path.stat().st_mode) != 0o700:
|
||||||
|
raise OSError("le répertoire temporaire n'est pas privé")
|
||||||
|
local = directory_path / "draft.json"
|
||||||
|
descriptor = os.open(local, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
os.close(descriptor)
|
||||||
|
if stat.S_IMODE(local.stat().st_mode) != 0o600:
|
||||||
|
raise OSError("le brouillon temporaire n'est pas privé")
|
||||||
|
return local
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_private_local_draft(local):
|
||||||
|
"""Restore and verify the inbound destination's private permissions."""
|
||||||
|
os.chmod(local, 0o600)
|
||||||
|
if stat.S_IMODE(local.stat().st_mode) != 0o600:
|
||||||
|
raise OSError("le brouillon temporaire n'est pas privé")
|
||||||
|
|
||||||
|
|
||||||
|
def synchronize(database, runner=subprocess.run):
|
||||||
|
"""Run shell-free rclone operations; transport failures are nonfatal.
|
||||||
|
|
||||||
|
WHY: a fixed /tmp filename could re-import stale content after a failed
|
||||||
|
download. CONTRACT: copyto writes only a fresh mode-0600 tempfile and argv
|
||||||
|
is never interpreted by a shell. INVARIANT: the exact validated tempfile is
|
||||||
|
archive-copied only after the importer commits; the mutable inbox object is
|
||||||
|
never moved or deleted, so every failure remains retryable.
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory(prefix="trainlog-ai-draft-") as directory:
|
||||||
|
present, listing_error = inbox_contains_artifact(runner)
|
||||||
|
if present is None:
|
||||||
|
print(f"AI_SESSION_DRAFT_FETCH=FAIL {listing_error}")
|
||||||
|
print("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL")
|
||||||
|
return 0
|
||||||
|
if not present:
|
||||||
|
print("AI_SESSION_DRAFT_FETCH=NONE")
|
||||||
|
print("AI_SESSION_DRAFT_INBOUND=NONE")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
local = secure_local_draft(directory)
|
||||||
|
except OSError as error:
|
||||||
|
print(f"AI_SESSION_DRAFT_FETCH=FAIL {error}")
|
||||||
|
print("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
fetched = run_rclone(runner, ["rclone", "copyto", SOURCE, str(local)])
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||||||
|
print(f"AI_SESSION_DRAFT_FETCH=FAIL {error}")
|
||||||
|
print("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL")
|
||||||
|
return 0
|
||||||
|
if fetched.returncode != 0:
|
||||||
|
print(f"AI_SESSION_DRAFT_FETCH=FAIL {detail(fetched)}")
|
||||||
|
print("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
if not local.is_file() or local.stat().st_size == 0:
|
||||||
|
raise OSError("rclone n'a produit aucun fichier")
|
||||||
|
enforce_private_local_draft(local)
|
||||||
|
except OSError as error:
|
||||||
|
print(f"AI_SESSION_DRAFT_FETCH=FAIL {error}")
|
||||||
|
print("AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
payload = load(local)
|
||||||
|
connection = connect_database(database)
|
||||||
|
try:
|
||||||
|
result = import_payload(connection, payload)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
except (OSError, sqlite3.Error, ImportFailure, ValueError) as error:
|
||||||
|
print(f"AI_SESSION_DRAFT_IMPORT=FAIL {error}")
|
||||||
|
print("AI_SESSION_DRAFT_INBOUND=REJECTED")
|
||||||
|
return 0
|
||||||
|
draft_id = payload["draft"]["draft_id"]
|
||||||
|
print(f"AI_SESSION_DRAFT_IMPORT=PASS result={result} draft_id={draft_id}")
|
||||||
|
target = f"{ARCHIVE_ROOT}/{draft_id}.json"
|
||||||
|
try:
|
||||||
|
archived = run_rclone(runner, ["rclone", "copyto", str(local), target])
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||||||
|
update_error = try_update_archive(database, draft_id, "failed", str(error)[:1000])
|
||||||
|
print(f"AI_SESSION_DRAFT_ARCHIVE=FAIL {error}")
|
||||||
|
if update_error is not None:
|
||||||
|
print(f"AI_SESSION_DRAFT_ARCHIVE_STATUS=FAIL {update_error}")
|
||||||
|
print("AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL")
|
||||||
|
return 0
|
||||||
|
if archived.returncode != 0:
|
||||||
|
error = detail(archived)
|
||||||
|
update_error = try_update_archive(database, draft_id, "failed", error)
|
||||||
|
print(f"AI_SESSION_DRAFT_ARCHIVE=FAIL {error}")
|
||||||
|
if update_error is not None:
|
||||||
|
print(f"AI_SESSION_DRAFT_ARCHIVE_STATUS=FAIL {update_error}")
|
||||||
|
print("AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL")
|
||||||
|
return 0
|
||||||
|
update_error = try_update_archive(database, draft_id, "archived")
|
||||||
|
if update_error is not None:
|
||||||
|
print(f"AI_SESSION_DRAFT_ARCHIVE_STATUS=FAIL {update_error}")
|
||||||
|
print("AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL")
|
||||||
|
return 0
|
||||||
|
print("AI_SESSION_DRAFT_ARCHIVE=PASS")
|
||||||
|
print("AI_SESSION_DRAFT_INBOUND=" +
|
||||||
|
("IMPORTED" if result == "imported" else "ALREADY_IMPORTED"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("artifact", help="reserved sync-tool positional argument")
|
||||||
|
parser.add_argument("--database", required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.artifact != ARTIFACT_NAME:
|
||||||
|
parser.error(f"artifact must be {ARTIFACT_NAME}")
|
||||||
|
return synchronize(args.database)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 17
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 18
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,16 @@ typedef struct TrainlogSyncDeviceInfo {
|
||||||
TrainlogMtpStorage storage;
|
TrainlogMtpStorage storage;
|
||||||
} TrainlogSyncDeviceInfo;
|
} TrainlogSyncDeviceInfo;
|
||||||
|
|
||||||
|
typedef enum TrainlogAiInboundStatus {
|
||||||
|
TRAINLOG_AI_INBOUND_NOT_RUN = 0,
|
||||||
|
TRAINLOG_AI_INBOUND_NONE,
|
||||||
|
TRAINLOG_AI_INBOUND_IMPORTED,
|
||||||
|
TRAINLOG_AI_INBOUND_ALREADY_IMPORTED,
|
||||||
|
TRAINLOG_AI_INBOUND_REJECTED,
|
||||||
|
TRAINLOG_AI_INBOUND_DRIVE_FAIL,
|
||||||
|
TRAINLOG_AI_INBOUND_ARCHIVE_FAIL
|
||||||
|
} TrainlogAiInboundStatus;
|
||||||
|
|
||||||
typedef struct TrainlogSyncReport {
|
typedef struct TrainlogSyncReport {
|
||||||
bool success;
|
bool success;
|
||||||
bool request_present;
|
bool request_present;
|
||||||
|
|
@ -72,6 +82,7 @@ typedef struct TrainlogSyncReport {
|
||||||
size_t equipment_definitions_skipped;
|
size_t equipment_definitions_skipped;
|
||||||
|
|
||||||
size_t catalog_published;
|
size_t catalog_published;
|
||||||
|
TrainlogAiInboundStatus ai_inbound_status;
|
||||||
|
|
||||||
char summary[
|
char summary[
|
||||||
TRAINLOG_SYNC_SUMMARY_MAX + 1U
|
TRAINLOG_SYNC_SUMMARY_MAX + 1U
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ session_generation_policy_generated = custom_target(
|
||||||
|
|
||||||
strict_c_args = [
|
strict_c_args = [
|
||||||
'-D_POSIX_C_SOURCE=200809L',
|
'-D_POSIX_C_SOURCE=200809L',
|
||||||
|
'-DTRAINLOG_TOOLS_DIR="@0@"'.format(meson.project_source_root() / 'tools'),
|
||||||
'-Wconversion',
|
'-Wconversion',
|
||||||
'-Wformat=2',
|
'-Wformat=2',
|
||||||
'-Wshadow',
|
'-Wshadow',
|
||||||
|
|
@ -674,6 +675,24 @@ test_feedback_correction = executable(
|
||||||
)
|
)
|
||||||
test('feedback_correction', test_feedback_correction)
|
test('feedback_correction', test_feedback_correction)
|
||||||
|
|
||||||
|
test(
|
||||||
|
'ai_history_export',
|
||||||
|
find_program('python3'),
|
||||||
|
args: [meson.project_source_root() / 'tests/test_ai_history_export.py'],
|
||||||
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
'post_sync_ai_drive',
|
||||||
|
find_program('python3'),
|
||||||
|
args: [meson.project_source_root() / 'tests/test_post_sync_ai_drive.py'],
|
||||||
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
'ai_session_draft',
|
||||||
|
find_program('python3'),
|
||||||
|
args: [meson.project_source_root() / 'tests/test_ai_session_draft.py'],
|
||||||
|
)
|
||||||
|
|
||||||
test_schema_v16_migration = executable(
|
test_schema_v16_migration = executable(
|
||||||
'test_schema_v16_migration',
|
'test_schema_v16_migration',
|
||||||
'tests/test_schema_v16_migration.c',
|
'tests/test_schema_v16_migration.c',
|
||||||
|
|
|
||||||
|
|
@ -772,6 +772,77 @@ static const char *const MIGRATE_V16_TO_V17_SQL =
|
||||||
"INSERT INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;"
|
"INSERT INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;"
|
||||||
"PRAGMA user_version=17;COMMIT;";
|
"PRAGMA user_version=17;COMMIT;";
|
||||||
|
|
||||||
|
/* WHY: generated plans are untrusted inbox data and must never masquerade as
|
||||||
|
* completed occurrence history. CONTRACT: v18 adds an isolated durable inbox,
|
||||||
|
* target-only SETS children, canonical import identity, bounded publication
|
||||||
|
* state, and post-commit Drive archive state. INVARIANT: no existing domain
|
||||||
|
* table or frozen exchange column is changed. */
|
||||||
|
static const char *const MIGRATE_V17_TO_V18_SQL =
|
||||||
|
"BEGIN IMMEDIATE;"
|
||||||
|
"CREATE TABLE ai_session_drafts("
|
||||||
|
"id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL,"
|
||||||
|
"planned_for TEXT,session_type TEXT NOT NULL CHECK(session_type='training'),"
|
||||||
|
"title TEXT,notes TEXT,archive_status TEXT NOT NULL DEFAULT 'pending' "
|
||||||
|
"CHECK(archive_status IN('pending','archived','failed')),"
|
||||||
|
"archived_at TEXT,archive_error TEXT,published_at TEXT);"
|
||||||
|
"CREATE TABLE ai_session_draft_entries("
|
||||||
|
"id INTEGER PRIMARY KEY,draft_row_id INTEGER NOT NULL REFERENCES ai_session_drafts(id) ON DELETE CASCADE,"
|
||||||
|
"entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL CHECK(position>=0),exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,"
|
||||||
|
"source_exercise_id TEXT NOT NULL,recording_mode TEXT NOT NULL CHECK(recording_mode='sets'),"
|
||||||
|
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),data_fields INTEGER NOT NULL,"
|
||||||
|
"equipment_id TEXT,load_mode TEXT NOT NULL CHECK(load_mode IN('none','external')),"
|
||||||
|
"target_sets INTEGER NOT NULL CHECK(target_sets BETWEEN 1 AND 99),"
|
||||||
|
"target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,"
|
||||||
|
"rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400),"
|
||||||
|
"UNIQUE(draft_row_id,position));"
|
||||||
|
"CREATE INDEX ai_session_draft_entries_exercise ON ai_session_draft_entries(exercise_row_id);"
|
||||||
|
"CREATE TABLE ai_session_draft_imports("
|
||||||
|
"draft_row_id INTEGER PRIMARY KEY,"
|
||||||
|
"draft_id TEXT NOT NULL UNIQUE,payload_sha256 TEXT NOT NULL UNIQUE,imported_at TEXT NOT NULL);"
|
||||||
|
"CREATE TRIGGER ai_session_draft_import_identity_guard BEFORE DELETE ON ai_session_drafts "
|
||||||
|
"WHEN EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) "
|
||||||
|
"BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;"
|
||||||
|
"PRAGMA user_version=18;COMMIT;";
|
||||||
|
|
||||||
|
/* CONTRACT: v18 was already deployed before bounded draft publication was
|
||||||
|
* added. Keep its version number and add only the nullable lifecycle cursor;
|
||||||
|
* NULL deliberately means retryable/not yet published. */
|
||||||
|
static TrainlogStatus ensure_v18_ai_draft_publication_state(
|
||||||
|
TrainlogDatabase *database
|
||||||
|
)
|
||||||
|
{
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int rc;
|
||||||
|
bool found = false;
|
||||||
|
|
||||||
|
rc = sqlite3_prepare_v2(database->connection,
|
||||||
|
"PRAGMA table_info(ai_session_drafts);", -1, &statement, NULL);
|
||||||
|
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||||
|
const unsigned char *name = sqlite3_column_text(statement, 1);
|
||||||
|
if (name != NULL && strcmp((const char *)name, "published_at") == 0) {
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rc != SQLITE_ROW && rc != SQLITE_DONE) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (sqlite3_finalize(statement) != SQLITE_OK)
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
if (!found && execute_sql(database,
|
||||||
|
"ALTER TABLE ai_session_drafts ADD COLUMN published_at TEXT;") !=
|
||||||
|
TRAINLOG_STATUS_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
/* INVARIANT: older v18 tables may still declare a cascading FK. This
|
||||||
|
* guard runs before it and makes replay identity durable for both shapes. */
|
||||||
|
return execute_sql(database,
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS ai_session_draft_import_identity_guard "
|
||||||
|
"BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM "
|
||||||
|
"ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN "
|
||||||
|
"SELECT RAISE(ABORT,'AI draft import identity is permanent');END;");
|
||||||
|
}
|
||||||
|
|
||||||
/* WHY: the published-unreleased v17 prototype may already exist locally.
|
/* WHY: the published-unreleased v17 prototype may already exist locally.
|
||||||
* CONTRACT: opening it adds the bounded durable lineage table without changing
|
* CONTRACT: opening it adds the bounded durable lineage table without changing
|
||||||
* user_version or domain rows. INVARIANT: the guarded trigger records local
|
* user_version or domain rows. INVARIANT: the guarded trigger records local
|
||||||
|
|
@ -1425,7 +1496,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
} else if (version == 10) {
|
} else if (version == 10) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
||||||
version == 15 || version == 16 || version == 17) {
|
version == 15 || version == 16 || version == 17 || version == 18) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
|
|
@ -1586,6 +1657,12 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
if (status == TRAINLOG_STATUS_OK && version < 17) {
|
if (status == TRAINLOG_STATUS_OK && version < 17) {
|
||||||
status = execute_sql(database, MIGRATE_V16_TO_V17_SQL);
|
status = execute_sql(database, MIGRATE_V16_TO_V17_SQL);
|
||||||
}
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && version < 18) {
|
||||||
|
status = execute_sql(database, MIGRATE_V17_TO_V18_SQL);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = ensure_v18_ai_draft_publication_state(database);
|
||||||
|
}
|
||||||
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, "BEGIN IMMEDIATE;");
|
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, "BEGIN IMMEDIATE;");
|
||||||
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, ENSURE_V17_PROFILE_REVISIONS_SQL);
|
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, ENSURE_V17_PROFILE_REVISIONS_SQL);
|
||||||
if (status == TRAINLOG_STATUS_OK) status = normalize_v17_prototype_profiles(database);
|
if (status == TRAINLOG_STATUS_OK) status = normalize_v17_prototype_profiles(database);
|
||||||
|
|
@ -1598,7 +1675,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
set_open_diagnostic(
|
set_open_diagnostic(
|
||||||
output_diagnostic,
|
output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
output_diagnostic_capacity,
|
||||||
version == 0 ? "create schema v17" : "migrate database to schema v17",
|
version == 0 ? "create schema v18" : "migrate database to schema v18",
|
||||||
database->connection,
|
database->connection,
|
||||||
SQLITE_ERROR
|
SQLITE_ERROR
|
||||||
);
|
);
|
||||||
|
|
|
||||||
309
tui/src/sync.c
309
tui/src/sync.c
|
|
@ -63,6 +63,8 @@ static const char *const TRAINING_FEEDBACK_V1_NAME =
|
||||||
"trainlog-training-feedback-v1.json";
|
"trainlog-training-feedback-v1.json";
|
||||||
static const char *const EXERCISE_PROFILE_STATE_NAME =
|
static const char *const EXERCISE_PROFILE_STATE_NAME =
|
||||||
"trainlog-exercise-profile-state-v1.json";
|
"trainlog-exercise-profile-state-v1.json";
|
||||||
|
static const char *const AI_SESSION_DRAFTS_NAME =
|
||||||
|
"trainlog-ai-session-drafts-v1.json";
|
||||||
|
|
||||||
static const char *const SYNC_REQUEST_NAME =
|
static const char *const SYNC_REQUEST_NAME =
|
||||||
"trainlog-sync-request-v1.json";
|
"trainlog-sync-request-v1.json";
|
||||||
|
|
@ -100,6 +102,8 @@ static const char *const TRAINING_FEEDBACK_LOCAL =
|
||||||
"/tmp/trainlog-training-feedback-v2.json";
|
"/tmp/trainlog-training-feedback-v2.json";
|
||||||
static const char *const EXERCISE_PROFILE_STATE_LOCAL =
|
static const char *const EXERCISE_PROFILE_STATE_LOCAL =
|
||||||
"/tmp/trainlog-exercise-profile-state-v1.json";
|
"/tmp/trainlog-exercise-profile-state-v1.json";
|
||||||
|
static const char *const AI_SESSION_DRAFTS_LOCAL =
|
||||||
|
"/tmp/trainlog-ai-session-drafts-v1.json";
|
||||||
|
|
||||||
static const char *const EXERCISE_BODY_ZONES_RESULT =
|
static const char *const EXERCISE_BODY_ZONES_RESULT =
|
||||||
"/tmp/trainlog-exercise-body-zones-result.txt";
|
"/tmp/trainlog-exercise-body-zones-result.txt";
|
||||||
|
|
@ -126,6 +130,11 @@ static const char *const PC_CATALOG_RESULT =
|
||||||
static const char *const EQUIPMENT_ASSOCIATIONS_RESULT =
|
static const char *const EQUIPMENT_ASSOCIATIONS_RESULT =
|
||||||
"/tmp/trainlog-equipment-associations-result.txt";
|
"/tmp/trainlog-equipment-associations-result.txt";
|
||||||
|
|
||||||
|
static const char *const AI_POST_SYNC_RESULT =
|
||||||
|
"/tmp/trainlog-ai-post-sync-result.txt";
|
||||||
|
static const char *const AI_SESSION_DRAFT_RESULT =
|
||||||
|
"/tmp/trainlog-ai-session-draft-result.txt";
|
||||||
|
|
||||||
typedef struct SyncSilence {
|
typedef struct SyncSilence {
|
||||||
int saved_stdout;
|
int saved_stdout;
|
||||||
int saved_stderr;
|
int saved_stderr;
|
||||||
|
|
@ -165,6 +174,60 @@ static void sync_compose_diagnostic(
|
||||||
output[written] = '\0';
|
output[written] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const char *sync_ai_inbound_status_text(TrainlogAiInboundStatus status)
|
||||||
|
{
|
||||||
|
switch (status) {
|
||||||
|
case TRAINLOG_AI_INBOUND_NONE: return "NONE";
|
||||||
|
case TRAINLOG_AI_INBOUND_IMPORTED: return "IMPORTED";
|
||||||
|
case TRAINLOG_AI_INBOUND_ALREADY_IMPORTED: return "ALREADY_IMPORTED";
|
||||||
|
case TRAINLOG_AI_INBOUND_REJECTED: return "REJECTED";
|
||||||
|
case TRAINLOG_AI_INBOUND_DRIVE_FAIL: return "DRIVE_FAIL";
|
||||||
|
case TRAINLOG_AI_INBOUND_ARCHIVE_FAIL: return "ARCHIVE_FAIL";
|
||||||
|
case TRAINLOG_AI_INBOUND_NOT_RUN: default: return "NOT_RUN";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogAiInboundStatus sync_parse_ai_inbound_status(const char *text)
|
||||||
|
{
|
||||||
|
static const struct {
|
||||||
|
const char *marker;
|
||||||
|
TrainlogAiInboundStatus status;
|
||||||
|
} values[] = {
|
||||||
|
{"AI_SESSION_DRAFT_INBOUND=ALREADY_IMPORTED", TRAINLOG_AI_INBOUND_ALREADY_IMPORTED},
|
||||||
|
{"AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL", TRAINLOG_AI_INBOUND_ARCHIVE_FAIL},
|
||||||
|
{"AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", TRAINLOG_AI_INBOUND_DRIVE_FAIL},
|
||||||
|
{"AI_SESSION_DRAFT_INBOUND=IMPORTED", TRAINLOG_AI_INBOUND_IMPORTED},
|
||||||
|
{"AI_SESSION_DRAFT_INBOUND=REJECTED", TRAINLOG_AI_INBOUND_REJECTED},
|
||||||
|
{"AI_SESSION_DRAFT_INBOUND=NONE", TRAINLOG_AI_INBOUND_NONE},
|
||||||
|
};
|
||||||
|
TrainlogAiInboundStatus result = TRAINLOG_AI_INBOUND_NOT_RUN;
|
||||||
|
const char *line;
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
if (text == NULL) return TRAINLOG_AI_INBOUND_NOT_RUN;
|
||||||
|
|
||||||
|
/* WHY: helper diagnostics are untrusted text and may quote a status marker.
|
||||||
|
* CONTRACT: only a complete marker line is a helper result; CRLF is accepted.
|
||||||
|
* INVARIANT: scanning through all lines makes the last valid result authoritative. */
|
||||||
|
line = text;
|
||||||
|
while (*line != '\0') {
|
||||||
|
const char *newline = strchr(line, '\n');
|
||||||
|
size_t length = newline != NULL ? (size_t)(newline - line) : strlen(line);
|
||||||
|
|
||||||
|
if (length > 0U && line[length - 1U] == '\r') length -= 1U;
|
||||||
|
for (index = 0U; index < sizeof(values) / sizeof(values[0]); ++index) {
|
||||||
|
size_t marker_length = strlen(values[index].marker);
|
||||||
|
if (length == marker_length &&
|
||||||
|
memcmp(line, values[index].marker, marker_length) == 0) {
|
||||||
|
result = values[index].status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newline == NULL) break;
|
||||||
|
line = newline + 1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
TrainlogSyncDirectionPlan trainlog_sync_direction_plan(
|
TrainlogSyncDirectionPlan trainlog_sync_direction_plan(
|
||||||
TrainlogSyncDirection direction
|
TrainlogSyncDirection direction
|
||||||
)
|
)
|
||||||
|
|
@ -909,7 +972,7 @@ static TrainlogStatus sync_find_exchange_folder(
|
||||||
uint32_t *output_folder_id
|
uint32_t *output_folder_id
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
uint32_t download_id = 0U;
|
uint32_t documents_id = 0U;
|
||||||
uint64_t ignored_size = 0U;
|
uint64_t ignored_size = 0U;
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
|
|
||||||
|
|
@ -920,13 +983,16 @@ static TrainlogStatus sync_find_exchange_folder(
|
||||||
TRAINLOG_STATUS_INVALID_ARGUMENT;
|
TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CONTRACT: Documents/Trainlog is the only writable exchange endpoint.
|
||||||
|
* The historical Download/Trainlog tree may remain on the device, but no
|
||||||
|
* publication path may resolve or mutate it. */
|
||||||
status =
|
status =
|
||||||
sync_find_child(
|
sync_find_child(
|
||||||
device,
|
device,
|
||||||
UINT32_MAX,
|
UINT32_MAX,
|
||||||
"Download",
|
"Documents",
|
||||||
true,
|
true,
|
||||||
&download_id,
|
&documents_id,
|
||||||
&ignored_size
|
&ignored_size
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -940,7 +1006,7 @@ static TrainlogStatus sync_find_exchange_folder(
|
||||||
return
|
return
|
||||||
sync_find_child(
|
sync_find_child(
|
||||||
device,
|
device,
|
||||||
download_id,
|
documents_id,
|
||||||
"Trainlog",
|
"Trainlog",
|
||||||
true,
|
true,
|
||||||
output_folder_id,
|
output_folder_id,
|
||||||
|
|
@ -1117,6 +1183,7 @@ static bool sync_resolve_repo_tool(
|
||||||
size_t output_size
|
size_t output_size
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
const char *configured_directory;
|
||||||
char executable[
|
char executable[
|
||||||
PATH_MAX + 1U
|
PATH_MAX + 1U
|
||||||
];
|
];
|
||||||
|
|
@ -1134,6 +1201,29 @@ static bool sync_resolve_repo_tool(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* WHY: installed TUI binaries are not necessarily nested under the source
|
||||||
|
* tree, whereas the Python sync helpers remain repository-owned assets.
|
||||||
|
* CONTRACT: an explicit process-local override is accepted for packaged
|
||||||
|
* deployments and tests; otherwise Meson's absolute source tools path is
|
||||||
|
* authoritative. INVARIANT: resolution never invokes a shell and accepts
|
||||||
|
* only the caller-supplied fixed helper filename. */
|
||||||
|
configured_directory = getenv("TRAINLOG_TOOLS_DIR");
|
||||||
|
if (configured_directory != NULL && configured_directory[0] != '\0') {
|
||||||
|
written = snprintf(output, output_size, "%s/%s",
|
||||||
|
configured_directory, tool_name);
|
||||||
|
return written >= 0 && (size_t)written < output_size &&
|
||||||
|
access(output, R_OK) == 0;
|
||||||
|
}
|
||||||
|
configured_directory = TRAINLOG_TOOLS_DIR;
|
||||||
|
written = snprintf(output, output_size, "%s/%s",
|
||||||
|
configured_directory, tool_name);
|
||||||
|
if (written >= 0 && (size_t)written < output_size &&
|
||||||
|
access(output, R_OK) == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compatibility for binaries from older or relocated build trees. */
|
||||||
|
|
||||||
length =
|
length =
|
||||||
readlink(
|
readlink(
|
||||||
"/proc/self/exe",
|
"/proc/self/exe",
|
||||||
|
|
@ -1273,17 +1363,6 @@ static TrainlogStatus sync_run_python_tool(
|
||||||
|
|
||||||
output[0] = '\0';
|
output[0] = '\0';
|
||||||
|
|
||||||
if (
|
|
||||||
!sync_resolve_repo_tool(
|
|
||||||
tool_name,
|
|
||||||
tool,
|
|
||||||
sizeof(tool)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
TRAINLOG_STATUS_NOT_FOUND;
|
|
||||||
}
|
|
||||||
|
|
||||||
result_fd =
|
result_fd =
|
||||||
open(
|
open(
|
||||||
result_path,
|
result_path,
|
||||||
|
|
@ -1295,10 +1374,21 @@ static TrainlogStatus sync_run_python_tool(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result_fd < 0) {
|
if (result_fd < 0) {
|
||||||
|
(void)snprintf(output, output_size,
|
||||||
|
"résultat outil Python inaccessible (errno=%d)", errno);
|
||||||
return
|
return
|
||||||
TRAINLOG_STATUS_SYSTEM_ERROR;
|
TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* INVARIANT: a prior run can never satisfy the current run's PASS check,
|
||||||
|
* including when helper resolution fails before a child is started. */
|
||||||
|
if (!sync_resolve_repo_tool(tool_name, tool, sizeof(tool))) {
|
||||||
|
(void)close(result_fd);
|
||||||
|
(void)snprintf(output, output_size,
|
||||||
|
"outil Python introuvable: %s", tool_name);
|
||||||
|
return TRAINLOG_STATUS_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
child = fork();
|
child = fork();
|
||||||
|
|
||||||
if (child < (pid_t)0) {
|
if (child < (pid_t)0) {
|
||||||
|
|
@ -1306,8 +1396,9 @@ static TrainlogStatus sync_run_python_tool(
|
||||||
result_fd
|
result_fd
|
||||||
);
|
);
|
||||||
|
|
||||||
return
|
(void)snprintf(output, output_size,
|
||||||
TRAINLOG_STATUS_SYSTEM_ERROR;
|
"lancement outil Python échoué (errno=%d)", errno);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (child == (pid_t)0) {
|
if (child == (pid_t)0) {
|
||||||
|
|
@ -1366,6 +1457,8 @@ static TrainlogStatus sync_run_python_tool(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(void)dprintf(STDERR_FILENO,
|
||||||
|
"python3: exécution impossible (errno=%d)\n", errno);
|
||||||
_exit(127);
|
_exit(127);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1380,15 +1473,16 @@ static TrainlogStatus sync_run_python_tool(
|
||||||
0
|
0
|
||||||
) < (pid_t)0
|
) < (pid_t)0
|
||||||
) {
|
) {
|
||||||
return
|
(void)snprintf(output, output_size,
|
||||||
TRAINLOG_STATUS_SYSTEM_ERROR;
|
"attente outil Python échouée (errno=%d)", errno);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
(void)sync_read_text(
|
if (!sync_read_text(result_path, output, output_size)) {
|
||||||
result_path,
|
(void)snprintf(output, output_size,
|
||||||
output,
|
"lecture diagnostic outil Python échouée");
|
||||||
output_size
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
);
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!WIFEXITED(
|
!WIFEXITED(
|
||||||
|
|
@ -1398,6 +1492,18 @@ static TrainlogStatus sync_run_python_tool(
|
||||||
child_status
|
child_status
|
||||||
) != 0
|
) != 0
|
||||||
) {
|
) {
|
||||||
|
if (output[0] == '\0') {
|
||||||
|
if (WIFSIGNALED(child_status)) {
|
||||||
|
(void)snprintf(output, output_size,
|
||||||
|
"outil Python interrompu par signal %d",
|
||||||
|
WTERMSIG(child_status));
|
||||||
|
} else {
|
||||||
|
(void)snprintf(output, output_size,
|
||||||
|
"outil Python terminé avec code %d",
|
||||||
|
WIFEXITED(child_status)
|
||||||
|
? WEXITSTATUS(child_status) : -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
TRAINLOG_STATUS_DATABASE_ERROR;
|
TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
@ -2354,6 +2460,13 @@ bool trainlog_sync_record_local_run(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(void)fputs(",\n \"ai_inbound_status\":", json_file);
|
||||||
|
if (!sync_json_write_escaped(json_file,
|
||||||
|
sync_ai_inbound_status_text(report->ai_inbound_status))) {
|
||||||
|
(void)fclose(json_file);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
(void)fprintf(
|
(void)fprintf(
|
||||||
json_file,
|
json_file,
|
||||||
",\n \"android_to_pc\":{"
|
",\n \"android_to_pc\":{"
|
||||||
|
|
@ -2424,6 +2537,11 @@ bool trainlog_sync_record_local_run(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (report->ai_inbound_status != TRAINLOG_AI_INBOUND_NOT_RUN) {
|
||||||
|
(void)fprintf(detail_file, "AI entrant : %s\n",
|
||||||
|
sync_ai_inbound_status_text(report->ai_inbound_status));
|
||||||
|
}
|
||||||
|
|
||||||
(void)fprintf(
|
(void)fprintf(
|
||||||
detail_file,
|
detail_file,
|
||||||
"\nANDROID → PC\n"
|
"\nANDROID → PC\n"
|
||||||
|
|
@ -2545,6 +2663,58 @@ void trainlog_sync_build_summary(
|
||||||
: "Synchronisation échouée."
|
: "Synchronisation échouée."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (report->ai_inbound_status != TRAINLOG_AI_INBOUND_NOT_RUN) {
|
||||||
|
size_t used = strlen(report->summary);
|
||||||
|
if (used < sizeof(report->summary)) {
|
||||||
|
(void)snprintf(report->summary + used,
|
||||||
|
sizeof(report->summary) - used, " · AI_INBOUND=%s",
|
||||||
|
sync_ai_inbound_status_text(report->ai_inbound_status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sync_append_post_sync_summary(
|
||||||
|
TrainlogSyncReport *report,
|
||||||
|
const char *tool_output
|
||||||
|
)
|
||||||
|
{
|
||||||
|
char export_status[96] = "AI_EXPORT=FAIL";
|
||||||
|
char upload_status[96] = "GDRIVE_UPLOAD=SKIPPED";
|
||||||
|
const char *line;
|
||||||
|
size_t used;
|
||||||
|
|
||||||
|
if (report == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
line = tool_output != NULL ? strstr(tool_output, "AI_EXPORT=") : NULL;
|
||||||
|
if (line != NULL) {
|
||||||
|
size_t length = strcspn(line, "\r\n");
|
||||||
|
(void)snprintf(export_status, sizeof(export_status), "%.*s",
|
||||||
|
(int)(length < sizeof(export_status) - 1U
|
||||||
|
? length : sizeof(export_status) - 1U), line);
|
||||||
|
}
|
||||||
|
line = tool_output != NULL ? strstr(tool_output, "GDRIVE_UPLOAD=") : NULL;
|
||||||
|
if (line != NULL) {
|
||||||
|
size_t length = strcspn(line, "\r\n");
|
||||||
|
(void)snprintf(upload_status, sizeof(upload_status), "%.*s",
|
||||||
|
(int)(length < sizeof(upload_status) - 1U
|
||||||
|
? length : sizeof(upload_status) - 1U), line);
|
||||||
|
}
|
||||||
|
|
||||||
|
used = strlen(report->summary);
|
||||||
|
if (used < sizeof(report->summary)) {
|
||||||
|
/* WHY: post-sync publication is operational follow-up, not part of
|
||||||
|
* the already committed Trainlog synchronization. CONTRACT: expose
|
||||||
|
* each outcome while preserving report.success and the sync return
|
||||||
|
* status. INVARIANT: the fixed report buffer remains terminated. */
|
||||||
|
(void)snprintf(
|
||||||
|
report->summary + used,
|
||||||
|
sizeof(report->summary) - used,
|
||||||
|
" · SYNC=PASS %s %s",
|
||||||
|
export_status,
|
||||||
|
upload_status
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static TrainlogStatus sync_prepare_run_identity(
|
static TrainlogStatus sync_prepare_run_identity(
|
||||||
|
|
@ -2750,7 +2920,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
(void)snprintf(
|
(void)snprintf(
|
||||||
output->error,
|
output->error,
|
||||||
sizeof(output->error),
|
sizeof(output->error),
|
||||||
"Résolution de Download/Trainlog échouée (status=%d).",
|
"Résolution de Documents/Trainlog échouée (status=%d).",
|
||||||
(int)status
|
(int)status
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -2882,7 +3052,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
|
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
|
||||||
goto outbound;
|
goto ai_draft_midpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* WHY: aliases must exist before the frozen session/catalog snapshot is
|
/* WHY: aliases must exist before the frozen session/catalog snapshot is
|
||||||
|
|
@ -3196,13 +3366,67 @@ TrainlogStatus trainlog_sync_run(
|
||||||
goto finalize;
|
goto finalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ai_draft_midpoint:
|
||||||
|
/* WHY: an Android-triggered bidirectional run must publish a newly fetched
|
||||||
|
* AI draft immediately, not one synchronization later. CONTRACT: the
|
||||||
|
* shell-free helper owns Drive fetch, strict transactional import, and
|
||||||
|
* post-commit archive; transport/archive failures are explicitly nonfatal.
|
||||||
|
* INVARIANT: this midpoint precedes every PC→Android artifact and receipt. */
|
||||||
|
status = sync_run_python_tool("sync_ai_session_draft_drive.py",
|
||||||
|
"trainlog_ai_session_draft_v1.json", database_path, NULL,
|
||||||
|
AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output));
|
||||||
|
output->ai_inbound_status = sync_parse_ai_inbound_status(tool_output);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
|
||||||
|
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
|
||||||
|
sync_compose_diagnostic(output->error, sizeof(output->error),
|
||||||
|
"AI→PC : brouillon séance : ",
|
||||||
|
useful[0] != '\0' ? useful : "import échoué");
|
||||||
|
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
goto finalize;
|
||||||
|
}
|
||||||
|
if (output->ai_inbound_status == TRAINLOG_AI_INBOUND_NOT_RUN) {
|
||||||
|
(void)snprintf(output->error, sizeof(output->error),
|
||||||
|
"AI→PC : statut du brouillon séance absent.");
|
||||||
|
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
goto finalize;
|
||||||
|
}
|
||||||
|
|
||||||
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
|
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
|
||||||
output->success = true;
|
output->success = true;
|
||||||
final_status = TRAINLOG_STATUS_OK;
|
final_status = TRAINLOG_STATUS_OK;
|
||||||
goto finalize;
|
goto finalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
outbound:
|
status = sync_run_python_tool("export_ai_session_drafts.py",
|
||||||
|
AI_SESSION_DRAFTS_LOCAL, database_path, NULL,
|
||||||
|
AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output));
|
||||||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
strstr(tool_output, "AI_SESSION_DRAFT_EXPORT=PASS") == NULL) {
|
||||||
|
(void)snprintf(output->error, sizeof(output->error),
|
||||||
|
"PC→Android : export brouillons IA échoué.");
|
||||||
|
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
goto finalize;
|
||||||
|
}
|
||||||
|
status = sync_publish_named(&device, folder_id,
|
||||||
|
AI_SESSION_DRAFTS_LOCAL, AI_SESSION_DRAFTS_NAME);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
(void)snprintf(output->error, sizeof(output->error),
|
||||||
|
"PC→Android : publication brouillons IA échouée.");
|
||||||
|
final_status = status;
|
||||||
|
goto finalize;
|
||||||
|
}
|
||||||
|
status = sync_run_python_tool("mark_ai_session_drafts_published.py",
|
||||||
|
AI_SESSION_DRAFTS_LOCAL, database_path, NULL,
|
||||||
|
AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output));
|
||||||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
strstr(tool_output, "AI_SESSION_DRAFT_PUBLISH_MARK=PASS") == NULL) {
|
||||||
|
(void)snprintf(output->error, sizeof(output->error),
|
||||||
|
"PC→Android : enregistrement publication brouillons IA échoué.");
|
||||||
|
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
goto finalize;
|
||||||
|
}
|
||||||
|
|
||||||
status = sync_run_python_tool("export_exercise_profile_state.py",
|
status = sync_run_python_tool("export_exercise_profile_state.py",
|
||||||
EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL,
|
EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL,
|
||||||
EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output));
|
EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output));
|
||||||
|
|
@ -3468,11 +3692,6 @@ finalize:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(void)trainlog_sync_record_local_run(
|
|
||||||
trigger,
|
|
||||||
output
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
output->request_present &&
|
output->request_present &&
|
||||||
receipt_published
|
receipt_published
|
||||||
|
|
@ -3482,6 +3701,32 @@ finalize:
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (final_status == TRAINLOG_STATUS_OK && output->success) {
|
||||||
|
TrainlogStatus post_status = sync_run_python_tool(
|
||||||
|
"post_sync_ai_drive.py",
|
||||||
|
"trainlog_ai_export_v1.json",
|
||||||
|
database_path,
|
||||||
|
NULL,
|
||||||
|
AI_POST_SYNC_RESULT,
|
||||||
|
tool_output,
|
||||||
|
sizeof(tool_output)
|
||||||
|
);
|
||||||
|
|
||||||
|
/* A launcher failure has no script output, but it must still remain a
|
||||||
|
* visible post-sync failure rather than changing Trainlog sync state. */
|
||||||
|
if (post_status != TRAINLOG_STATUS_OK && tool_output[0] == '\0') {
|
||||||
|
(void)snprintf(tool_output, sizeof(tool_output),
|
||||||
|
"AI_EXPORT=FAIL launcher status=%d\n"
|
||||||
|
"GDRIVE_UPLOAD=SKIPPED\n", (int)post_status);
|
||||||
|
}
|
||||||
|
sync_append_post_sync_summary(output, tool_output);
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)trainlog_sync_record_local_run(
|
||||||
|
trigger,
|
||||||
|
output
|
||||||
|
);
|
||||||
|
|
||||||
done:
|
done:
|
||||||
if (silence_active) {
|
if (silence_active) {
|
||||||
sync_silence_end(
|
sync_silence_end(
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ static int migration_preserves_identity_and_history(void)
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
raw = NULL;
|
raw = NULL;
|
||||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 17);
|
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 18);
|
||||||
CHECK(trainlog_database_list_exercise_body_zones(database,
|
CHECK(trainlog_database_list_exercise_body_zones(database,
|
||||||
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(count == 2U);
|
CHECK(count == 2U);
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ static bool run_test(void)
|
||||||
(void)fprintf(stderr, "open diagnostic: %s\n", diagnostic);
|
(void)fprintf(stderr, "open diagnostic: %s\n", diagnostic);
|
||||||
CHECK(false);
|
CHECK(false);
|
||||||
}
|
}
|
||||||
CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && version == 17);
|
CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && version == 18);
|
||||||
trainlog_database_close(db); db = NULL;
|
trainlog_database_close(db); db = NULL;
|
||||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK);
|
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK);
|
||||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE s.revision_id IS NULL OR s.revision_id<>'pr_legacy_v1' OR s.legacy_seed<>1", 0));
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE s.revision_id IS NULL OR s.revision_id<>'pr_legacy_v1' OR s.legacy_seed<>1", 0));
|
||||||
|
|
@ -97,6 +97,10 @@ static bool run_test(void)
|
||||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_feedback_revisions WHERE revision_id='fr_old'", 1));
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_feedback_revisions WHERE revision_id='fr_old'", 1));
|
||||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followups WHERE id=23", 1));
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followups WHERE id=23", 1));
|
||||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followup_revisions WHERE revision_id='ur_old'", 1));
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followup_revisions WHERE revision_id='ur_old'", 1));
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('ai_session_drafts') WHERE name='archive_status'", 1));
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('ai_session_draft_entries') WHERE name='target_sets'", 1));
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('ai_session_draft_imports') WHERE name='payload_sha256'", 1));
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM ai_session_drafts", 0));
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||||
CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_update_exercise_profiled(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", "Planche face sol",
|
CHECK(trainlog_database_update_exercise_profiled(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", "Planche face sol",
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic, "migrate database to schema v17: SQLite rc=",
|
CHECK(strncmp(diagnostic, "migrate database to schema v18: SQLite rc=",
|
||||||
strlen("migrate database to schema v17: SQLite rc=")) == 0);
|
strlen("migrate database to schema v18: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=18;", NULL, NULL, NULL) == SQLITE_OK);
|
CHECK(sqlite3_exec(raw, "PRAGMA user_version=19;", NULL, NULL, NULL) == SQLITE_OK);
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "schema version 18 is newer") != NULL);
|
CHECK(strstr(diagnostic, "schema version 19 is newer") != NULL);
|
||||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "migrate database to schema v17") != NULL);
|
CHECK(strstr(diagnostic, "migrate database to schema v18") != NULL);
|
||||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,75 @@ static const char *remote_mobile_name = "trainlog-mobile-export-v2.json";
|
||||||
static unsigned int remote_mobile_version = 2U;
|
static unsigned int remote_mobile_version = 2U;
|
||||||
static const char *remote_tracking_mode = "reps";
|
static const char *remote_tracking_mode = "reps";
|
||||||
static bool remote_profile_available = true;
|
static bool remote_profile_available = true;
|
||||||
|
static bool remote_alias_available = false;
|
||||||
|
static char rclone_log[4096];
|
||||||
|
|
||||||
|
static bool install_fake_rclone(const char *root)
|
||||||
|
{
|
||||||
|
char bin[4096];
|
||||||
|
char executable[4096];
|
||||||
|
char path[8192];
|
||||||
|
const char *old_path = getenv("PATH");
|
||||||
|
FILE *file;
|
||||||
|
|
||||||
|
CHECK(snprintf(bin, sizeof(bin), "%s/bin", root) > 0);
|
||||||
|
CHECK(mkdir(bin, 0700) == 0);
|
||||||
|
CHECK(snprintf(executable, sizeof(executable), "%s/rclone", bin) > 0);
|
||||||
|
file = fopen(executable, "wb");
|
||||||
|
CHECK(file != NULL);
|
||||||
|
CHECK(fputs("#!/bin/sh\n"
|
||||||
|
"if test \"$1\" = lsf; then\n"
|
||||||
|
" test \"$2\" = 'TrainLog Gdrive:Trainlog/AI/inbox' || exit 65\n"
|
||||||
|
" case \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" in\n"
|
||||||
|
" invalid|archive_fail|imported|already) printf 'trainlog_ai_session_draft_v1.json\\n'; exit 0 ;;\n"
|
||||||
|
" marker_prefix) printf 'diagnostic AI_SESSION_DRAFT_INBOUND=IMPORTED\\n' >&2; exit 65 ;;\n"
|
||||||
|
" marker_suffix) printf 'AI_SESSION_DRAFT_INBOUND=IMPORTED diagnostic\\n' >&2; exit 65 ;;\n"
|
||||||
|
" none) exit 0 ;;\n"
|
||||||
|
" *) exit 65 ;;\n"
|
||||||
|
" esac\n"
|
||||||
|
"fi\n"
|
||||||
|
"test \"$1\" = copyto || exit 64\n"
|
||||||
|
"case \"$2\" in\n"
|
||||||
|
" 'TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json')\n"
|
||||||
|
" case \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" in\n"
|
||||||
|
" invalid) printf '{}\\n' > \"$3\"; exit 0 ;;\n"
|
||||||
|
" archive_fail|imported|already) cp \"$TRAINLOG_TEST_AI_SOURCE\" \"$3\"; exit 0 ;;\n"
|
||||||
|
" *) exit 65 ;;\n"
|
||||||
|
" esac ;;\n"
|
||||||
|
"esac\n"
|
||||||
|
"test -f \"$2\" || exit 65\n"
|
||||||
|
"case \"$3\" in\n"
|
||||||
|
" 'TrainLog Gdrive:Trainlog/AI/archive/'*)\n"
|
||||||
|
" test \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" != archive_fail || exit 9 ;;\n"
|
||||||
|
"esac\n"
|
||||||
|
"printf '%s\\n' \"$3\" >> \"$TRAINLOG_TEST_RCLONE_LOG\"\n",
|
||||||
|
file) >= 0);
|
||||||
|
CHECK(fclose(file) == 0);
|
||||||
|
CHECK(chmod(executable, 0700) == 0);
|
||||||
|
CHECK(snprintf(rclone_log, sizeof(rclone_log), "%s/rclone.log", root) > 0);
|
||||||
|
CHECK(setenv("TRAINLOG_TEST_RCLONE_LOG", rclone_log, 1) == 0);
|
||||||
|
CHECK(snprintf(path, sizeof(path), "%s:%s", bin,
|
||||||
|
old_path != NULL ? old_path : "") > 0);
|
||||||
|
CHECK(setenv("PATH", path, 1) == 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t rclone_upload_count(void)
|
||||||
|
{
|
||||||
|
FILE *file = fopen(rclone_log, "rb");
|
||||||
|
size_t count = 0U;
|
||||||
|
int byte;
|
||||||
|
if (file == NULL) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
while ((byte = fgetc(file)) != EOF) {
|
||||||
|
if (byte == '\n') {
|
||||||
|
count += 1U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CHECK(fclose(file) == 0);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
static bool copy_file(const char *source, const char *target)
|
static bool copy_file(const char *source, const char *target)
|
||||||
{
|
{
|
||||||
|
|
@ -134,13 +203,20 @@ TrainlogStatus trainlog_mtp_list_folder(
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
if (parent_folder_id == UINT32_MAX) {
|
if (parent_folder_id == UINT32_MAX) {
|
||||||
if (capacity < 1U) {
|
if (capacity < 2U) {
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
set_entry(&output[0], 1U, UINT32_MAX, "Download", true);
|
set_entry(&output[0], 1U, UINT32_MAX, "Documents", true);
|
||||||
*output_count = 1U;
|
/* CONTRACT fixture: the historical exchange tree remains present but
|
||||||
|
* must never be selected for reads or publications after migration. */
|
||||||
|
set_entry(&output[1], 90U, UINT32_MAX, "Download", true);
|
||||||
|
*output_count = 2U;
|
||||||
return TRAINLOG_STATUS_OK;
|
return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
|
if (parent_folder_id == 90U) {
|
||||||
|
fprintf(stderr, "legacy Download tree was accessed\n");
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
if (parent_folder_id == 1U) {
|
if (parent_folder_id == 1U) {
|
||||||
if (capacity < 1U) {
|
if (capacity < 1U) {
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
|
@ -150,7 +226,9 @@ TrainlogStatus trainlog_mtp_list_folder(
|
||||||
return TRAINLOG_STATUS_OK;
|
return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
if (parent_folder_id == 2U) {
|
if (parent_folder_id == 2U) {
|
||||||
size_t count = remote_profile_available ? 4U : 3U;
|
size_t count = 3U + (remote_profile_available ? 1U : 0U) +
|
||||||
|
(remote_alias_available ? 1U : 0U);
|
||||||
|
size_t next = 3U;
|
||||||
if (capacity < count) {
|
if (capacity < count) {
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
@ -160,8 +238,13 @@ TrainlogStatus trainlog_mtp_list_folder(
|
||||||
set_entry(&output[2], 12U, 2U,
|
set_entry(&output[2], 12U, 2U,
|
||||||
"trainlog-equipment-associations-v2.json", false);
|
"trainlog-equipment-associations-v2.json", false);
|
||||||
if (remote_profile_available) {
|
if (remote_profile_available) {
|
||||||
set_entry(&output[3], 13U, 2U,
|
set_entry(&output[next], 13U, 2U,
|
||||||
"trainlog-exercise-profile-state-v1.json", false);
|
"trainlog-exercise-profile-state-v1.json", false);
|
||||||
|
next += 1U;
|
||||||
|
}
|
||||||
|
if (remote_alias_available) {
|
||||||
|
set_entry(&output[next], 14U, 2U,
|
||||||
|
"trainlog-exercise-aliases-v1.json", false);
|
||||||
}
|
}
|
||||||
*output_count = count;
|
*output_count = count;
|
||||||
return TRAINLOG_STATUS_OK;
|
return TRAINLOG_STATUS_OK;
|
||||||
|
|
@ -186,7 +269,8 @@ TrainlogStatus trainlog_mtp_receive_file(
|
||||||
name = item_id == 10U ? remote_mobile_name :
|
name = item_id == 10U ? remote_mobile_name :
|
||||||
item_id == 11U ? "trainlog-exercise-body-zones-v1.json" :
|
item_id == 11U ? "trainlog-exercise-body-zones-v1.json" :
|
||||||
item_id == 12U ? "trainlog-equipment-associations-v2.json" :
|
item_id == 12U ? "trainlog-equipment-associations-v2.json" :
|
||||||
item_id == 13U ? "trainlog-exercise-profile-state-v1.json" : NULL;
|
item_id == 13U ? "trainlog-exercise-profile-state-v1.json" :
|
||||||
|
item_id == 14U ? "trainlog-exercise-aliases-v1.json" : NULL;
|
||||||
if (name == NULL || local_path == NULL) {
|
if (name == NULL || local_path == NULL) {
|
||||||
return TRAINLOG_STATUS_NOT_FOUND;
|
return TRAINLOG_STATUS_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
@ -289,6 +373,18 @@ static bool write_artifacts(
|
||||||
}
|
}
|
||||||
CHECK(fclose(file) == 0);
|
CHECK(fclose(file) == 0);
|
||||||
|
|
||||||
|
written = snprintf(path, sizeof(path),
|
||||||
|
"%s/trainlog-exercise-aliases-v1.json", remote_root);
|
||||||
|
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
||||||
|
file = fopen(path, "wb");
|
||||||
|
CHECK(file != NULL);
|
||||||
|
CHECK(fprintf(file,
|
||||||
|
"{\"format\":\"trainlog-exercise-aliases\",\"version\":1,"
|
||||||
|
"\"aliases\":[{\"source_exercise_id\":\"%s\","
|
||||||
|
"\"canonical_exercise_id\":\"%s\"}]}",
|
||||||
|
SOURCE_ID, CANONICAL_ID) > 0);
|
||||||
|
CHECK(fclose(file) == 0);
|
||||||
|
|
||||||
written = snprintf(path, sizeof(path),
|
written = snprintf(path, sizeof(path),
|
||||||
"%s/trainlog-exercise-profile-state-v1.json", remote_root);
|
"%s/trainlog-exercise-profile-state-v1.json", remote_root);
|
||||||
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
||||||
|
|
@ -382,6 +478,18 @@ static bool prepare_database(
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool add_live_source_exercise(const char *database_path)
|
||||||
|
{
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(trainlog_database_insert_exercise_profiled(
|
||||||
|
database, SOURCE_ID, "Retired sync wiring exercise",
|
||||||
|
"retired sync wiring exercise", TRAINLOG_TRACKING_REPS,
|
||||||
|
TRAINLOG_RECORDING_SETS, UINT32_C(0)) == TRAINLOG_STATUS_OK);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool database_state(
|
static bool database_state(
|
||||||
const char *database_path,
|
const char *database_path,
|
||||||
bool source_should_resolve,
|
bool source_should_resolve,
|
||||||
|
|
@ -466,6 +574,24 @@ static bool successful_journal(const char *case_root, const char *sync_id)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool history_contains(const char *case_root, const char *needle)
|
||||||
|
{
|
||||||
|
char path[4096];
|
||||||
|
char content[65536];
|
||||||
|
FILE *file;
|
||||||
|
size_t count;
|
||||||
|
CHECK(snprintf(path, sizeof(path), "%s/trainlog/sync_history.log",
|
||||||
|
case_root) > 0);
|
||||||
|
file = fopen(path, "rb");
|
||||||
|
CHECK(file != NULL);
|
||||||
|
count = fread(content, 1U, sizeof(content) - 1U, file);
|
||||||
|
CHECK(ferror(file) == 0);
|
||||||
|
CHECK(fclose(file) == 0);
|
||||||
|
content[count] = '\0';
|
||||||
|
CHECK(strstr(content, needle) != NULL);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool file_contains(const char *name, const char *needle, bool expected)
|
static bool file_contains(const char *name, const char *needle, bool expected)
|
||||||
{
|
{
|
||||||
char path[4096];
|
char path[4096];
|
||||||
|
|
@ -521,7 +647,9 @@ static bool run_success_case(
|
||||||
database_path, sizeof(database_path)));
|
database_path, sizeof(database_path)));
|
||||||
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, persistent_alias));
|
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, persistent_alias));
|
||||||
{
|
{
|
||||||
TrainlogStatus status = trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
TrainlogSyncTrigger trigger = persistent_alias
|
||||||
|
? TRAINLOG_SYNC_TRIGGER_ANDROID : TRAINLOG_SYNC_TRIGGER_TUI;
|
||||||
|
TrainlogStatus status = trainlog_sync_run(trigger, false,
|
||||||
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
||||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report);
|
TRAINLOG_SYNC_ANDROID_TO_PC, &report);
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
|
@ -530,6 +658,9 @@ static bool run_success_case(
|
||||||
CHECK(status == TRAINLOG_STATUS_OK);
|
CHECK(status == TRAINLOG_STATUS_OK);
|
||||||
}
|
}
|
||||||
CHECK(report.success);
|
CHECK(report.success);
|
||||||
|
CHECK(strstr(report.summary, "AI_INBOUND=DRIVE_FAIL") != NULL);
|
||||||
|
CHECK(strstr(report.summary,
|
||||||
|
"SYNC=PASS AI_EXPORT=PASS GDRIVE_UPLOAD=PASS") != NULL);
|
||||||
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
|
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
|
||||||
CHECK(successful_journal(case_root, report.sync_id));
|
CHECK(successful_journal(case_root, report.sync_id));
|
||||||
if (persistent_alias) {
|
if (persistent_alias) {
|
||||||
|
|
@ -551,10 +682,13 @@ static bool run_success_case(
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Replay exercises the same production argv path and sync baseline. */
|
/* Replay exercises the same production argv path and sync baseline. */
|
||||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
CHECK(trainlog_sync_run(persistent_alias ? TRAINLOG_SYNC_TRIGGER_ANDROID :
|
||||||
|
TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||||
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
||||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(report.success);
|
CHECK(report.success);
|
||||||
|
CHECK(strstr(report.summary,
|
||||||
|
"SYNC=PASS AI_EXPORT=PASS GDRIVE_UPLOAD=PASS") != NULL);
|
||||||
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
|
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
|
||||||
CHECK(successful_journal(case_root, report.sync_id));
|
CHECK(successful_journal(case_root, report.sync_id));
|
||||||
if (persistent_alias) {
|
if (persistent_alias) {
|
||||||
|
|
@ -628,7 +762,7 @@ static bool run_missing_profile_companion_case(const char *case_root)
|
||||||
remote_profile_available = false;
|
remote_profile_available = false;
|
||||||
CHECK(prepare_database(case_root, false, false,
|
CHECK(prepare_database(case_root, false, false,
|
||||||
database_path, sizeof(database_path)));
|
database_path, sizeof(database_path)));
|
||||||
CHECK(write_artifacts(CANONICAL_ID, CANONICAL_ID, false));
|
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, false));
|
||||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
|
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
|
||||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
|
|
@ -642,6 +776,139 @@ static bool run_missing_profile_companion_case(const char *case_root)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool run_incoming_alias_case(const char *case_root)
|
||||||
|
{
|
||||||
|
TrainlogSyncReport report;
|
||||||
|
char database_path[4096];
|
||||||
|
remote_alias_available = true;
|
||||||
|
remote_mobile_name = "trainlog-mobile-export-v3.json";
|
||||||
|
remote_mobile_version = 3U;
|
||||||
|
CHECK(prepare_database(case_root, false, false,
|
||||||
|
database_path, sizeof(database_path)));
|
||||||
|
CHECK(add_live_source_exercise(database_path));
|
||||||
|
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, true));
|
||||||
|
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, false,
|
||||||
|
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(report.success);
|
||||||
|
CHECK(database_state(database_path, true, "back", "arms", 2U));
|
||||||
|
CHECK(successful_journal(case_root, report.sync_id));
|
||||||
|
remote_alias_available = false;
|
||||||
|
remote_mobile_name = "trainlog-mobile-export-v2.json";
|
||||||
|
remote_mobile_version = 2U;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool write_fake_alias_tool(const char *directory, const char *source)
|
||||||
|
{
|
||||||
|
char path[4096];
|
||||||
|
FILE *file;
|
||||||
|
CHECK(snprintf(path, sizeof(path), "%s/import_exercise_aliases.py",
|
||||||
|
directory) > 0);
|
||||||
|
file = fopen(path, "wb");
|
||||||
|
CHECK(file != NULL);
|
||||||
|
CHECK(fputs(source, file) >= 0);
|
||||||
|
CHECK(fclose(file) == 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool run_alias_diagnostic_case(
|
||||||
|
const char *case_root,
|
||||||
|
const char *tools_directory,
|
||||||
|
const char *script,
|
||||||
|
const char *expected
|
||||||
|
)
|
||||||
|
{
|
||||||
|
static const char *const result_path =
|
||||||
|
"/tmp/trainlog-exercise-aliases-result.txt";
|
||||||
|
TrainlogSyncReport report;
|
||||||
|
char database_path[4096];
|
||||||
|
char result[256];
|
||||||
|
FILE *file;
|
||||||
|
size_t count;
|
||||||
|
|
||||||
|
CHECK(prepare_database(case_root, false, false,
|
||||||
|
database_path, sizeof(database_path)));
|
||||||
|
CHECK(write_artifacts(CANONICAL_ID, CANONICAL_ID, false));
|
||||||
|
if (script != NULL) {
|
||||||
|
CHECK(write_fake_alias_tool(tools_directory, script));
|
||||||
|
}
|
||||||
|
file = fopen(result_path, "wb");
|
||||||
|
CHECK(file != NULL);
|
||||||
|
CHECK(fputs("EXERCISE_ALIAS_EXPORT=PASS aliases=99\n", file) >= 0);
|
||||||
|
CHECK(fclose(file) == 0);
|
||||||
|
CHECK(setenv("TRAINLOG_TOOLS_DIR", tools_directory, 1) == 0);
|
||||||
|
remote_alias_available = true;
|
||||||
|
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||||
|
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
|
||||||
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
|
CHECK(!report.success);
|
||||||
|
CHECK(strstr(report.error, expected) != NULL);
|
||||||
|
CHECK(history_contains(case_root, expected));
|
||||||
|
file = fopen(result_path, "rb");
|
||||||
|
CHECK(file != NULL);
|
||||||
|
count = fread(result, 1U, sizeof(result) - 1U, file);
|
||||||
|
CHECK(ferror(file) == 0);
|
||||||
|
CHECK(fclose(file) == 0);
|
||||||
|
result[count] = '\0';
|
||||||
|
CHECK(strstr(result, "EXERCISE_ALIAS_EXPORT=PASS") == NULL);
|
||||||
|
CHECK(unsetenv("TRAINLOG_TOOLS_DIR") == 0);
|
||||||
|
remote_alias_available = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool run_ai_inbound_diagnostic_case(
|
||||||
|
const char *case_root,
|
||||||
|
const char *mode,
|
||||||
|
const char *expected,
|
||||||
|
bool repeat
|
||||||
|
)
|
||||||
|
{
|
||||||
|
TrainlogSyncReport report;
|
||||||
|
char database_path[4096];
|
||||||
|
char source_path[4096];
|
||||||
|
FILE *file;
|
||||||
|
|
||||||
|
CHECK(prepare_database(case_root, false, false,
|
||||||
|
database_path, sizeof(database_path)));
|
||||||
|
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, false));
|
||||||
|
CHECK(snprintf(source_path, sizeof(source_path), "%s/ai-source.json",
|
||||||
|
case_root) > 0);
|
||||||
|
file = fopen(source_path, "wb");
|
||||||
|
CHECK(file != NULL);
|
||||||
|
CHECK(fprintf(file,
|
||||||
|
"{\"format\":\"TRAINLOG_AI_SESSION_DRAFT\",\"version\":1,\"draft\":{"
|
||||||
|
"\"draft_id\":\"aid_12345678-1234-4abc-8abc-123456789abc\","
|
||||||
|
"\"created_at\":\"2032-01-01T02:00:00Z\",\"planned_for\":null,"
|
||||||
|
"\"session_type\":\"training\",\"title\":null,\"notes\":null,"
|
||||||
|
"\"entries\":[{\"position\":0,\"exercise_id\":\"%s\","
|
||||||
|
"\"target_sets\":3,\"target_reps\":8,"
|
||||||
|
"\"target_duration_seconds\":null,\"target_weight_kg\":null,"
|
||||||
|
"\"rest_seconds\":60}]}}", CANONICAL_ID) > 0);
|
||||||
|
CHECK(fclose(file) == 0);
|
||||||
|
CHECK(setenv("TRAINLOG_TEST_AI_SOURCE", source_path, 1) == 0);
|
||||||
|
CHECK(setenv("TRAINLOG_TEST_AI_INBOUND_MODE", mode, 1) == 0);
|
||||||
|
{
|
||||||
|
TrainlogStatus status = trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI,
|
||||||
|
false, TRAINLOG_SYNC_ANDROID_TO_PC, &report);
|
||||||
|
if (status != TRAINLOG_STATUS_OK)
|
||||||
|
(void)fprintf(stderr, "AI diagnostic sync failure: %s\n", report.error);
|
||||||
|
CHECK(status == TRAINLOG_STATUS_OK);
|
||||||
|
}
|
||||||
|
CHECK(report.success);
|
||||||
|
CHECK(strstr(report.summary, expected) != NULL);
|
||||||
|
CHECK(history_contains(case_root, expected));
|
||||||
|
if (repeat) {
|
||||||
|
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||||
|
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(report.success);
|
||||||
|
CHECK(strstr(report.summary, "AI_INBOUND=ALREADY_IMPORTED") != NULL);
|
||||||
|
CHECK(history_contains(case_root, "AI_INBOUND=ALREADY_IMPORTED"));
|
||||||
|
}
|
||||||
|
CHECK(unsetenv("TRAINLOG_TEST_AI_INBOUND_MODE") == 0);
|
||||||
|
CHECK(unsetenv("TRAINLOG_TEST_AI_SOURCE") == 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool run_all(void)
|
static bool run_all(void)
|
||||||
{
|
{
|
||||||
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
|
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
|
||||||
|
|
@ -650,9 +917,21 @@ static bool run_all(void)
|
||||||
char case_c[4096];
|
char case_c[4096];
|
||||||
char case_d[4096];
|
char case_d[4096];
|
||||||
char case_e[4096];
|
char case_e[4096];
|
||||||
|
char case_f[4096];
|
||||||
|
char case_g[4096];
|
||||||
|
char case_h[4096];
|
||||||
|
char case_i[4096];
|
||||||
|
char case_j[4096];
|
||||||
|
char case_k[4096];
|
||||||
|
char case_l[4096];
|
||||||
|
char case_m[4096];
|
||||||
|
char case_n[4096];
|
||||||
|
char case_o[4096];
|
||||||
|
char fake_tools[4096];
|
||||||
char *root = mkdtemp(temporary);
|
char *root = mkdtemp(temporary);
|
||||||
|
|
||||||
CHECK(root != NULL);
|
CHECK(root != NULL);
|
||||||
|
CHECK(install_fake_rclone(root));
|
||||||
CHECK(snprintf(remote_root, sizeof(remote_root), "%s/remote", root) > 0);
|
CHECK(snprintf(remote_root, sizeof(remote_root), "%s/remote", root) > 0);
|
||||||
CHECK(mkdir(remote_root, 0700) == 0);
|
CHECK(mkdir(remote_root, 0700) == 0);
|
||||||
CHECK(snprintf(case_a, sizeof(case_a), "%s/case-a", root) > 0);
|
CHECK(snprintf(case_a, sizeof(case_a), "%s/case-a", root) > 0);
|
||||||
|
|
@ -660,12 +939,51 @@ static bool run_all(void)
|
||||||
CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", root) > 0);
|
CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", root) > 0);
|
||||||
CHECK(snprintf(case_d, sizeof(case_d), "%s/case-d", root) > 0);
|
CHECK(snprintf(case_d, sizeof(case_d), "%s/case-d", root) > 0);
|
||||||
CHECK(snprintf(case_e, sizeof(case_e), "%s/case-e", root) > 0);
|
CHECK(snprintf(case_e, sizeof(case_e), "%s/case-e", root) > 0);
|
||||||
|
CHECK(snprintf(case_f, sizeof(case_f), "%s/case-f", root) > 0);
|
||||||
|
CHECK(snprintf(case_g, sizeof(case_g), "%s/case-g", root) > 0);
|
||||||
|
CHECK(snprintf(case_h, sizeof(case_h), "%s/case-h", root) > 0);
|
||||||
|
CHECK(snprintf(case_i, sizeof(case_i), "%s/case-i", root) > 0);
|
||||||
|
CHECK(snprintf(case_j, sizeof(case_j), "%s/case-j", root) > 0);
|
||||||
|
CHECK(snprintf(case_k, sizeof(case_k), "%s/case-k", root) > 0);
|
||||||
|
CHECK(snprintf(case_l, sizeof(case_l), "%s/case-l", root) > 0);
|
||||||
|
CHECK(snprintf(case_m, sizeof(case_m), "%s/case-m", root) > 0);
|
||||||
|
CHECK(snprintf(case_n, sizeof(case_n), "%s/case-n", root) > 0);
|
||||||
|
CHECK(snprintf(case_o, sizeof(case_o), "%s/case-o", root) > 0);
|
||||||
|
CHECK(snprintf(fake_tools, sizeof(fake_tools), "%s/fake-tools", root) > 0);
|
||||||
|
CHECK(mkdir(fake_tools, 0700) == 0);
|
||||||
|
|
||||||
CHECK(run_success_case(case_a, false));
|
CHECK(run_success_case(case_a, false));
|
||||||
CHECK(run_success_case(case_b, true));
|
CHECK(run_success_case(case_b, true));
|
||||||
|
CHECK(rclone_upload_count() == 4U);
|
||||||
|
CHECK(run_ai_inbound_diagnostic_case(case_j, "invalid",
|
||||||
|
"AI_INBOUND=REJECTED", false));
|
||||||
|
CHECK(run_ai_inbound_diagnostic_case(case_k, "archive_fail",
|
||||||
|
"AI_INBOUND=ARCHIVE_FAIL", false));
|
||||||
|
CHECK(run_ai_inbound_diagnostic_case(case_l, "none",
|
||||||
|
"AI_INBOUND=NONE", false));
|
||||||
|
CHECK(run_ai_inbound_diagnostic_case(case_m, "already",
|
||||||
|
"AI_INBOUND=IMPORTED", true));
|
||||||
|
/* Marker-like diagnostic text is not a result line; the final exact
|
||||||
|
* helper marker, including a later failure, remains authoritative. */
|
||||||
|
CHECK(run_ai_inbound_diagnostic_case(case_n, "marker_prefix",
|
||||||
|
"AI_INBOUND=DRIVE_FAIL", false));
|
||||||
|
CHECK(run_ai_inbound_diagnostic_case(case_o, "marker_suffix",
|
||||||
|
"AI_INBOUND=DRIVE_FAIL", false));
|
||||||
CHECK(run_unknown_without_proof_case(case_c));
|
CHECK(run_unknown_without_proof_case(case_c));
|
||||||
CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d));
|
CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d));
|
||||||
CHECK(run_missing_profile_companion_case(case_e));
|
CHECK(run_missing_profile_companion_case(case_e));
|
||||||
|
CHECK(run_incoming_alias_case(case_f));
|
||||||
|
CHECK(run_alias_diagnostic_case(case_g, fake_tools, NULL,
|
||||||
|
"outil Python introuvable"));
|
||||||
|
CHECK(run_alias_diagnostic_case(case_h, fake_tools,
|
||||||
|
"import argparse\nargparse.ArgumentParser().parse_args()\n",
|
||||||
|
"unrecognized arguments"));
|
||||||
|
CHECK(run_alias_diagnostic_case(case_i, fake_tools,
|
||||||
|
"raise RuntimeError('alias importer synthetic exception')\n",
|
||||||
|
"RuntimeError: alias importer synthetic exception"));
|
||||||
|
/* INVARIANT: none of the three failed synchronization paths may start a
|
||||||
|
* post-sync Drive upload, even when an older export remains on disk. */
|
||||||
|
CHECK(rclone_upload_count() == 14U);
|
||||||
(void)puts("PASS production sync body-zone V2 proof wiring");
|
(void)puts("PASS production sync body-zone V2 proof wiring");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ int main(void)
|
||||||
|
|
||||||
size_t device_count = 0U;
|
size_t device_count = 0U;
|
||||||
size_t storage_count = 0U;
|
size_t storage_count = 0U;
|
||||||
uint32_t download_id = 0U;
|
uint32_t documents_id = 0U;
|
||||||
uint32_t trainlog_id = 0U;
|
uint32_t trainlog_id = 0U;
|
||||||
uint32_t export_id = 0U;
|
uint32_t export_id = 0U;
|
||||||
uint64_t export_size = 0U;
|
uint64_t export_size = 0U;
|
||||||
|
|
@ -240,14 +240,14 @@ int main(void)
|
||||||
&devices[0],
|
&devices[0],
|
||||||
storages[0].storage_id,
|
storages[0].storage_id,
|
||||||
UINT32_MAX,
|
UINT32_MAX,
|
||||||
"Download",
|
"Documents",
|
||||||
&download_id
|
&documents_id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
(void)fprintf(
|
(void)fprintf(
|
||||||
stderr,
|
stderr,
|
||||||
"Download folder not found\n"
|
"Documents folder not found\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
|
|
@ -257,7 +257,7 @@ int main(void)
|
||||||
find_child_folder(
|
find_child_folder(
|
||||||
&devices[0],
|
&devices[0],
|
||||||
storages[0].storage_id,
|
storages[0].storage_id,
|
||||||
download_id,
|
documents_id,
|
||||||
"Trainlog",
|
"Trainlog",
|
||||||
&trainlog_id
|
&trainlog_id
|
||||||
);
|
);
|
||||||
|
|
@ -265,7 +265,7 @@ int main(void)
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
(void)fprintf(
|
(void)fprintf(
|
||||||
stderr,
|
stderr,
|
||||||
"Download/Trainlog not found\n"
|
"Documents/Trainlog not found\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
|
|
@ -329,7 +329,7 @@ int main(void)
|
||||||
);
|
);
|
||||||
|
|
||||||
(void)printf(
|
(void)printf(
|
||||||
"remote=Download/Trainlog/"
|
"remote=Documents/Trainlog/"
|
||||||
"trainlog-mobile-export-v1.json\n"
|
"trainlog-mobile-export-v1.json\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue