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/
|
||||
exports/
|
||||
.local-backups/
|
||||
/trainlog_ai_export_v1.json
|
||||
|
||||
# Python helpers
|
||||
__pycache__/
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ Do not introduce a mandatory GVFS/FUSE mount.
|
|||
Canonical exchange folder:
|
||||
|
||||
```text
|
||||
Download/Trainlog
|
||||
Documents/Trainlog
|
||||
```
|
||||
|
||||
The shared desktop synchronization engine is:
|
||||
|
|
|
|||
49
CHANGELOG.md
49
CHANGELOG.md
|
|
@ -1,5 +1,44 @@
|
|||
# Changelog
|
||||
|
||||
- Added an Android confirmation dialog before deleting an imported AI session
|
||||
draft. Cancel, Back and outside dismissal are non-mutating; only the
|
||||
destructive confirmation invokes the existing deleted-tombstone operation.
|
||||
|
||||
- Replaced Android SAF folder grants with user-enabled
|
||||
`MANAGE_EXTERNAL_STORAGE` access to the single fixed path
|
||||
`/storage/emulated/0/Documents/Trainlog`. Android opens the system all-files
|
||||
permission screen, writes canonical artifacts through fsync plus
|
||||
same-directory replacement, reads PC artifacts directly, and removes stale
|
||||
tree-URI preferences. Desktop publication remains `Documents/Trainlog`; the
|
||||
historical Download tree and recovery backup remain untouched.
|
||||
|
||||
- Added `TRAINLOG_AI_SESSION_DRAFT_V1` as a separate strict proposal path:
|
||||
desktop schema v18 imports one Drive source with `aid_` identity/digest
|
||||
idempotence and post-commit archive bookkeeping, then publishes the bounded
|
||||
`trainlog-ai-session-drafts` V1 Android companion in durable deterministic
|
||||
batches, marking only successful MTP publications. Entry notes are rejected;
|
||||
only draft-level notes belong to the source contract. Inbound helper outcomes
|
||||
remain visible without turning Drive/archive/fetched-invalid results into a
|
||||
failed device sync. Android schema v17 keeps
|
||||
those proposals separate from its singleton active capture draft and retains
|
||||
start/delete tombstones against replay. Automated contract coverage exists;
|
||||
the required real Drive and Android-triggered bidirectional smoke validation
|
||||
remains pending.
|
||||
|
||||
- Fixed automatic Android→PC exercise-alias imports when the desktop binary is
|
||||
installed outside the repository build tree. Python sync helpers now resolve
|
||||
from Meson's authoritative tools directory, stale result files are truncated
|
||||
before resolution, and bounded child stderr/exit diagnostics reach sync
|
||||
history instead of collapsing to a generic import failure.
|
||||
- Added `TRAINLOG_AI_EXPORT_V1`, a deterministic local JSON history export for
|
||||
manual external analysis. It opens desktop SQLite explicitly read-only,
|
||||
preserves occurrence-owned immediate feedback and session-owned follow-ups,
|
||||
and emits no inferred recommendations or progression.
|
||||
- Added the shared desktop post-sync publication: successful TUI and
|
||||
Android-requested sync runs regenerate the AI export and invoke external
|
||||
`rclone copyto` for Google Drive. Export/Drive failures remain visible but do
|
||||
not invalidate an already successful Trainlog synchronization.
|
||||
|
||||
- Fixed Android-triggered synchronization so the complete current Android→PC
|
||||
companion bundle is published before the request signal, including Training
|
||||
Feedback V2 and Exercise Profile State V1. Desktop orchestration now refuses
|
||||
|
|
@ -8,6 +47,16 @@
|
|||
- Made Android exchange-file reuse exact and directory-scoped: pending,
|
||||
trashed, unrelated, and conflict-numbered MediaStore rows no longer enter
|
||||
canonical selection, while genuine duplicate exact names fail closed.
|
||||
- Hardened the shared Android SAF publisher so every outbound companion and
|
||||
sync request rewrites an existing exact canonical document with truncation,
|
||||
never selects historical `(N)` copies, and reports
|
||||
`SAF_CANONICAL_NAME_CONFLICT` before writing when a provider renames a newly
|
||||
created document.
|
||||
- Added an exact-document fallback for Samsung Android 16
|
||||
`ExternalStorageProvider`, whose child query can omit ownerless MTP files even
|
||||
though creation detects their names. Canonical rows are reconstructed only
|
||||
beneath the granted tree and verified by exact provider display name;
|
||||
historical suffixes remain untouched.
|
||||
|
||||
## Unreleased — Desktop historical tracking snapshot
|
||||
|
||||
|
|
|
|||
59
README.md
59
README.md
|
|
@ -15,6 +15,7 @@ desktop.
|
|||
|
||||
```text
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
|
||||
|
||||
DESKTOP_SCHEMA_V11=PASS
|
||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||
|
|
@ -67,7 +68,7 @@ Android occurrence pages and latest-MAX context share the settled exact-instant
|
|||
ordering and source-text cursor contract. The independent temporal review
|
||||
returned PASS with no findings. The initial full-tranche audit's four findings
|
||||
were resolved by one bounded repair chain and independently verified. Fresh
|
||||
validation passed: strict build, 42 Meson tests, Python knowledge/temporal
|
||||
validation passed: strict build, 56 Meson tests, Python knowledge/temporal
|
||||
tests, validators, headers, sanitizers, 12-form temporal probes, Android 56
|
||||
tests with one known fixture skip, and Java 17 debug assembly.
|
||||
The [temporal correction record](docs/reviews/training_knowledge_v1_temporal_contract.md)
|
||||
|
|
@ -82,7 +83,7 @@ defines the accepted grammar, regression evidence and remaining review boundary.
|
|||
automatic mobile snapshot
|
||||
|
|
||||
v
|
||||
Download/Trainlog on Android storage
|
||||
Documents/Trainlog on Android storage
|
||||
|
|
||||
| direct MTP / libmtp
|
||||
v
|
||||
|
|
@ -192,6 +193,58 @@ python tools/validate_import_contract.py
|
|||
git diff --check
|
||||
```
|
||||
|
||||
## Local history export for external analysis
|
||||
|
||||
Create the read-only `TRAINLOG_AI_EXPORT_V1` artifact in the current directory:
|
||||
|
||||
```bash
|
||||
python tools/export_ai_history.py --database ~/.local/share/trainlog/trainlog.db
|
||||
```
|
||||
|
||||
The default output is `trainlog_ai_export_v1.json`. It is ignored by Git
|
||||
because it contains user data. After every completely successful desktop sync,
|
||||
the shared sync engine regenerates this artifact and asks the external desktop
|
||||
`rclone` command to copy it to
|
||||
`TrainLog Gdrive:Trainlog/AI/trainlog_ai_export_v1.json`. Export or Drive
|
||||
failure is reported without changing the successful Trainlog sync result.
|
||||
Android never runs `rclone`, and its configuration/OAuth credentials remain in
|
||||
the user's normal `~/.config/rclone/` location outside this repository and the
|
||||
APK. See [the exchange documentation](docs/exchange_format.md) for the
|
||||
versioned raw-data contract and feedback ownership rules.
|
||||
|
||||
## AI session-draft exchange
|
||||
|
||||
`TRAINLOG_AI_SESSION_DRAFT_V1` is a separate, strict proposal exchange; it
|
||||
does not alter `TRAINLOG_FORMAT_V1`, the mobile export, or the existing
|
||||
read-only `TRAINLOG_AI_EXPORT_V1` history export. An external producer places
|
||||
one source object at
|
||||
`TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json`. The desktop
|
||||
fetches it during synchronization, validates and imports it transactionally,
|
||||
then copies those exact fetched bytes after that commit to
|
||||
`TrainLog Gdrive:Trainlog/AI/archive/` under its stable `aid_<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 keeps one durable in-progress workout in its local SQLite database.
|
||||
|
|
@ -205,7 +258,7 @@ export or desktop synchronization as completed sessions.
|
|||
Schema migrations are additive and preserve existing capture data. See
|
||||
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
||||
|
||||
The current Android schema is v14. Its additive v4 -> v14 chain adds the shared
|
||||
The current Android schema is v17. Its additive v4 -> v17 chain adds the shared
|
||||
equipment catalogue, per-occurrence equipment links, durable occurrence
|
||||
identities, custom-equipment definition support, explicit MAX results and
|
||||
stable-source Test max resumption, then direct primary/secondary body-zone
|
||||
|
|
|
|||
|
|
@ -78,8 +78,6 @@ dependencies {
|
|||
"androidx.compose.ui:ui-tooling-preview"
|
||||
)
|
||||
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
|
||||
debugImplementation(
|
||||
"androidx.compose.ui:ui-tooling"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Voice feedback stores recognition text only; no audio file is created. -->
|
||||
<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
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
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_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) {
|
||||
"trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES"
|
||||
|
|
@ -22,141 +43,164 @@ internal fun exchangeArtifactIdentity(displayName: String): String = when (displ
|
|||
else -> displayName
|
||||
}
|
||||
|
||||
/* WHY: document providers may throw the same message from different SAF
|
||||
* operations. CONTRACT: traces contain artifact identity, operation phase,
|
||||
* URI and exception metadata only; user JSON is never logged. */
|
||||
internal fun logExchangeMediaStore(displayName: String, phase: String, detail: String? = null) {
|
||||
internal fun logDirectExchange(displayName: String, phase: String, detail: String? = null) {
|
||||
val suffix = detail?.let { " $it" } ?: ""
|
||||
Log.d(EXCHANGE_MEDIASTORE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} $phase$suffix")
|
||||
Log.d(DIRECT_EXCHANGE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} $phase$suffix")
|
||||
}
|
||||
|
||||
internal fun logExchangeMediaStoreError(displayName: String, phase: String, error: Throwable) {
|
||||
Log.e(
|
||||
EXCHANGE_MEDIASTORE_LOG_TAG,
|
||||
"${exchangeArtifactIdentity(displayName)} error phase=$phase " +
|
||||
"exception=${error.javaClass.name}:${error.message}",
|
||||
)
|
||||
internal fun logDirectExchangeError(displayName: String, phase: String, error: Throwable) {
|
||||
Log.e(DIRECT_EXCHANGE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} error phase=$phase " +
|
||||
"exception=${error.javaClass.name}:${error.message}")
|
||||
}
|
||||
|
||||
internal data class ExchangeSafDocument(
|
||||
val uri: Uri,
|
||||
val displayName: String,
|
||||
)
|
||||
internal data class DirectExchangeDocument(val file: File, val displayName: String)
|
||||
|
||||
internal interface ExchangeSafDirectory {
|
||||
fun listDirectChildren(): List<ExchangeSafDocument>
|
||||
fun createJson(displayName: String): ExchangeSafDocument?
|
||||
fun openForRewrite(document: ExchangeSafDocument): OutputStream?
|
||||
internal interface DirectExchangeDirectoryAccess {
|
||||
fun listDirectChildren(): List<DirectExchangeDocument>
|
||||
fun findExact(displayName: String): List<DirectExchangeDocument>
|
||||
fun createJson(displayName: String): DirectExchangeDocument?
|
||||
fun openForRewrite(document: DirectExchangeDocument): OutputStream?
|
||||
}
|
||||
|
||||
private class DocumentFileExchangeSafDirectory(
|
||||
private val context: Context,
|
||||
private val directory: DocumentFile,
|
||||
) : ExchangeSafDirectory {
|
||||
override fun listDirectChildren(): List<ExchangeSafDocument> =
|
||||
directory.listFiles().mapNotNull { child ->
|
||||
child.name?.let { ExchangeSafDocument(child.uri, it) }
|
||||
/* WHY: provider writes allowed Samsung to manufacture numbered conflict
|
||||
* copies. Direct files make the exact canonical name authoritative.
|
||||
* INVARIANT: bytes are fully written and fsync'd in this directory before a
|
||||
* same-directory move replaces the canonical path. */
|
||||
private class DirectExchangeDirectory(private val directory: File) : DirectExchangeDirectoryAccess {
|
||||
override fun listDirectChildren(): List<DirectExchangeDocument> =
|
||||
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."
|
||||
}
|
||||
|
||||
override fun createJson(displayName: String): ExchangeSafDocument? =
|
||||
directory.createFile("application/json", displayName)?.let { created ->
|
||||
val actualName = created.name
|
||||
require(actualName == displayName) {
|
||||
"Le fournisseur SAF a créé $actualName au lieu de $displayName."
|
||||
val temporary = File(directory, ".trainlog-${document.displayName}-${UUID.randomUUID()}.tmp")
|
||||
val delegate = FileOutputStream(temporary, false)
|
||||
return object : OutputStream() {
|
||||
private var closed = false
|
||||
private var failed = false
|
||||
override fun write(value: Int) = attempt { delegate.write(value) }
|
||||
override fun write(bytes: ByteArray, offset: Int, length: Int) =
|
||||
attempt { delegate.write(bytes, offset, length) }
|
||||
override fun flush() = attempt { delegate.flush() }
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (failed) {
|
||||
runCatching { delegate.close() }
|
||||
temporary.delete()
|
||||
return
|
||||
}
|
||||
try {
|
||||
delegate.flush()
|
||||
delegate.fd.sync()
|
||||
delegate.close()
|
||||
try {
|
||||
Files.move(temporary.toPath(), document.file.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(temporary.toPath(), document.file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
runCatching { delegate.close() }
|
||||
temporary.delete()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun attempt(operation: () -> Unit) {
|
||||
try {
|
||||
operation()
|
||||
} catch (error: Exception) {
|
||||
failed = true
|
||||
throw error
|
||||
}
|
||||
}
|
||||
ExchangeSafDocument(created.uri, displayName)
|
||||
}
|
||||
}
|
||||
|
||||
override fun openForRewrite(document: ExchangeSafDocument): OutputStream? =
|
||||
context.contentResolver.openOutputStream(document.uri, "wt")
|
||||
private fun requireCanonicalLeaf(displayName: String) {
|
||||
require(displayName.isNotEmpty() && displayName != "." && displayName != "..")
|
||||
require('/' !in displayName && '\\' !in displayName) { "Nom d'artefact invalide." }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun persistedExchangeSafDirectory(context: Context): ExchangeSafDirectory? {
|
||||
val appContext = context.applicationContext
|
||||
val treeUri = appContext.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
.getString(SYNC_TREE_URI_KEY, null)?.let(Uri::parse) ?: return null
|
||||
val directory = DocumentFile.fromTreeUri(appContext, treeUri) ?: return null
|
||||
if (!directory.isDirectory || !directory.canWrite()) return null
|
||||
return DocumentFileExchangeSafDirectory(appContext, directory)
|
||||
internal fun directExchangeDirectoryForPath(directory: File): DirectExchangeDirectoryAccess? {
|
||||
if (!directory.exists() && !directory.mkdirs()) return null
|
||||
if (!directory.isDirectory || !directory.canRead() || !directory.canWrite()) return null
|
||||
return DirectExchangeDirectory(directory)
|
||||
}
|
||||
|
||||
internal class ExchangeSafPublisher(
|
||||
private val directoryProvider: () -> ExchangeSafDirectory?,
|
||||
) {
|
||||
fun snapshot(): ExchangeSafSnapshotResult {
|
||||
internal fun directExchangeDirectory(context: Context): DirectExchangeDirectoryAccess? {
|
||||
clearLegacyExchangeTreePreference(context)
|
||||
if (!hasDirectExchangePermission()) return null
|
||||
return directExchangeDirectoryForPath(canonicalExchangeDirectory())
|
||||
}
|
||||
|
||||
internal class DirectExchangePublisher(private val directoryProvider: () -> DirectExchangeDirectoryAccess?) {
|
||||
fun snapshot(): DirectExchangeSnapshotResult {
|
||||
val directory = directoryProvider()
|
||||
?: return ExchangeSafSnapshotResult.Error(
|
||||
"Dossier Trainlog non autorisé ou inaccessible.",
|
||||
)
|
||||
?: return DirectExchangeSnapshotResult.Error("Accès fichiers requis pour Documents/Trainlog.")
|
||||
val started = android.os.SystemClock.elapsedRealtime()
|
||||
return try {
|
||||
logExchangeMediaStore("SAF_TREE", "snapshot.begin", "thread=${Thread.currentThread().name}")
|
||||
logDirectExchange("DIRECT_STORAGE", "snapshot.begin", "thread=${Thread.currentThread().name}")
|
||||
val children = directory.listDirectChildren()
|
||||
val byName = children.groupByTo(linkedMapOf()) { it.displayName }
|
||||
val elapsed = android.os.SystemClock.elapsedRealtime() - started
|
||||
logExchangeMediaStore(
|
||||
"SAF_TREE",
|
||||
"snapshot.success",
|
||||
"children=${children.size} duration_ms=$elapsed thread=${Thread.currentThread().name}",
|
||||
)
|
||||
ExchangeSafSnapshotResult.Ready(ExchangeSafSnapshot(directory, byName))
|
||||
logDirectExchange("DIRECT_STORAGE", "snapshot.success",
|
||||
"children=${children.size} duration_ms=${android.os.SystemClock.elapsedRealtime() - started} " +
|
||||
"thread=${Thread.currentThread().name}")
|
||||
DirectExchangeSnapshotResult.Ready(DirectExchangeSnapshot(directory, byName))
|
||||
} catch (error: Exception) {
|
||||
logExchangeMediaStoreError("SAF_TREE", "snapshot", error)
|
||||
ExchangeSafSnapshotResult.Error(error.message ?: "Lecture du dossier SAF impossible.")
|
||||
logDirectExchangeError("DIRECT_STORAGE", "snapshot", error)
|
||||
DirectExchangeSnapshotResult.Error(error.message ?: "Lecture de Documents/Trainlog impossible.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface ExchangeSafSnapshotResult {
|
||||
data class Ready(val snapshot: ExchangeSafSnapshot) : ExchangeSafSnapshotResult
|
||||
data class Error(val message: String) : ExchangeSafSnapshotResult
|
||||
internal sealed interface DirectExchangeSnapshotResult {
|
||||
data class Ready(val snapshot: DirectExchangeSnapshot) : DirectExchangeSnapshotResult
|
||||
data class Error(val message: String) : DirectExchangeSnapshotResult
|
||||
}
|
||||
|
||||
internal class ExchangeSafSnapshot(
|
||||
private val directory: ExchangeSafDirectory,
|
||||
private val byName: MutableMap<String, MutableList<ExchangeSafDocument>>,
|
||||
internal class DirectExchangeSnapshot(
|
||||
private val directory: DirectExchangeDirectoryAccess,
|
||||
private val byName: MutableMap<String, MutableList<DirectExchangeDocument>>,
|
||||
) {
|
||||
fun writeJson(displayName: String, json: String): String? {
|
||||
var phase = "saf.resolve"
|
||||
var phase = "direct.resolve"
|
||||
return try {
|
||||
logExchangeMediaStore(displayName, "saf.resolve.begin")
|
||||
val exact = byName[displayName].orEmpty()
|
||||
require(exact.size <= 1) {
|
||||
"Plusieurs documents SAF canoniques exacts pour $displayName (${exact.size})."
|
||||
}
|
||||
val existing = exact.singleOrNull()
|
||||
val document = if (existing != null) {
|
||||
logExchangeMediaStore(displayName, "saf.resolve.existing", "uri=${existing.uri}")
|
||||
existing
|
||||
} else {
|
||||
phase = "saf.create"
|
||||
logExchangeMediaStore(displayName, "saf.resolve.absent")
|
||||
logExchangeMediaStore(displayName, "saf.create.begin", "mime=application/json")
|
||||
directory.createJson(displayName)?.also { created ->
|
||||
/* INVARIANT: creation mutates this transaction-local index
|
||||
* so later artifacts never rescan the live directory. */
|
||||
byName.getOrPut(displayName) { mutableListOf() }.add(created)
|
||||
logExchangeMediaStore(displayName, "saf.create.success", "uri=${created.uri}")
|
||||
} ?: return "Création SAF de $displayName impossible."
|
||||
}
|
||||
phase = "saf.openOutputStream"
|
||||
logExchangeMediaStore(
|
||||
displayName,
|
||||
"saf.write.begin",
|
||||
"uri=${document.uri} mode=wt thread=${Thread.currentThread().name}",
|
||||
)
|
||||
val stream = directory.openForRewrite(document)
|
||||
?: return "Écriture SAF de $displayName impossible."
|
||||
phase = "saf.stream.write"
|
||||
stream.use {
|
||||
it.write(json.toByteArray(Charsets.UTF_8))
|
||||
it.flush()
|
||||
}
|
||||
logExchangeMediaStore(displayName, "saf.write.success", "uri=${document.uri}")
|
||||
require(exact.size <= 1) { "DIRECT_STORAGE_DUPLICATE_CANONICAL: nom=$displayName count=${exact.size}" }
|
||||
val resolved = exact.singleOrNull() ?: directory.findExact(displayName).also {
|
||||
require(it.size <= 1) { "DIRECT_STORAGE_DUPLICATE_CANONICAL: nom=$displayName count=${it.size}" }
|
||||
}.singleOrNull()
|
||||
val document = resolved ?: directory.createJson(displayName)?.also {
|
||||
require(it.displayName == displayName) {
|
||||
"DIRECT_STORAGE_CANONICAL_NAME_CONFLICT: attendu=$displayName obtenu=${it.displayName}"
|
||||
}
|
||||
byName.getOrPut(displayName) { mutableListOf() }.add(it)
|
||||
} ?: return "Création de $displayName impossible."
|
||||
phase = "direct.atomicWrite"
|
||||
val stream = directory.openForRewrite(document) ?: return "Écriture de $displayName impossible."
|
||||
stream.use { it.write(json.toByteArray(Charsets.UTF_8)) }
|
||||
logDirectExchange(displayName, "direct.write.success")
|
||||
null
|
||||
} catch (error: Exception) {
|
||||
logExchangeMediaStoreError(displayName, phase, error)
|
||||
error.message ?: "Publication SAF de $displayName impossible."
|
||||
logDirectExchangeError(displayName, phase, error)
|
||||
error.message ?: "Publication de $displayName impossible."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
sealed interface CatalogInboxResult {
|
||||
|
|
@ -50,96 +48,34 @@ class SyncCatalogInbox(
|
|||
private val appContext =
|
||||
context.applicationContext
|
||||
|
||||
private val preferences =
|
||||
appContext.getSharedPreferences(
|
||||
SYNC_PREFERENCES_NAME,
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
|
||||
fun hasFolderAccess(): Boolean =
|
||||
savedTreeUri() != null
|
||||
|
||||
fun saveTreeUri(
|
||||
uri: Uri,
|
||||
): Boolean {
|
||||
return try {
|
||||
appContext
|
||||
.contentResolver
|
||||
.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
|
||||
preferences
|
||||
.edit()
|
||||
.putString(
|
||||
SYNC_TREE_URI_KEY,
|
||||
uri.toString(),
|
||||
)
|
||||
.apply()
|
||||
|
||||
true
|
||||
} catch (
|
||||
error: SecurityException
|
||||
) {
|
||||
false
|
||||
}
|
||||
}
|
||||
directExchangeDirectory() != null
|
||||
|
||||
fun importPcCatalog():
|
||||
CatalogInboxResult {
|
||||
val treeUri =
|
||||
savedTreeUri()
|
||||
?: return CatalogInboxResult
|
||||
.FolderNotAuthorized
|
||||
val directory = directExchangeDirectory()
|
||||
?: return CatalogInboxResult.FolderNotAuthorized
|
||||
|
||||
val directory =
|
||||
DocumentFile
|
||||
.fromTreeUri(
|
||||
appContext,
|
||||
treeUri,
|
||||
)
|
||||
?: return CatalogInboxResult.Error(
|
||||
"Dossier Trainlog inaccessible."
|
||||
)
|
||||
return importPcCatalogFromDirectory(directory)
|
||||
}
|
||||
|
||||
/** Test seam for the production companion ordering on a fresh database. */
|
||||
internal fun importPcCatalogFromDirectoryForTest(directory: File): CatalogInboxResult =
|
||||
importPcCatalogFromDirectory(directory)
|
||||
|
||||
private fun importPcCatalogFromDirectory(directory: File): CatalogInboxResult {
|
||||
|
||||
val file =
|
||||
directory.findFile(
|
||||
"trainlog-pc-catalog-v1.json"
|
||||
)
|
||||
directory.findDirectFile("trainlog-pc-catalog-v1.json")
|
||||
?: return CatalogInboxResult.FileNotFound
|
||||
|
||||
return try {
|
||||
val stream =
|
||||
appContext
|
||||
.contentResolver
|
||||
.openInputStream(
|
||||
file.uri
|
||||
)
|
||||
?: return CatalogInboxResult.Error(
|
||||
"Lecture du catalogue impossible."
|
||||
)
|
||||
|
||||
val json =
|
||||
stream.bufferedReader(
|
||||
Charsets.UTF_8
|
||||
)
|
||||
.use {
|
||||
it.readText()
|
||||
}
|
||||
val json = file.readText(Charsets.UTF_8)
|
||||
|
||||
val definitionsError = importPcEquipmentDefinitions(directory)
|
||||
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
|
||||
val aliasesError = importExerciseAliases(directory)
|
||||
if (aliasesError != null) return CatalogInboxResult.Error(aliasesError)
|
||||
val profileFile = directory.findFile("trainlog-exercise-profile-state-v1.json")
|
||||
val profileJson = profileFile?.let { candidate ->
|
||||
appContext.contentResolver.openInputStream(candidate.uri)
|
||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||
}
|
||||
if (profileFile != null && profileJson == null)
|
||||
return CatalogInboxResult.Error("Lecture profile-state impossible.")
|
||||
val profileFile = directory.findDirectFile("trainlog-exercise-profile-state-v1.json")
|
||||
val profileJson = profileFile?.readText(Charsets.UTF_8)
|
||||
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson,allowPending=true)) {
|
||||
is ExerciseProfileStateImportResult.Applied -> Unit
|
||||
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
|
||||
|
|
@ -155,6 +91,12 @@ class SyncCatalogInbox(
|
|||
)
|
||||
) {
|
||||
is PcCatalogImportResult.Applied -> {
|
||||
/* WHY: a fresh Android database cannot validate an alias
|
||||
* target until the catalog has created that canonical
|
||||
* exercise. CONTRACT: aliases still precede session import
|
||||
* so retired creator IDs resolve during the same sync. */
|
||||
val aliasesError = importExerciseAliases(directory)
|
||||
if (aliasesError != null) return CatalogInboxResult.Error(aliasesError)
|
||||
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson)) {
|
||||
is ExerciseProfileStateImportResult.Applied -> Unit
|
||||
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
|
||||
|
|
@ -163,23 +105,16 @@ class SyncCatalogInbox(
|
|||
}
|
||||
/* Catalogue identities now exist and the strict second pass
|
||||
* has either installed or verified their causal state. */
|
||||
when (val sessions = importPcSessions(directory)) {
|
||||
null -> when (val equipment = importPcEquipmentAssociations(directory)) {
|
||||
null -> when (val bodyZones = importPcBodyZones(directory)) {
|
||||
null -> when (val feedback = importTrainingFeedback(directory)) {
|
||||
null -> CatalogInboxResult.Imported(
|
||||
imported = result.imported,
|
||||
reconciled = result.reconciled,
|
||||
skipped = result.skipped,
|
||||
)
|
||||
else -> CatalogInboxResult.Error(feedback)
|
||||
}
|
||||
else -> CatalogInboxResult.Error(bodyZones)
|
||||
}
|
||||
else -> CatalogInboxResult.Error(equipment)
|
||||
}
|
||||
else -> CatalogInboxResult.Error(sessions)
|
||||
}
|
||||
importPcSessions(directory)?.let { return CatalogInboxResult.Error(it) }
|
||||
importPcEquipmentAssociations(directory)?.let { return CatalogInboxResult.Error(it) }
|
||||
importAiSessionDrafts(directory)?.let { return CatalogInboxResult.Error(it) }
|
||||
importPcBodyZones(directory)?.let { return CatalogInboxResult.Error(it) }
|
||||
importTrainingFeedback(directory)?.let { return CatalogInboxResult.Error(it) }
|
||||
CatalogInboxResult.Imported(
|
||||
imported = result.imported,
|
||||
reconciled = result.reconciled,
|
||||
skipped = result.skipped,
|
||||
)
|
||||
}
|
||||
|
||||
is PcCatalogImportResult.Invalid ->
|
||||
|
|
@ -202,15 +137,13 @@ class SyncCatalogInbox(
|
|||
}
|
||||
}
|
||||
|
||||
private fun importPcSessions(directory: DocumentFile): String? {
|
||||
private fun importPcSessions(directory: File): String? {
|
||||
/* CONTRACT: a present V3 artifact is authoritative. Invalid V3 must
|
||||
* surface its error and never fall back to a stale V2 snapshot. */
|
||||
val v3 = directory.findFile("trainlog-pc-mobile-export-v3.json")
|
||||
val file = v3 ?: directory.findFile("trainlog-pc-mobile-export-v2.json") ?: return null
|
||||
val v3 = directory.findDirectFile("trainlog-pc-mobile-export-v3.json")
|
||||
val file = v3 ?: directory.findDirectFile("trainlog-pc-mobile-export-v2.json") ?: return null
|
||||
return try {
|
||||
val json = appContext.contentResolver.openInputStream(file.uri)
|
||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||
?: return "Lecture snapshot séances impossible."
|
||||
val json = file.readText(Charsets.UTF_8)
|
||||
val result = if (v3 != null) repository.applyPcMobileExportV3Json(json)
|
||||
else repository.applyPcMobileExportV2Json(json)
|
||||
when (result) {
|
||||
|
|
@ -221,14 +154,13 @@ class SyncCatalogInbox(
|
|||
} catch (error: Exception) { error.message ?: "Import séances impossible." }
|
||||
}
|
||||
|
||||
private fun importTrainingFeedback(directory: DocumentFile): String? {
|
||||
private fun importTrainingFeedback(directory: File): String? {
|
||||
/* V2 is authoritative for revision history. V1 remains a fallback for
|
||||
* an older peer and can only add deterministic initial revisions. */
|
||||
val file = directory.findFile("trainlog-training-feedback-v2.json")
|
||||
?: directory.findFile("trainlog-training-feedback-v1.json") ?: return null
|
||||
val file = directory.findDirectFile("trainlog-training-feedback-v2.json")
|
||||
?: directory.findDirectFile("trainlog-training-feedback-v1.json") ?: return null
|
||||
return try {
|
||||
val json = appContext.contentResolver.openInputStream(file.uri)?.bufferedReader(Charsets.UTF_8)
|
||||
?.use { it.readText() } ?: return "Lecture des ressentis impossible."
|
||||
val json = file.readText(Charsets.UTF_8)
|
||||
when (val result = repository.applyTrainingFeedbackJson(json)) {
|
||||
is TrainingFeedbackImportResult.Applied -> null
|
||||
is TrainingFeedbackImportResult.Invalid -> result.message
|
||||
|
|
@ -239,15 +171,31 @@ class SyncCatalogInbox(
|
|||
}
|
||||
|
||||
/** Test seam for the real filename-priority boundary; production uses the same method. */
|
||||
internal fun importPcSessionsFromDirectoryForTest(directory: DocumentFile): String? =
|
||||
internal fun importPcSessionsFromDirectoryForTest(directory: File): String? =
|
||||
importPcSessions(directory)
|
||||
|
||||
private fun importPcBodyZones(directory: DocumentFile): String? {
|
||||
val file = directory.findFile("trainlog-exercise-body-zones-v1.json") ?: return null
|
||||
private fun importAiSessionDrafts(directory: File): String? {
|
||||
val file = directory.findDirectFile("trainlog-ai-session-drafts-v1.json") ?: return null
|
||||
return try {
|
||||
val json = appContext.contentResolver.openInputStream(file.uri)
|
||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||
?: return "Lecture zones corporelles impossible."
|
||||
val json = file.readText(Charsets.UTF_8)
|
||||
when (val result = repository.applyAiSessionDraftsJson(json)) {
|
||||
is AiSessionDraftImportResult.Applied -> null
|
||||
is AiSessionDraftImportResult.Invalid -> result.message
|
||||
AiSessionDraftImportResult.DatabaseError -> "Erreur base locale brouillons IA."
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
error.message ?: "Import des brouillons IA impossible."
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam for the production one-sync prerequisite ordering. */
|
||||
internal fun importAiSessionDraftsFromDirectoryForTest(directory: File): String? =
|
||||
importAiSessionDrafts(directory)
|
||||
|
||||
private fun importPcBodyZones(directory: File): String? {
|
||||
val file = directory.findDirectFile("trainlog-exercise-body-zones-v1.json") ?: return null
|
||||
return try {
|
||||
val json = file.readText(Charsets.UTF_8)
|
||||
when (val result = repository.applyExerciseBodyZonesJson(json)) {
|
||||
is ExerciseBodyZoneImportResult.Applied -> null
|
||||
is ExerciseBodyZoneImportResult.Invalid -> result.message
|
||||
|
|
@ -261,12 +209,10 @@ class SyncCatalogInbox(
|
|||
}
|
||||
}
|
||||
|
||||
private fun importExerciseAliases(directory: DocumentFile): String? {
|
||||
val file = directory.findFile("trainlog-exercise-aliases-v1.json") ?: return null
|
||||
private fun importExerciseAliases(directory: File): String? {
|
||||
val file = directory.findDirectFile("trainlog-exercise-aliases-v1.json") ?: return null
|
||||
return try {
|
||||
val json = appContext.contentResolver.openInputStream(file.uri)
|
||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||
?: return "Lecture des alias exercice impossible."
|
||||
val json = file.readText(Charsets.UTF_8)
|
||||
when (val result = repository.applyExerciseAliasesJson(json)) {
|
||||
is ExerciseAliasImportResult.Applied -> null
|
||||
is ExerciseAliasImportResult.Invalid -> result.message
|
||||
|
|
@ -280,12 +226,10 @@ class SyncCatalogInbox(
|
|||
}
|
||||
}
|
||||
|
||||
private fun importPcEquipmentDefinitions(directory: DocumentFile): String? {
|
||||
val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null
|
||||
private fun importPcEquipmentDefinitions(directory: File): String? {
|
||||
val file = directory.findDirectFile("trainlog-pc-equipment-definitions-v1.json") ?: return null
|
||||
return try {
|
||||
val json = appContext.contentResolver.openInputStream(file.uri)
|
||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||
?: return "Lecture définitions équipement impossible."
|
||||
val json = file.readText(Charsets.UTF_8)
|
||||
when (val result = repository.applyPcEquipmentDefinitionsJson(json)) {
|
||||
is EquipmentDefinitionImportResult.Applied -> null
|
||||
is EquipmentDefinitionImportResult.Invalid -> result.message
|
||||
|
|
@ -294,14 +238,12 @@ class SyncCatalogInbox(
|
|||
} catch (error: Exception) { error.message ?: "Import définitions équipement impossible." }
|
||||
}
|
||||
|
||||
private fun importPcEquipmentAssociations(directory: DocumentFile): String? {
|
||||
val file = directory.findFile("trainlog-equipment-associations-v2.json")
|
||||
?: directory.findFile("trainlog-equipment-associations-v1.json")
|
||||
private fun importPcEquipmentAssociations(directory: File): String? {
|
||||
val file = directory.findDirectFile("trainlog-equipment-associations-v2.json")
|
||||
?: directory.findDirectFile("trainlog-equipment-associations-v1.json")
|
||||
?: return null /* Historic PC sync: absence means no information. */
|
||||
return try {
|
||||
val json = appContext.contentResolver.openInputStream(file.uri)
|
||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||
?: return "Lecture extension équipement impossible."
|
||||
val json = file.readText(Charsets.UTF_8)
|
||||
when (val result = repository.applyPcEquipmentAssociationsJson(json)) {
|
||||
is EquipmentAssociationImportResult.Applied -> null
|
||||
is EquipmentAssociationImportResult.Invalid -> result.message
|
||||
|
|
@ -315,45 +257,15 @@ class SyncCatalogInbox(
|
|||
fun readSyncReceipt(
|
||||
requestId: String,
|
||||
): SyncReceiptResult {
|
||||
val treeUri =
|
||||
savedTreeUri()
|
||||
?: return SyncReceiptResult
|
||||
.FolderNotAuthorized
|
||||
|
||||
val directory =
|
||||
DocumentFile
|
||||
.fromTreeUri(
|
||||
appContext,
|
||||
treeUri,
|
||||
)
|
||||
?: return SyncReceiptResult.Error(
|
||||
"Dossier Trainlog inaccessible."
|
||||
)
|
||||
val directory = directExchangeDirectory()
|
||||
?: return SyncReceiptResult.FolderNotAuthorized
|
||||
|
||||
val file =
|
||||
directory.findFile(
|
||||
"trainlog-sync-receipt-v1.json"
|
||||
)
|
||||
directory.findDirectFile("trainlog-sync-receipt-v1.json")
|
||||
?: return SyncReceiptResult.Pending
|
||||
|
||||
return try {
|
||||
val stream =
|
||||
appContext
|
||||
.contentResolver
|
||||
.openInputStream(
|
||||
file.uri
|
||||
)
|
||||
?: return SyncReceiptResult.Error(
|
||||
"Lecture du reçu impossible."
|
||||
)
|
||||
|
||||
val json =
|
||||
stream.bufferedReader(
|
||||
Charsets.UTF_8
|
||||
)
|
||||
.use {
|
||||
it.readText()
|
||||
}
|
||||
val json = file.readText(Charsets.UTF_8)
|
||||
|
||||
val root =
|
||||
JSONObject(json)
|
||||
|
|
@ -418,14 +330,17 @@ fun readSyncReceipt(
|
|||
}
|
||||
}
|
||||
|
||||
private fun savedTreeUri(): Uri? =
|
||||
preferences
|
||||
.getString(
|
||||
SYNC_TREE_URI_KEY,
|
||||
null,
|
||||
)
|
||||
?.let {
|
||||
Uri.parse(it)
|
||||
}
|
||||
private fun directExchangeDirectory(): File? {
|
||||
clearLegacyExchangeTreePreference(appContext)
|
||||
if (!hasDirectExchangePermission()) return null
|
||||
val directory = canonicalExchangeDirectory()
|
||||
if (!directory.exists() && !directory.mkdirs()) return null
|
||||
return directory.takeIf { it.isDirectory && it.canRead() && it.canWrite() }
|
||||
}
|
||||
|
||||
private fun File.findDirectFile(name: String): File? {
|
||||
require('/' !in name && '\\' !in name)
|
||||
return File(this, name).takeIf(File::isFile)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,31 +22,31 @@ sealed interface SyncExportResult {
|
|||
class SyncExporter private constructor(
|
||||
private val repository:
|
||||
TrainlogRepository,
|
||||
private val safPublisher: ExchangeSafPublisher,
|
||||
private val publisher: DirectExchangePublisher,
|
||||
) {
|
||||
constructor(context: Context, repository: TrainlogRepository) : this(
|
||||
repository,
|
||||
ExchangeSafPublisher { persistedExchangeSafDirectory(context) },
|
||||
DirectExchangePublisher { directExchangeDirectory(context) },
|
||||
)
|
||||
|
||||
internal constructor(
|
||||
context: Context,
|
||||
repository: TrainlogRepository,
|
||||
directoryProvider: () -> ExchangeSafDirectory?,
|
||||
) : this(repository, ExchangeSafPublisher(directoryProvider))
|
||||
directoryProvider: () -> DirectExchangeDirectoryAccess?,
|
||||
) : this(repository, DirectExchangePublisher(directoryProvider))
|
||||
|
||||
/* CONTRACT: repository snapshot construction and every SAF operation are
|
||||
/* CONTRACT: repository snapshot construction and every filesystem operation are
|
||||
* blocking I/O work and must never execute on the Compose/main thread. */
|
||||
suspend fun exportMobileBundle(): SyncExportResult = withContext(Dispatchers.IO) {
|
||||
when (val opened = openSafSnapshot()) {
|
||||
is ExchangeSafSnapshotResult.Ready -> exportMobileBundle(opened.snapshot)
|
||||
is ExchangeSafSnapshotResult.Error -> SyncExportResult.Error(opened.message)
|
||||
when (val opened = openStorageSnapshot()) {
|
||||
is DirectExchangeSnapshotResult.Ready -> exportMobileBundle(opened.snapshot)
|
||||
is DirectExchangeSnapshotResult.Error -> SyncExportResult.Error(opened.message)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun openSafSnapshot(): ExchangeSafSnapshotResult = safPublisher.snapshot()
|
||||
internal fun openStorageSnapshot(): DirectExchangeSnapshotResult = publisher.snapshot()
|
||||
|
||||
internal fun exportMobileBundle(snapshot: ExchangeSafSnapshot): SyncExportResult {
|
||||
internal fun exportMobileBundle(snapshot: DirectExchangeSnapshot): SyncExportResult {
|
||||
if (
|
||||
Build.VERSION.SDK_INT <
|
||||
Build.VERSION_CODES.Q
|
||||
|
|
@ -89,9 +89,8 @@ class SyncExporter private constructor(
|
|||
"trainlog-training-feedback-v2.json" to feedbackJson,
|
||||
"trainlog-exercise-profile-state-v1.json" to profileStateJson,
|
||||
)
|
||||
/* CONTRACT: the persisted user-authorized SAF tree is the sole
|
||||
* outbound directory authority. MediaStore ownership must neither hide
|
||||
* a canonical document nor cause creation of a numbered conflict. */
|
||||
/* CONTRACT: the fixed direct-storage directory is the sole outbound
|
||||
* authority. Same-directory atomic replacement preserves exact names. */
|
||||
artifacts.forEach { (displayName, json) ->
|
||||
snapshot.writeJson(displayName, json)?.let { error ->
|
||||
return SyncExportResult.Error(error)
|
||||
|
|
@ -120,7 +119,7 @@ class SyncExporter private constructor(
|
|||
associationsJson,
|
||||
)?.let { return SyncExportResult.Error(it) }
|
||||
return SyncExportResult.Exported(
|
||||
displayPath = "Download/Trainlog/trainlog-mobile-export-v3.json",
|
||||
displayPath = "Documents/Trainlog/trainlog-mobile-export-v3.json",
|
||||
bytes = mobileJson.toByteArray(Charsets.UTF_8).size,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,24 +22,24 @@ sealed interface SyncRequestResult {
|
|||
}
|
||||
|
||||
class SyncRequestOutbox private constructor(
|
||||
private val safPublisher: ExchangeSafPublisher,
|
||||
private val publisher: DirectExchangePublisher,
|
||||
) {
|
||||
constructor(context: Context) : this(
|
||||
ExchangeSafPublisher { persistedExchangeSafDirectory(context) },
|
||||
DirectExchangePublisher { directExchangeDirectory(context) },
|
||||
)
|
||||
|
||||
internal constructor(directoryProvider: () -> ExchangeSafDirectory?) : this(
|
||||
ExchangeSafPublisher(directoryProvider),
|
||||
internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this(
|
||||
DirectExchangePublisher(directoryProvider),
|
||||
)
|
||||
|
||||
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||
when (val opened = safPublisher.snapshot()) {
|
||||
is ExchangeSafSnapshotResult.Ready -> requestSync(opened.snapshot)
|
||||
is ExchangeSafSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
|
||||
when (val opened = publisher.snapshot()) {
|
||||
is DirectExchangeSnapshotResult.Ready -> requestSync(opened.snapshot)
|
||||
is DirectExchangeSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun requestSync(snapshot: ExchangeSafSnapshot): SyncRequestResult {
|
||||
internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult {
|
||||
if (
|
||||
Build.VERSION.SDK_INT <
|
||||
Build.VERSION_CODES.Q
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import android.database.sqlite.SQLiteOpenHelper
|
|||
import android.util.JsonReader
|
||||
import android.util.JsonToken
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.AiSessionDraft
|
||||
import com.labfytools.trainlog.model.BodyObservationDraft
|
||||
import com.labfytools.trainlog.model.BodyObservationSummary
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
|
|
@ -275,6 +276,19 @@ sealed interface ActiveDraftMutationResult {
|
|||
) : ActiveDraftMutationResult
|
||||
}
|
||||
|
||||
sealed interface AiSessionDraftImportResult {
|
||||
data class Applied(val imported: Int, val skipped: Int) : AiSessionDraftImportResult
|
||||
data class Invalid(val message: String) : AiSessionDraftImportResult
|
||||
data object DatabaseError : AiSessionDraftImportResult
|
||||
}
|
||||
|
||||
sealed interface StartAiSessionDraftResult {
|
||||
data object Started : StartAiSessionDraftResult
|
||||
data object ExistingActiveDraft : StartAiSessionDraftResult
|
||||
data object NotPending : StartAiSessionDraftResult
|
||||
data class Error(val message: String) : StartAiSessionDraftResult
|
||||
}
|
||||
|
||||
sealed interface FinalizeActiveDraftResult {
|
||||
data class Saved(
|
||||
val sessionId: String,
|
||||
|
|
@ -1318,7 +1332,20 @@ class TrainlogRepository(
|
|||
if(!c.moveToFirst()) null else (0..12).map{if(c.isNull(it)) null else c.getString(it)}}
|
||||
if(row==null){if(allowPending){skipped++;continue};return ExerciseProfileStateImportResult.Invalid("Identité exercice inconnue : $id")}
|
||||
val incomingAdj=arrayOf("load_semantics","machine_variant","machine_provenance","scientific_profile_id","science_state","legacy_equipment_id").map{key->if(x.value(key)===JSONObject.NULL)null else x.value(key) as String}
|
||||
if((4..9).map{row[it]}!=incomingAdj)return ExerciseProfileStateImportResult.Conflict(id)
|
||||
val localAdj=(4..9).map{row[it]}
|
||||
/* WHY: the V1 catalog intentionally omits machine/science
|
||||
* identity. A fresh peer therefore creates a completely
|
||||
* unresolved row before this companion can establish it.
|
||||
* CONTRACT: only that all-null/unresolved bootstrap may adopt
|
||||
* PC identity metadata. science_state may remain unresolved;
|
||||
* any row carrying identity metadata is thereafter immutable. */
|
||||
val adoptsInitialIdentity = localAdj != incomingAdj &&
|
||||
localAdj == listOf<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 sameProfile=row[1]==recording&&row[2]==tracking&&row[3]?.toInt()==fields
|
||||
val accept = when {
|
||||
|
|
@ -1328,7 +1355,7 @@ class TrainlogRepository(
|
|||
db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revision)).use{it.moveToFirst()} -> false
|
||||
else -> return ExerciseProfileStateImportResult.Conflict(id)
|
||||
}
|
||||
if(!accept){skipped++;continue}
|
||||
if(!accept){if(adoptsInitialIdentity)updated++ else skipped++;continue}
|
||||
val knownCount=db.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",arrayOf(row[0])).use{it.moveToFirst();it.getInt(0)}
|
||||
val additions=revisions.count { revisionRow -> db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revisionRow[0] as String)).use{!it.moveToFirst()} }
|
||||
if(knownCount+additions>PROFILE_HISTORY_MAX)return ExerciseProfileStateImportResult.Invalid("Historique profile-state hors limite : $id")
|
||||
|
|
@ -1572,6 +1599,248 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports inert desktop proposals only after catalog/profile/equipment
|
||||
* prerequisites have been reconciled by [SyncCatalogInbox].
|
||||
*/
|
||||
fun applyAiSessionDraftsJson(json: String): AiSessionDraftImportResult {
|
||||
if (!hasStrictJsonShape(json))
|
||||
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA JSON invalide.")
|
||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA JSON invalide.")
|
||||
}
|
||||
if (!root.hasExactKeys(setOf("format", "version", "generated_at", "drafts")) ||
|
||||
root.value("format") != "trainlog-ai-session-drafts" ||
|
||||
!root.value("version").isExactJsonInteger(1) ||
|
||||
!root.value("generated_at").isNonemptyJsonString() || root.value("drafts") !is JSONArray ||
|
||||
!isUtcTimestamp(root.optString("generated_at"))) {
|
||||
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA v1 non supporté.")
|
||||
}
|
||||
val drafts = root.getJSONArray("drafts")
|
||||
if (drafts.length() > MAX_AI_DRAFTS)
|
||||
return AiSessionDraftImportResult.Invalid("Trop de brouillons IA.")
|
||||
|
||||
val db = database.writableDatabase
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
val seenDrafts = mutableSetOf<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():
|
||||
ActiveDraftLoadResult =
|
||||
try {
|
||||
|
|
@ -6604,6 +6873,16 @@ private const val DESKTOP_LEG_PRESS_CANONICAL_ID =
|
|||
private val EXERCISE_ID_V4_PATTERN = Regex(
|
||||
"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||
)
|
||||
private val AI_DRAFT_ID_V4_PATTERN = Regex(
|
||||
"^aid_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||
)
|
||||
private val SESSION_ENTRY_ID_V4_PATTERN = Regex(
|
||||
"^sxe_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||
)
|
||||
private const val MAX_AI_DRAFTS = 256
|
||||
private const val MAX_AI_DRAFT_ENTRIES = 64
|
||||
private const val MAX_AI_TARGET_SETS = 99
|
||||
private const val MAX_AI_TARGET_REPS = 999
|
||||
|
||||
private class TrainlogDatabaseHelper(
|
||||
private val appContext: Context,
|
||||
|
|
@ -6612,7 +6891,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
16,
|
||||
17,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -6653,6 +6932,7 @@ private class TrainlogDatabaseHelper(
|
|||
createTrainingFeedbackTables(db)
|
||||
createTrainingFeedbackRevisionTables(db)
|
||||
createExerciseProfileStateTable(db, seedLegacy = false)
|
||||
createAiSessionDraftTables(db)
|
||||
seedEquipment(db)
|
||||
}
|
||||
|
||||
|
|
@ -6780,6 +7060,14 @@ private class TrainlogDatabaseHelper(
|
|||
version = 16
|
||||
}
|
||||
|
||||
if (version < 17 && newVersion >= 17) {
|
||||
/* WHY: PC-authored proposals must survive replay without entering
|
||||
* the singleton capture draft. CONTRACT: v17 is additive.
|
||||
* INVARIANT: existing active and completed sessions are untouched. */
|
||||
createAiSessionDraftTables(db)
|
||||
version = 17
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
"Unsupported Android DB upgrade " +
|
||||
|
|
@ -6788,6 +7076,33 @@ private class TrainlogDatabaseHelper(
|
|||
}
|
||||
}
|
||||
|
||||
private fun createAiSessionDraftTables(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +
|
||||
"draft_id TEXT PRIMARY KEY,created_at TEXT NOT NULL,planned_for TEXT," +
|
||||
"title TEXT,notes TEXT,state TEXT NOT NULL DEFAULT 'pending' " +
|
||||
"CHECK(state IN('pending','started','deleted')),state_changed_at TEXT NOT NULL);",
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS ai_session_draft_entries(" +
|
||||
"id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL REFERENCES ai_session_drafts(draft_id) ON DELETE CASCADE," +
|
||||
"entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL CHECK(position>=0)," +
|
||||
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT," +
|
||||
"recording_mode TEXT NOT NULL CHECK(recording_mode='sets')," +
|
||||
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration'))," +
|
||||
"data_fields INTEGER NOT NULL CHECK(data_fields>=0 AND (data_fields & ~3)=0)," +
|
||||
"equipment_row_id INTEGER REFERENCES equipment(id) ON DELETE RESTRICT," +
|
||||
"load_mode TEXT NOT NULL CHECK(load_mode IN('none','external'))," +
|
||||
"rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400)," +
|
||||
"target_sets INTEGER NOT NULL CHECK(target_sets BETWEEN 1 AND 99)," +
|
||||
"target_reps INTEGER CHECK(target_reps BETWEEN 1 AND 999)," +
|
||||
"target_duration_seconds INTEGER CHECK(target_duration_seconds BETWEEN 1 AND 86400)," +
|
||||
"target_weight_kg REAL CHECK(target_weight_kg>0.0)," +
|
||||
"CHECK((target_reps IS NULL)!=(target_duration_seconds IS NULL))," +
|
||||
"UNIQUE(draft_id,position));",
|
||||
)
|
||||
}
|
||||
|
||||
private fun createBodyZoneTables(db: SQLiteDatabase) {
|
||||
/* CONTRACT: canonical zone definitions remain in the asset; SQLite
|
||||
* stores only direct exercise relations and role cardinality. */
|
||||
|
|
|
|||
|
|
@ -104,6 +104,20 @@ data class ActiveSessionDraft(
|
|||
val updatedAt: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* A desktop-authored proposal is inert until the user explicitly starts it.
|
||||
* INVARIANT: entries contain targets only; performed values remain owned by
|
||||
* [ActiveSessionDraft] after an atomic start operation.
|
||||
*/
|
||||
data class AiSessionDraft(
|
||||
val draftId: String,
|
||||
val createdAt: String,
|
||||
val plannedFor: String? = null,
|
||||
val title: String? = null,
|
||||
val notes: String? = null,
|
||||
val entries: List<SessionExerciseDraft>,
|
||||
)
|
||||
|
||||
data class SessionSummary(
|
||||
val sessionId: String,
|
||||
val startedAt: String,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ sealed interface AppRoute {
|
|||
data object Sessions : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object SessionEditor : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||
|
|
@ -157,6 +158,7 @@ internal fun routeTitle(route: AppRoute): String = when (route) {
|
|||
AppRoute.Sessions -> "Séances"
|
||||
AppRoute.SessionEditor -> "Séance en cours"
|
||||
AppRoute.SessionGenerator -> "Programmer une séance"
|
||||
AppRoute.AiSessionDrafts -> "Brouillons"
|
||||
AppRoute.CompletedSessions -> "Séances effectuées"
|
||||
is AppRoute.SessionDetail -> "Détail de la séance"
|
||||
is AppRoute.SessionCorrection -> "Modifier la séance"
|
||||
|
|
|
|||
|
|
@ -7,21 +7,27 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.labfytools.trainlog.data.CreateEquipmentResult
|
||||
import com.labfytools.trainlog.data.CatalogInboxResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.EquipmentCatalogEntry
|
||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||
import com.labfytools.trainlog.data.KnowledgeConfidence
|
||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||
import com.labfytools.trainlog.data.StartAiSessionDraftResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
||||
@Composable
|
||||
fun SessionsHub(
|
||||
draft: ActiveSessionDraft?,
|
||||
pendingAiDraftCount: Int,
|
||||
onResume: () -> Unit,
|
||||
onManual: () -> Unit,
|
||||
onDrafts: () -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
|
|
@ -32,11 +38,94 @@ fun SessionsHub(
|
|||
}
|
||||
TrainlogFrame("Préparer") {
|
||||
TrainlogAction("Nouvelle séance manuelle", if (draft == null) "Créer explicitement un brouillon de séance." else "Ouvrir la séance en cours sans l'écraser.", onManual)
|
||||
TrainlogAction(
|
||||
"Brouillons",
|
||||
"$pendingAiDraftCount séance(s) préparée(s) · consulter les propositions importées depuis le PC.",
|
||||
onDrafts,
|
||||
)
|
||||
}
|
||||
TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AiSessionDraftsScreen(
|
||||
repository: TrainlogRepository,
|
||||
externalRevision: Int,
|
||||
onPendingChanged: () -> Unit = {},
|
||||
onStarted: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
var message by remember { mutableStateOf<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
|
||||
fun LatestMaximaScreen(repository: TrainlogRepository) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
|
|
@ -155,13 +244,13 @@ private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics) = when (value
|
|||
@Composable
|
||||
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val context = LocalContext.current
|
||||
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null) {
|
||||
authorized = inbox.saveTreeUri(uri)
|
||||
message = if (authorized) "Dossier d'échange autorisé." else "Autorisation du dossier impossible."
|
||||
if (authorized) {
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
authorized = inbox.hasFolderAccess()
|
||||
message = if (authorized) "Accès fichiers autorisé." else "Accès fichiers toujours requis."
|
||||
if (authorized) {
|
||||
when (val result = inbox.importPcCatalog()) {
|
||||
is CatalogInboxResult.Imported -> {
|
||||
message = "Dossier autorisé · ${result.imported} exercice(s) importé(s), ${result.reconciled} réconcilié(s)."
|
||||
|
|
@ -171,13 +260,12 @@ fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
|||
CatalogInboxResult.FolderNotAuthorized -> message = "Le dossier n'est plus autorisé."
|
||||
is CatalogInboxResult.Error -> message = result.message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TrainlogScreen("Paramètres") {
|
||||
TrainlogFrame("DOSSIER D'ÉCHANGE") {
|
||||
TrainlogInfo(if (authorized) "Téléchargements/Trainlog autorisé." else "Aucun dossier Trainlog autorisé.", if (authorized) colors.success else colors.warning)
|
||||
TrainlogAction(if (authorized) "Changer de dossier" else "Autoriser le dossier", "Choisir le dossier d'échange avec le sélecteur Android.", { launcher.launch(null) })
|
||||
TrainlogInfo(if (authorized) "Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé" else "Accès fichiers requis", if (authorized) colors.success else colors.warning)
|
||||
TrainlogAction(if (authorized) "Ouvrir les réglages d'accès" else "Autoriser", "Autoriser l'accès au dossier Trainlog dans Android.", { launcher.launch(directStoragePermissionIntent(context)) })
|
||||
if (authorized) TrainlogAction("Relire le catalogue PC", "Appliquer explicitement le catalogue présent dans le dossier autorisé.", {
|
||||
when (val result = inbox.importPcCatalog()) {
|
||||
is CatalogInboxResult.Imported -> { message = "Catalogue relu · ${result.imported} nouveau(x), ${result.reconciled} réconcilié(s)."; onCatalogChanged() }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ package com.labfytools.trainlog.ui
|
|||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -18,7 +19,8 @@ import com.labfytools.trainlog.data.SyncExportResult
|
|||
import com.labfytools.trainlog.data.SyncReceiptResult
|
||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.SyncRequestResult
|
||||
import com.labfytools.trainlog.data.logExchangeMediaStore
|
||||
import com.labfytools.trainlog.data.logDirectExchange
|
||||
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -31,19 +33,19 @@ internal suspend fun publishBundleAndRequest(
|
|||
exporter: SyncExporter,
|
||||
requestOutbox: SyncRequestOutbox,
|
||||
): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||
logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.begin")
|
||||
when (val opened = exporter.openSafSnapshot()) {
|
||||
is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Error ->
|
||||
logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
|
||||
when (val opened = exporter.openStorageSnapshot()) {
|
||||
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error ->
|
||||
SyncRequestResult.Error("Préparation Android → PC : ${opened.message}")
|
||||
is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Ready ->
|
||||
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready ->
|
||||
when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
|
||||
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
|
||||
is SyncExportResult.Error -> SyncRequestResult.Error(
|
||||
"Préparation Android → PC : ${publication.message}",
|
||||
)
|
||||
is SyncExportResult.Exported -> {
|
||||
logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.success")
|
||||
logExchangeMediaStore("trainlog-sync-request-v1.json", "coordinator.request.begin")
|
||||
logDirectExchange("SYNC_BUNDLE", "coordinator.export.success")
|
||||
logDirectExchange("trainlog-sync-request-v1.json", "coordinator.request.begin")
|
||||
requestOutbox.requestSync(opened.snapshot)
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +63,7 @@ fun SyncScreen(
|
|||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
var status by
|
||||
remember {
|
||||
|
|
@ -208,63 +211,32 @@ fun SyncScreen(
|
|||
null
|
||||
}
|
||||
|
||||
val folderLauncher =
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract =
|
||||
ActivityResultContracts
|
||||
.OpenDocumentTree(),
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
) {
|
||||
uri ->
|
||||
if (uri != null) {
|
||||
val saved =
|
||||
inbox.saveTreeUri(
|
||||
uri
|
||||
)
|
||||
|
||||
folderAuthorized =
|
||||
saved
|
||||
|
||||
success =
|
||||
saved
|
||||
|
||||
status =
|
||||
if (saved) {
|
||||
"Dossier Trainlog autorisé."
|
||||
} else {
|
||||
"Autorisation du dossier impossible."
|
||||
}
|
||||
|
||||
if (saved) {
|
||||
coroutineScope.launch {
|
||||
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
|
||||
is CatalogInboxResult.Imported -> {
|
||||
success = true
|
||||
|
||||
status =
|
||||
(
|
||||
"Dossier autorisé · catalogue PC : " +
|
||||
"${result.imported} nouveau(x), " +
|
||||
"${result.reconciled} réconcilié(s)."
|
||||
)
|
||||
|
||||
onCatalogChanged()
|
||||
}
|
||||
|
||||
CatalogInboxResult.FileNotFound -> {
|
||||
success = true
|
||||
|
||||
status =
|
||||
"Dossier autorisé · aucun catalogue PC reçu."
|
||||
}
|
||||
|
||||
CatalogInboxResult.FolderNotAuthorized,
|
||||
is CatalogInboxResult.Error -> {
|
||||
/* Keep permission state. */
|
||||
}
|
||||
}
|
||||
folderAuthorized = inbox.hasFolderAccess()
|
||||
success = folderAuthorized
|
||||
status = if (folderAuthorized) {
|
||||
"Accès fichiers autorisé · Documents/Trainlog prêt."
|
||||
} else {
|
||||
"Accès fichiers requis. Activez l'autorisation dans Android."
|
||||
}
|
||||
if (folderAuthorized) {
|
||||
coroutineScope.launch {
|
||||
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
|
||||
is CatalogInboxResult.Imported -> {
|
||||
status = "Accès autorisé · catalogue PC : ${result.imported} nouveau(x), " +
|
||||
"${result.reconciled} réconcilié(s)."
|
||||
onCatalogChanged()
|
||||
}
|
||||
CatalogInboxResult.FileNotFound ->
|
||||
status = "Accès autorisé · aucun catalogue PC reçu."
|
||||
CatalogInboxResult.FolderNotAuthorized -> folderAuthorized = false
|
||||
is CatalogInboxResult.Error -> status = result.message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogScreen(
|
||||
|
|
@ -305,7 +277,7 @@ fun SyncScreen(
|
|||
success = false
|
||||
|
||||
status =
|
||||
"Autorisez d'abord Téléchargements/Trainlog."
|
||||
"Accès fichiers requis. Autorisez d'abord Documents/Trainlog."
|
||||
|
||||
return@TrainlogAction
|
||||
}
|
||||
|
|
@ -347,7 +319,7 @@ fun SyncScreen(
|
|||
) {
|
||||
if (folderAuthorized) {
|
||||
TrainlogInfo(
|
||||
"Téléchargements/Trainlog autorisé.",
|
||||
"Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé",
|
||||
color =
|
||||
colors.success,
|
||||
)
|
||||
|
|
@ -355,17 +327,11 @@ fun SyncScreen(
|
|||
|
||||
TrainlogAction(
|
||||
label =
|
||||
if (folderAuthorized) {
|
||||
"Changer le dossier Trainlog"
|
||||
} else {
|
||||
"Autoriser le dossier Trainlog"
|
||||
},
|
||||
if (folderAuthorized) "Ouvrir les réglages d'accès" else "Autoriser l'accès au dossier Trainlog",
|
||||
description =
|
||||
"Choisir Téléchargements/Trainlog.",
|
||||
"Autoriser l'accès pour gérer tous les fichiers dans Android.",
|
||||
onClick = {
|
||||
folderLauncher.launch(
|
||||
null
|
||||
)
|
||||
permissionLauncher.launch(directStoragePermissionIntent(context))
|
||||
},
|
||||
accent =
|
||||
colors.warning,
|
||||
|
|
@ -397,7 +363,7 @@ fun SyncScreen(
|
|||
success = false
|
||||
|
||||
status =
|
||||
"Autorisez d'abord Téléchargements/Trainlog."
|
||||
"Accès fichiers requis pour Documents/Trainlog."
|
||||
}
|
||||
|
||||
CatalogInboxResult.FileNotFound -> {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
|
||||
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
|
||||
val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
||||
val pendingAiDraftCount = remember(draftRevision, catalogRevision) {
|
||||
repository.listAiSessionDrafts().size
|
||||
}
|
||||
|
||||
fun open(route: AppRoute) { navigationController.open(route) }
|
||||
fun openSection(section: AppSection) { navigationController.openSection(section) }
|
||||
|
|
@ -94,7 +97,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
{ open(AppRoute.Statistics) },
|
||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
||||
)
|
||||
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) })
|
||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) })
|
||||
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
|
||||
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."
|
||||
|
|
@ -105,6 +109,12 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
* Back and drawer navigation. */
|
||||
openSection(AppSection.SESSIONS)
|
||||
})
|
||||
AppRoute.AiSessionDrafts -> AiSessionDraftsScreen(
|
||||
repository = repository,
|
||||
externalRevision = catalogRevision,
|
||||
onPendingChanged = { draftRevision++ },
|
||||
onStarted = { open(AppRoute.SessionEditor) },
|
||||
)
|
||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
||||
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) }
|
||||
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import androidx.compose.foundation.text.BasicTextField
|
|||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -377,14 +378,24 @@ fun DestructiveConfirmationDialog(
|
|||
detail: String,
|
||||
confirmLabel: String,
|
||||
onCancel: () -> Unit,
|
||||
dismissOnClickOutside: Boolean = false,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(title) },
|
||||
text = { Text(detail) },
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } },
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } },
|
||||
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false),
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
|
||||
) { Text(confirmLabel) }
|
||||
},
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = dismissOnClickOutside,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(16, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(17, scalar(db, "PRAGMA user_version"))
|
||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(16, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(17, scalar(db, "PRAGMA user_version"))
|
||||
/* v13 intentionally adds the frozen machine identities and
|
||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||
|
|
|
|||
|
|
@ -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.OPEN_READONLY).use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(16, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(17, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
||||
assertEquals(16, scalarInt(database, "PRAGMA user_version;"))
|
||||
assertEquals(17, scalarInt(database, "PRAGMA user_version;"))
|
||||
assertEquals("ok", scalarString(database, "PRAGMA integrity_check;"))
|
||||
database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class RealAndroidV14FeedbackRevisionMigrationTest {
|
|||
}
|
||||
TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() }
|
||||
SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db->
|
||||
assertEquals(16,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())}
|
||||
assertEquals(17,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())}
|
||||
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_state WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
|
||||
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_revisions WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
|
||||
assertEquals(before.first,values(db,"SELECT entry_id FROM draft_session_exercises ORDER BY entry_id"));assertEquals(before.second,values(db,"SELECT feedback_id||'|'||observed_at||'|'||raw_text FROM draft_exercise_feedback ORDER BY feedback_id"))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.labfytools.trainlog.data
|
|||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.BodyObservationDraft
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
|
|
@ -1669,7 +1668,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(16, cursor.getInt(0))
|
||||
assertEquals(17, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -1796,7 +1795,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(16, cursor.getInt(0))
|
||||
assertEquals(17, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
@ -2287,12 +2286,11 @@ class TrainlogRepositoryDraftTest {
|
|||
v3File.writeText(malformedV3.toString())
|
||||
File(directory, "trainlog-pc-mobile-export-v2.json").writeText(validV2.toString())
|
||||
val inbox = SyncCatalogInbox(context, destination)
|
||||
val documentDirectory = DocumentFile.fromFile(directory)
|
||||
val error = inbox.importPcSessionsFromDirectoryForTest(documentDirectory)
|
||||
val error = inbox.importPcSessionsFromDirectoryForTest(directory)
|
||||
assertTrue(error!!.contains("started_at"))
|
||||
assertTrue(destination.listSessions().isEmpty())
|
||||
assertTrue(v3File.delete())
|
||||
assertEquals(null, inbox.importPcSessionsFromDirectoryForTest(documentDirectory))
|
||||
assertEquals(null, inbox.importPcSessionsFromDirectoryForTest(directory))
|
||||
assertEquals(1, destination.listSessions().size)
|
||||
} finally {
|
||||
directory.deleteRecursively()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.data.SaveFeedbackResult
|
||||
import com.labfytools.trainlog.data.SaveSessionResult
|
||||
|
|
@ -9,21 +8,23 @@ import com.labfytools.trainlog.data.SyncExporter
|
|||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.SyncRequestResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.ExchangeSafDirectory
|
||||
import com.labfytools.trainlog.data.ExchangeSafDocument
|
||||
import com.labfytools.trainlog.data.EXCHANGE_MEDIASTORE_LOG_TAG
|
||||
import com.labfytools.trainlog.data.ExchangeSafPublisher
|
||||
import com.labfytools.trainlog.data.ExchangeSafSnapshotResult
|
||||
import com.labfytools.trainlog.data.DirectExchangeDirectoryAccess
|
||||
import com.labfytools.trainlog.data.DirectExchangeDocument
|
||||
import com.labfytools.trainlog.data.DIRECT_EXCHANGE_LOG_TAG
|
||||
import com.labfytools.trainlog.data.DirectExchangePublisher
|
||||
import com.labfytools.trainlog.data.DirectExchangeSnapshotResult
|
||||
import com.labfytools.trainlog.model.SessionDraft
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import java.util.UUID
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONArray
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
|
@ -134,27 +135,20 @@ class SyncBundlePublicationTest {
|
|||
val writesBeforeSecondClick = directory.writeCounts.toMap()
|
||||
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
|
||||
assertEquals(3, directory.enumerationCount)
|
||||
val phases = ShadowLog.getLogsForTag(EXCHANGE_MEDIASTORE_LOG_TAG).map { it.msg }
|
||||
val phases = ShadowLog.getLogsForTag(DIRECT_EXCHANGE_LOG_TAG).map { it.msg }
|
||||
assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") })
|
||||
val snapshotPhases = phases.filter { it.startsWith("SAF_TREE snapshot.success ") }
|
||||
val snapshotPhases = phases.filter { it.startsWith("DIRECT_STORAGE snapshot.success ") }
|
||||
assertEquals(1, snapshotPhases.size)
|
||||
assertTrue("SAF snapshot ran on main: $snapshotPhases",
|
||||
snapshotPhases.none { it.contains("thread=main") })
|
||||
val writeBegins = phases.filter { it.contains(" saf.write.begin ") }
|
||||
val writeBegins = phases.filter { it.contains(" direct.write.success") }
|
||||
assertEquals(required.size, writeBegins.size)
|
||||
assertTrue("SAF write ran on main: $writeBegins",
|
||||
writeBegins.none { it.contains("thread=main") })
|
||||
val bodyResolve = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.resolve.existing ") }
|
||||
val bodyWrite = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.begin ") }
|
||||
val bodyWritten = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.success ") }
|
||||
val bodyWritten = phases.indexOfFirst { it.startsWith("BODY_ZONES direct.write.success") }
|
||||
val requestStart = phases.indexOfFirst { it == "SYNC_REQUEST coordinator.request.begin" }
|
||||
assertTrue("production coordinator did not resolve existing BODY ZONES", bodyResolve >= 0)
|
||||
assertTrue("BODY ZONES must open only after exact resolution", bodyWrite > bodyResolve)
|
||||
assertTrue("BODY ZONES write did not complete", bodyWritten > bodyWrite)
|
||||
assertTrue("BODY ZONES write did not complete", bodyWritten >= 0)
|
||||
assertTrue("request must be published after the complete export", requestStart > bodyWritten)
|
||||
val beforeBodyOpen = phases.subList(bodyResolve + 1, bodyWrite)
|
||||
assertTrue("existing BODY ZONES unexpectedly created: $beforeBodyOpen",
|
||||
beforeBodyOpen.none { it.startsWith("BODY_ZONES saf.create.") })
|
||||
assertTrue("outbound path must not call MediaStore insert/update/pending: $phases",
|
||||
phases.none { it.contains(" insert.") || it.contains(" publish.") || it.contains("IS_PENDING") })
|
||||
assertEquals(bodyCreatesBefore, directory.createCount("trainlog-exercise-body-zones-v1.json"))
|
||||
|
|
@ -173,8 +167,8 @@ class SyncBundlePublicationTest {
|
|||
}
|
||||
|
||||
@Test fun `created exact document updates transaction index without rescan`() {
|
||||
val publisher = ExchangeSafPublisher { directory }
|
||||
val opened = publisher.snapshot() as ExchangeSafSnapshotResult.Ready
|
||||
val publisher = DirectExchangePublisher { directory }
|
||||
val opened = publisher.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||
assertEquals(1, directory.enumerationCount)
|
||||
assertEquals(null, opened.snapshot.writeJson("foo.json", "first"))
|
||||
assertEquals(null, opened.snapshot.writeJson("foo.json", "second"))
|
||||
|
|
@ -185,34 +179,135 @@ class SyncBundlePublicationTest {
|
|||
assertEquals(0, directory.canonicalCount("foo (1).json"))
|
||||
}
|
||||
|
||||
@Test fun `empty directory creates exact canonical name and final content`() {
|
||||
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||
|
||||
assertEquals(null, opened.snapshot.writeJson("artifact.json", "new export"))
|
||||
|
||||
assertEquals(1, directory.canonicalCount("artifact.json"))
|
||||
assertEquals("new export", directory.read("artifact.json"))
|
||||
assertFalse(directory.names().any { it.matches(Regex("artifact \\(\\d+\\)\\.json")) })
|
||||
}
|
||||
|
||||
@Test fun `ten publications rewrite one canonical document without numbered copies`() {
|
||||
repeat(10) { publication ->
|
||||
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||
assertEquals(null, opened.snapshot.writeJson("artifact.json", "export-$publication"))
|
||||
}
|
||||
|
||||
assertEquals(1, directory.createCount("artifact.json"))
|
||||
assertEquals(10, directory.writeCount("artifact.json"))
|
||||
assertEquals("export-9", directory.read("artifact.json"))
|
||||
assertEquals(setOf("artifact.json"), directory.names())
|
||||
}
|
||||
|
||||
@Test fun `historical numbered copies are never selected instead of canonical`() {
|
||||
directory.seed("artifact (1).json", "history one")
|
||||
directory.seed("artifact (2).json", "history two")
|
||||
directory.seed("artifact.json", "old canonical")
|
||||
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||
|
||||
assertEquals(null, opened.snapshot.writeJson("artifact.json", "new canonical"))
|
||||
|
||||
assertEquals("new canonical", directory.read("artifact.json"))
|
||||
assertEquals("history one", directory.read("artifact (1).json"))
|
||||
assertEquals("history two", directory.read("artifact (2).json"))
|
||||
assertEquals(0, directory.writeCount("artifact (1).json"))
|
||||
assertEquals(0, directory.writeCount("artifact (2).json"))
|
||||
}
|
||||
|
||||
@Test fun `historical numbered copy alone does not become canonical target`() {
|
||||
directory.seed("artifact (1).json", "history")
|
||||
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||
|
||||
assertEquals(null, opened.snapshot.writeJson("artifact.json", "canonical"))
|
||||
|
||||
assertEquals("canonical", directory.read("artifact.json"))
|
||||
assertEquals("history", directory.read("artifact (1).json"))
|
||||
}
|
||||
|
||||
@Test fun `provider rename fails explicitly before writing conflict copy`() {
|
||||
directory.createdNameOverride = "artifact (1).json"
|
||||
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||
|
||||
assertEquals(
|
||||
"DIRECT_STORAGE_CANONICAL_NAME_CONFLICT: attendu=artifact.json obtenu=artifact (1).json",
|
||||
opened.snapshot.writeJson("artifact.json", "must not be written"),
|
||||
)
|
||||
|
||||
assertEquals(0, directory.writeCount("artifact (1).json"))
|
||||
assertEquals(0, directory.canonicalCount("artifact.json"))
|
||||
}
|
||||
|
||||
@Test fun `raw exact lookup recovers canonical omitted from ordinary discovery`() {
|
||||
directory.seedRawOnly("artifact.json", "old canonical")
|
||||
directory.seed("artifact (1).json", "history")
|
||||
repeat(10) { publication ->
|
||||
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||
assertEquals(null, opened.snapshot.writeJson("artifact.json", "export-$publication"))
|
||||
}
|
||||
assertEquals(0, directory.createCount("artifact.json"))
|
||||
assertEquals(10, directory.writeCount("artifact.json"))
|
||||
assertEquals("export-9", directory.read("artifact.json"))
|
||||
assertEquals("history", directory.read("artifact (1).json"))
|
||||
assertFalse(directory.names().contains("artifact (2).json"))
|
||||
}
|
||||
|
||||
@Test fun `multiple raw exact canonical rows fail deterministically`() {
|
||||
directory.seedRawOnly("artifact.json", "one")
|
||||
directory.rawExactDuplicates = 2
|
||||
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready
|
||||
assertEquals(
|
||||
"DIRECT_STORAGE_DUPLICATE_CANONICAL: nom=artifact.json count=2",
|
||||
opened.snapshot.writeJson("artifact.json", "new"),
|
||||
)
|
||||
assertEquals(0, directory.writeCount("artifact.json"))
|
||||
}
|
||||
|
||||
private fun readCanonical(name: String): String = directory.read(name)
|
||||
|
||||
private class FakeSafDirectory : ExchangeSafDirectory {
|
||||
private class FakeSafDirectory : DirectExchangeDirectoryAccess {
|
||||
private val contents = linkedMapOf<String, ByteArray>()
|
||||
private val creates = mutableMapOf<String, Int>()
|
||||
val writeCounts = mutableMapOf<String, Int>()
|
||||
var enumerationCount = 0
|
||||
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 seedRawOnly(name: String, value: String) {
|
||||
seed(name, value)
|
||||
discoveryHidden += name
|
||||
}
|
||||
fun read(name: String): String = contents.getValue(name).toString(Charsets.UTF_8)
|
||||
fun canonicalCount(name: String): Int = if (contents.containsKey(name)) 1 else 0
|
||||
fun createCount(name: String): Int = creates[name] ?: 0
|
||||
fun writeCount(name: String): Int = writeCounts[name] ?: 0
|
||||
fun names(): Set<String> = contents.keys
|
||||
|
||||
override fun listDirectChildren(): List<ExchangeSafDocument> {
|
||||
override fun listDirectChildren(): List<DirectExchangeDocument> {
|
||||
enumerationCount++
|
||||
return contents.keys.map(::document)
|
||||
return contents.keys.filterNot(discoveryHidden::contains).map(::document)
|
||||
}
|
||||
|
||||
override fun createJson(displayName: String): ExchangeSafDocument {
|
||||
check(!contents.containsKey(displayName))
|
||||
override fun findExact(displayName: String): List<DirectExchangeDocument> =
|
||||
if (contents.containsKey(displayName)) {
|
||||
List(rawExactDuplicates) { document(displayName) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
override fun createJson(displayName: String): DirectExchangeDocument {
|
||||
val actualName = createdNameOverride ?: displayName
|
||||
check(!contents.containsKey(actualName))
|
||||
creates[displayName] = createCount(displayName) + 1
|
||||
contents[displayName] = byteArrayOf()
|
||||
return document(displayName)
|
||||
contents[actualName] = byteArrayOf()
|
||||
return document(actualName)
|
||||
}
|
||||
|
||||
override fun openForRewrite(document: ExchangeSafDocument): OutputStream =
|
||||
override fun openForRewrite(document: DirectExchangeDocument): OutputStream =
|
||||
object : ByteArrayOutputStream() {
|
||||
override fun close() {
|
||||
super.close()
|
||||
|
|
@ -221,8 +316,8 @@ class SyncBundlePublicationTest {
|
|||
}
|
||||
}
|
||||
|
||||
private fun document(name: String) = ExchangeSafDocument(
|
||||
Uri.parse("content://trainlog-test/tree/Download%2FTrainlog/document/$name"),
|
||||
private fun document(name: String) = DirectExchangeDocument(
|
||||
File("/storage/emulated/0/Documents/Trainlog", name),
|
||||
name,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,23 +25,51 @@ historical occurrence values; later local edits and peer reconciliation advance
|
|||
lineage explicitly inside the same repository transaction as the current-profile
|
||||
change. The insert trigger owns only initial root creation.
|
||||
|
||||
Android schema v17 adds the separate `trainlog-ai-session-drafts` V1 proposal
|
||||
collection. It is not a second active workout: the existing
|
||||
`active_session_draft(id=1)` remains the exactly-one durable capture draft.
|
||||
The companion is imported only after definitions, aliases, profile state,
|
||||
catalogue, sessions, and equipment associations have been reconciled. It
|
||||
accepts at most 256 strict, idempotent `aid_<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
|
||||
before it exposes `trainlog-sync-request-v1.json`. The bundle contains mobile
|
||||
V3, mobile equipment definitions, equipment associations V2, exercise aliases
|
||||
V1, BODY ZONES V1, Training Feedback V2, and Exercise Profile State V1. The
|
||||
request is not written if any required publication fails. The persisted,
|
||||
user-authorized `Download/Trainlog` SAF tree is the canonical authority for
|
||||
both inbound and outbound exchange: MediaStore ownership and app-scoped row
|
||||
visibility are not consulted. Exact display names are rewritten through their
|
||||
SAF document URI; absence creates the exact name once, and multiple exact names
|
||||
fail conservatively. Conflict copies such as `name (1).json` are historical
|
||||
objects, never aliases of `name.json`, and are neither selected nor deleted.
|
||||
One synchronization enumerates the direct SAF children once, builds a bounded
|
||||
exact-name multimap, and shares that transaction-local snapshot across all
|
||||
outbound artifacts and the request. Newly created documents update that map.
|
||||
The next synchronization enumerates again so PC/MTP changes are never hidden by
|
||||
a long-lived cache. Snapshot, create and stream operations run on the I/O
|
||||
dispatcher; Compose only launches the suspend coordinator and applies results.
|
||||
request is not written if any required publication fails. After the user
|
||||
enables `MANAGE_EXTERNAL_STORAGE` in Android settings, the fixed direct path
|
||||
`/storage/emulated/0/Documents/Trainlog` is the sole authority for inbound and
|
||||
outbound exchange. Trainlog creates it if absent and requires a readable,
|
||||
writable directory. No tree URI, persisted URI permission, directory picker,
|
||||
`DocumentFile`, `DocumentsContract`, or MediaStore row participates.
|
||||
|
||||
Each JSON payload is written to a uniquely named temporary file in that same
|
||||
directory, flushed and fsync'd, then moved over the exact canonical filename
|
||||
with atomic replacement where the filesystem supports it. This prevents SAF
|
||||
provider suffixes. Existing `name (N).json` files are unrelated historical
|
||||
objects and are never selected, overwritten, moved, or deleted. The next sync
|
||||
enumerates again so PC/MTP changes are not hidden by a long-lived cache.
|
||||
|
||||
When permission is absent, synchronization fails before publication and the UI
|
||||
offers **Autoriser l'accès au dossier Trainlog**. This opens the package-scoped
|
||||
all-files settings screen, falling back to the global all-files screen only if
|
||||
the OEM exposes no package activity. Android itself grants the permission; the
|
||||
application never does. Legacy SAF preferences are ignored and removed without
|
||||
touching `Download/Trainlog` or its recovery backup.
|
||||
|
||||
V3 preserves ordinary plan metadata atomically with occurrence identity,
|
||||
equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are
|
||||
|
|
@ -475,7 +503,7 @@ bo_<uuid-v4>
|
|||
Android maintains:
|
||||
|
||||
```text
|
||||
Download/Trainlog/trainlog-mobile-export-v3.json
|
||||
Documents/Trainlog/trainlog-mobile-export-v3.json
|
||||
```
|
||||
|
||||
The V3 snapshot is refreshed after relevant local changes, including exercise,
|
||||
|
|
@ -511,16 +539,17 @@ exact transferred completed rows are exported after finalization.
|
|||
|
||||
## 10. PC catalog access
|
||||
|
||||
PC-created files are accessed through a persistent Storage Access Framework
|
||||
grant.
|
||||
PC-created files are accessed directly after the user enables Android's
|
||||
all-files access for Trainlog.
|
||||
|
||||
The selected folder must be:
|
||||
|
||||
```text
|
||||
Download/Trainlog
|
||||
Documents/Trainlog
|
||||
```
|
||||
|
||||
The Sync screen always permits changing the stored folder selection.
|
||||
The Sync screen always exposes the system authorization settings. There is no
|
||||
stored folder selection.
|
||||
|
||||
No application-data reset is required to fix a wrong folder choice.
|
||||
|
||||
|
|
@ -538,10 +567,9 @@ Android writes:
|
|||
trainlog-sync-request-v1.json
|
||||
```
|
||||
|
||||
The request is resolved and rewritten through the same persisted SAF tree as
|
||||
the bundle. Android does not create a numbered request sibling merely because
|
||||
MediaStore cannot expose an MTP- or peer-created canonical object. The stable
|
||||
`request_id` remains the replay boundary.
|
||||
The request is replaced through the same direct `Documents/Trainlog` directory
|
||||
as the bundle. No document provider can manufacture a numbered request sibling.
|
||||
The stable `request_id` remains the replay boundary.
|
||||
|
||||
The application-start catalog import does not publish an outbound bundle.
|
||||
Exactly one explicit `Synchroniser maintenant` action invokes one export and
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Android capture client
|
|||
|
|
||||
v
|
||||
Android shared storage
|
||||
Download/Trainlog
|
||||
Documents/Trainlog
|
||||
|
|
||||
| direct MTP
|
||||
v
|
||||
|
|
@ -168,7 +168,7 @@ silently rendered as unclassified.
|
|||
|
||||
### Desktop
|
||||
|
||||
Desktop SQLite schema v17 is canonical long-term history. Its v15 -> v16
|
||||
Desktop SQLite schema v18 is canonical long-term history. Its v15 -> v16
|
||||
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
|
||||
one-time backfill copies the linked catalogue mode without changing child rows
|
||||
or stable identities. The additive v16 -> v17 migration adds deterministic
|
||||
|
|
@ -182,6 +182,19 @@ their strictly-positive contracts. `session_exercises`
|
|||
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
|
||||
occur more than once in one session without identity fusion.
|
||||
|
||||
The additive v17 -> v18 migration adds a desktop-private AI proposal inbox:
|
||||
imported headers, ordered target-only entries, an immutable `(aid_ draft_id,
|
||||
payload digest)` import identity, and a nullable successful-publication cursor.
|
||||
The cursor drains deterministic batches without deleting replay identity. It is
|
||||
intentionally not the desktop TUI's
|
||||
in-progress session state and it never creates actual sets. The separate
|
||||
Android v17 migration adds a pending AI-proposal collection and permanent
|
||||
`started`/`deleted` tombstones; the pre-existing
|
||||
`active_session_draft(id=1)` remains the one capture draft. An explicit start
|
||||
transaction copies a pending proposal's targets into that singleton and
|
||||
tombstones the proposal; a delete transaction tombstones it. This keeps replay
|
||||
idempotent without silently resurrecting user-dismissed work.
|
||||
|
||||
The additive v10 -> v11 migration creates direct exercise/body-zone relations
|
||||
and a private synchronization baseline, then seeds only stable-ID mappings
|
||||
whose decision evidence is recorded in the manifest. It never rewrites an
|
||||
|
|
@ -336,7 +349,7 @@ No GVFS/FUSE mount is required.
|
|||
Canonical exchange directory:
|
||||
|
||||
```text
|
||||
Download/Trainlog
|
||||
Documents/Trainlog
|
||||
```
|
||||
|
||||
## 7. Shared synchronization engine
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ GATE_1_TRAINLOG_FORMAT_V1=PASS/FROZEN
|
|||
GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
|
||||
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
TRAINLOG_AI_EXPORT_V1=PASS
|
||||
TRAINLOG_AI_GDRIVE_SYNC_V1=PASS
|
||||
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
|
||||
|
||||
DESKTOP_SCHEMA_V11=PASS
|
||||
ANDROID_LOCAL_DATABASE_V10=PASS
|
||||
|
|
@ -81,7 +84,7 @@ Implemented:
|
|||
F7 actions, and restored focus after overlays/routes);
|
||||
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
|
||||
minimum terminal;
|
||||
- SQLite schema v17, with stable ordered `session_exercises.entry_id`, an
|
||||
- SQLite schema v18, with stable ordered `session_exercises.entry_id`, an
|
||||
immutable occurrence-owned `tracking_mode` snapshot,
|
||||
bounded durable current-profile revision ancestry,
|
||||
occurrence-level equipment identity, and desktop-local custom-equipment
|
||||
|
|
@ -110,6 +113,35 @@ Implemented:
|
|||
- manual Android -> PC, PC -> Android, and bidirectional synchronization;
|
||||
- structured synchronization history and detail.
|
||||
|
||||
`TRAINLOG_AI_SESSION_DRAFT_V1` is implemented but awaits one real external
|
||||
Drive/Android-triggered bidirectional smoke test. The Drive source is the
|
||||
single strict `TRAINLOG_AI_SESSION_DRAFT` V1 object at
|
||||
`TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json`; after a
|
||||
transactional, idempotent desktop import its exact fetched bytes are copied to
|
||||
the archive by stable `aid_` ID. The inbox object remains available for logical
|
||||
replay and archive/status convergence.
|
||||
Desktop exports the separate `trainlog-ai-session-drafts-v1.json` companion to
|
||||
Android in deterministic batches of at most 256 unpublished proposals. It
|
||||
marks exactly a batch successfully published over MTP and retries an unchanged
|
||||
batch after publication failure; source replay identities remain permanent.
|
||||
The Android pending proposal collection is not the durable singleton
|
||||
active capture draft: explicit start copies targets only and tombstones the
|
||||
proposal in one transaction; explicit deletion tombstones it too. The 64-entry,
|
||||
99-set, 999-rep and 256-proposal limits, profile compatibility and replay rules
|
||||
are enforced. The always-visible **Brouillons** action reports the pending
|
||||
proposal count, and a successful catalogue/companion import invalidates the
|
||||
pending-screen query so newly imported proposals appear without restarting the
|
||||
application. Inbound `NONE`, `IMPORTED`, `ALREADY_IMPORTED`, `REJECTED`,
|
||||
`DRIVE_FAIL`, and `ARCHIVE_FAIL` outcomes remain visible in successful sync
|
||||
history. A transport/archive failure never rolls back a committed import.
|
||||
|
||||
The September 2026 connected-device diagnosis found that the installed APK was
|
||||
the older 2026-09-13 build: its copied local database was still schema v16 and
|
||||
had no `ai_session_drafts` table. That APK therefore could not have imported the
|
||||
Android companion. After a current APK is installed, one new synchronization is
|
||||
required to import the still-pending companion. This diagnosis is not the real
|
||||
Drive/Android acceptance smoke test, which remains pending.
|
||||
|
||||
Primary navigation is now shared with Android:
|
||||
|
||||
```text
|
||||
|
|
@ -130,7 +162,7 @@ status can advance.
|
|||
Implemented:
|
||||
|
||||
- native Kotlin/Compose application;
|
||||
- local SQLite database v11, with non-destructive v3 -> v11 migration;
|
||||
- local SQLite database v17, with non-destructive migrations through v17;
|
||||
- one durable active-session draft, Home resume and raw-form restoration;
|
||||
- `BODY_FOCUS_HOME_V1`: a compact accessible Home front/back BODY ZONES map,
|
||||
textual top-three exposure ranking and Catalogue/Statistics drill-through;
|
||||
|
|
@ -151,7 +183,8 @@ Implemented:
|
|||
- local session history/detail;
|
||||
- body measurements;
|
||||
- automatic mobile snapshot maintenance;
|
||||
- PC catalog application through a persistent SAF folder grant;
|
||||
- PC catalog application through user-enabled direct all-files access to the
|
||||
fixed `Documents/Trainlog` directory;
|
||||
- Android-triggered synchronization request;
|
||||
- synchronization receipt handling.
|
||||
|
||||
|
|
@ -189,7 +222,7 @@ The implemented read-only training-knowledge layer loads shared versioned JSON
|
|||
catalogs as the sole authored scientific source, generates the immutable C
|
||||
catalog representation, and loads the same assets on Android. It has no
|
||||
database migration, no auto-seeding, and no synchronization artifact. The
|
||||
desktop database is schema v17 and Android is schema v16. Schema v13 is additive: exercise
|
||||
desktop database is schema v18 and Android is schema v17. Schema v13 is additive: exercise
|
||||
rows own machine-facing load semantics, optional legacy equipment provenance,
|
||||
an optional scientific profile, and an explicit resolved/unresolved science
|
||||
state. Legacy equipment tables and occurrence columns remain intact.
|
||||
|
|
@ -217,7 +250,7 @@ and a C role-only query mismatch (HIGH). One bounded repair chain resolved all
|
|||
four findings; independent repair verification returned
|
||||
`FINAL_REVIEW_REPAIR_VERIFICATION=PASS` and
|
||||
`TRAINING_KNOWLEDGE_V1_ENGINEERING_REVIEW=PASS`. Fresh final validation passed:
|
||||
strict build, 42 Meson tests, eight knowledge and four temporal Python tests,
|
||||
strict build, 56 Meson tests, eight knowledge and four temporal Python tests,
|
||||
knowledge/JSON/import validators, three C17 headers, affected C knowledge and
|
||||
context tests under ASan/UBSan plus Python timestamp validation, normal and
|
||||
sanitized 12-form temporal probes, and Android 56 tests with zero failures or
|
||||
|
|
@ -237,7 +270,7 @@ visual validation or manual MTP validation is claimed for this tranche.
|
|||
Canonical exchange directory:
|
||||
|
||||
```text
|
||||
Download/Trainlog
|
||||
Documents/Trainlog
|
||||
```
|
||||
|
||||
Artifacts:
|
||||
|
|
@ -268,6 +301,15 @@ The desktop TUI and `trainlog-syncd` share `trainlog_sync_run()`. Its explicit
|
|||
inbound-then-outbound respectively. Each direction reconciles equipment
|
||||
definitions V1 before V2 artifacts that reference them.
|
||||
|
||||
After the shared engine has reached a definitive successful result (including
|
||||
publication of an Android-request receipt when applicable), the desktop runs
|
||||
`TRAINLOG_AI_EXPORT_V1` and then the external `rclone copyto` publication to
|
||||
`TrainLog Gdrive:Trainlog/AI/trainlog_ai_export_v1.json`. The TUI summary and
|
||||
`trainlog-syncd` log expose `SYNC`, `AI_EXPORT`, and `GDRIVE_UPLOAD` separately.
|
||||
An export or Drive failure never retroactively changes the successful Trainlog
|
||||
sync result. Android does not execute `rclone`; all rclone/OAuth configuration
|
||||
remains external under the user's normal `~/.config/rclone/` directory.
|
||||
|
||||
Each structured local sync-history run persists its selected `a`, `p`, or `b`
|
||||
direction. `direction inconnue` is reserved for legacy history rows whose
|
||||
direction was not recorded; it is never used for a current run.
|
||||
|
|
@ -428,13 +470,20 @@ unchanged. Live sync runs `sy_0b00dc46-d899-4865-8718-c95085a380b2` and
|
|||
second run reported zero additions/reconciliations, both stores retained the
|
||||
same 32-relation stable-ID hash, and Android application tables plus companion
|
||||
exercise states compared equal to the first pass. Android now resolves the
|
||||
exact request and every outbound artifact through the persisted authorized SAF
|
||||
tree, independently of MediaStore ownership visibility. Historical numbered
|
||||
copies remain untouched. One transaction-local exact-name snapshot is shared
|
||||
exact request and every outbound artifact through direct filesystem access to
|
||||
`Documents/Trainlog`. Historical numbered copies remain untouched. One
|
||||
transaction-local exact-name snapshot is shared
|
||||
by the complete export and request on the I/O dispatcher; each later sync takes
|
||||
a fresh snapshot. The redundant application-start export was removed, and
|
||||
request-ID replay protection remains authoritative.
|
||||
|
||||
`TRAINLOG_ANDROID_DIRECT_STORAGE_V1=PASS`. Android no longer uses SAF for
|
||||
exchange artifacts. User-enabled all-files access gates the fixed
|
||||
`/storage/emulated/0/Documents/Trainlog` directory; direct temporary-file
|
||||
replacement preserves canonical names. A physical Samsung Android 16 one-sync
|
||||
and its identical second run completed successfully without suffixes, and the
|
||||
recovered `Dos + biceps` proposal remained visible after replay.
|
||||
|
||||
## Current implementation cursor
|
||||
|
||||
Body Zones V1, exercise reconciliation, definition-first V2 synchronization
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ is a later, separately versioned migration.
|
|||
## 1. Status
|
||||
|
||||
```text
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=17
|
||||
DATABASE_SCHEMA_V17=PASS
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=18
|
||||
DATABASE_SCHEMA_V18=IMPLEMENTED_VALIDATION_PENDING
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ PRAGMA user_version;
|
|||
Current value:
|
||||
|
||||
```text
|
||||
12
|
||||
18
|
||||
```
|
||||
|
||||
The independent actual-set loads documented in the current desktop, Android
|
||||
|
|
@ -90,6 +90,25 @@ direct zones, and rejects profile or primary-zone conflicts before mutation.
|
|||
A schema fixture must represent the real historical structure. Rewriting only
|
||||
`user_version` is not an acceptable migration test.
|
||||
|
||||
Version 18 is additive and transactional. It creates `ai_session_drafts`,
|
||||
`ai_session_draft_entries`, and `ai_session_draft_imports` for the strict
|
||||
`TRAINLOG_AI_SESSION_DRAFT_V1` Drive inbox. A desktop row retains the source
|
||||
`aid_<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
|
||||
v13 facts. Both use stable immutable IDs, exact timestamps, non-empty bounded
|
||||
text at API boundaries, and parent-owned cascading foreign keys to
|
||||
|
|
@ -447,7 +466,7 @@ distance km
|
|||
|
||||
The Android SQLite database is independent.
|
||||
|
||||
Current Android-local version: **10**. The explicit migration chain adds the
|
||||
Current Android-local version: **17**. The explicit migration chain adds the
|
||||
durable draft in v4, equipment references in v5, per-set load in v6, occurrence
|
||||
identity/multi-occurrence support in v7, and the widened custom-equipment
|
||||
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw
|
||||
|
|
|
|||
|
|
@ -1,5 +1,65 @@
|
|||
# Trainlog Exchange Format v1
|
||||
|
||||
## TRAINLOG_AI_SESSION_DRAFT_V1
|
||||
|
||||
`TRAINLOG_AI_SESSION_DRAFT_V1` is a separately versioned AI-proposal contract,
|
||||
not an extension of frozen `TRAINLOG_FORMAT_V1`. Its one-object Drive source is
|
||||
`TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json` with exact root
|
||||
`{"format":"TRAINLOG_AI_SESSION_DRAFT","version":1,"draft":...}`.
|
||||
The source `draft_id` is an `aid_<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
|
||||
|
||||
```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
|
||||
future feature explicitly introduces a new version.
|
||||
|
||||
## TRAINLOG_AI_SESSION_DRAFT_V1 — implementation validation pending
|
||||
|
||||
The implemented proposal path is deliberately separate from completed-session
|
||||
exchange and the singleton capture draft. A strict single Drive source,
|
||||
`TRAINLOG_AI_SESSION_DRAFT` V1, imports target-only SETS proposals into desktop
|
||||
schema v18, archive-copies the exact fetched snapshot only after commit while
|
||||
retaining the inbox object for logical replay, then produces the bounded
|
||||
`trainlog-ai-session-drafts` V1 Android companion in deterministic batches of
|
||||
at most 256 unpublished proposals. Only successful MTP publication advances
|
||||
the desktop cursor; replay identities remain permanent. Android v17 retains a
|
||||
pending collection, while explicit start/delete creates durable tombstones so
|
||||
replay is idempotent and cannot resurrect proposals. The remaining gate is one
|
||||
real Drive plus Android-triggered bidirectional synchronization smoke test;
|
||||
therefore this item is `VALIDATION_PENDING` and is not PASS.
|
||||
|
||||
## Completed foundation
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Machine-exercise Phase 1 compatibility
|
||||
|
||||
Desktop schema v17 and Android schema v16 retain mobile export V3, readable V1/V2 imports,
|
||||
Desktop schema v18 and Android schema v17 retain mobile export V3, readable V1/V2 imports,
|
||||
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
||||
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
||||
silently added to a frozen artifact: stable exercise IDs and canonical names
|
||||
|
|
@ -43,6 +43,7 @@ EXPLICIT_MAX_RESULTS_V2=PASS
|
|||
SESSION_GENERATOR_V1=PASS
|
||||
BODY_ZONE_SYNC_V1=PASS
|
||||
BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS
|
||||
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
|
||||
|
||||
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
|
||||
```
|
||||
|
|
@ -63,13 +64,22 @@ v1 format.
|
|||
Canonical Android shared-storage directory:
|
||||
|
||||
```text
|
||||
Download/Trainlog
|
||||
Documents/Trainlog
|
||||
```
|
||||
|
||||
Desktop accesses this directory through direct MTP.
|
||||
|
||||
Android accesses PC-created artifacts through a persistent Storage Access
|
||||
Framework folder grant.
|
||||
Android accesses this exact public directory directly after the user enables
|
||||
`MANAGE_EXTERNAL_STORAGE` in Android settings. It checks
|
||||
`Environment.isExternalStorageManager()`, creates the directory when necessary,
|
||||
and never uses a SAF tree URI, directory picker, `DocumentFile`, or
|
||||
`DocumentsContract` for Trainlog artifacts.
|
||||
|
||||
The desktop resolves `Documents` and its direct `Trainlog` child for the whole
|
||||
run. All outbound publications target that folder exclusively. A historical
|
||||
`Download/Trainlog` tree may remain on the device for manual recovery and may
|
||||
be inspected by explicitly bounded legacy tooling, but the active sync engine
|
||||
does not publish to it or let it override an artifact in the new endpoint.
|
||||
|
||||
## 3. Artifact table
|
||||
|
||||
|
|
@ -87,6 +97,7 @@ Framework folder grant.
|
|||
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
||||
| PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
|
||||
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
|
||||
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1 companion |
|
||||
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
|
||||
|
||||
|
|
@ -115,7 +126,8 @@ falls back to an older candidate when the newest one is malformed. Definitions
|
|||
reconcile before the V2 snapshot, so a valid custom `equipment_id` is known
|
||||
before a session may reference it. This prevents an older canonical object from
|
||||
being mistaken for Android's current data while preserving every file in
|
||||
`Download/Trainlog`.
|
||||
the historical `Download/Trainlog` tree without selecting it as the active
|
||||
publication endpoint.
|
||||
|
||||
V2 is a separate format: every session entry has an `entry_id`, `position`,
|
||||
metrics, actual loads and optional equipment identity. This permits two
|
||||
|
|
@ -451,6 +463,25 @@ PC -> Android catalog count
|
|||
Android accepts a receipt only when its `request_id` matches the pending
|
||||
request.
|
||||
|
||||
For `TRAINLOG_AI_SESSION_DRAFT_V1`, Drive—not `Documents/Trainlog`—is the
|
||||
single inbound source: `TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json`.
|
||||
The desktop fetches it shell-free, parses the strict one-draft source, and
|
||||
commits it idempotently before copying the exact validated local snapshot with
|
||||
shell-free `rclone copyto` to
|
||||
`TrainLog Gdrive:Trainlog/AI/archive/<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
|
||||
|
||||
Canonical implementation:
|
||||
|
|
@ -472,16 +503,37 @@ Android-triggered path:
|
|||
Android request
|
||||
-> trainlog-syncd
|
||||
-> shared engine
|
||||
-> Drive AI draft fetch/import/archive attempt
|
||||
-> PC -> Android AI draft companion publication
|
||||
-> receipt
|
||||
```
|
||||
|
||||
Only after either path has fully succeeded, the same desktop engine runs:
|
||||
|
||||
```text
|
||||
SYNC=PASS
|
||||
-> tools/export_ai_history.py
|
||||
-> AI_EXPORT=PASS
|
||||
-> rclone copyto trainlog_ai_export_v1.json
|
||||
"TrainLog Gdrive:Trainlog/AI/trainlog_ai_export_v1.json"
|
||||
-> GDRIVE_UPLOAD=PASS|FAIL
|
||||
```
|
||||
|
||||
`AI_EXPORT=FAIL` skips Drive publication so an older artifact cannot be
|
||||
uploaded as the result of the new synchronization. Export and Drive are
|
||||
best-effort post-sync operations: either may fail visibly while the completed
|
||||
Trainlog synchronization remains successful. Android never executes rclone,
|
||||
and Trainlog stores neither its configuration nor Google OAuth secrets.
|
||||
|
||||
The engine has three explicit modes:
|
||||
|
||||
```text
|
||||
a Android -> PC: definition V1 -> mobile V3 -> body zones V1 -> association V2; no publish
|
||||
p PC -> Android: definition V1 -> catalog V1 -> body zones V1 -> mobile V3
|
||||
(including bodies) -> association V2; no receive
|
||||
b bidirectional: complete inbound sequence, then complete outbound sequence
|
||||
a Android -> PC: definition V1 -> mobile V3 -> body zones V1 -> association V2
|
||||
-> Drive AI draft midpoint; no Android publish
|
||||
p PC -> Android: Drive AI draft midpoint -> AI-drafts companion -> profile
|
||||
state/aliases/feedback -> definition V1 -> catalog V1 -> body zones V1 ->
|
||||
mobile V3 (including bodies) -> association V2; no Android receive
|
||||
b bidirectional: complete inbound sequence -> Drive AI draft midpoint -> complete outbound sequence
|
||||
```
|
||||
|
||||
In both directions, definitions are reconciled before V2 artifacts that may
|
||||
|
|
@ -731,3 +783,12 @@ the importer prepares `UPDATE exercises`; the persisted guarded profile trigger
|
|||
therefore remains valid even when the authoritative incoming tip makes its body
|
||||
a no-op. Replaying the same artifact neither changes the current revision nor
|
||||
adds history rows.
|
||||
|
||||
Desktop Python companions, including the alias importer, receive the explicit
|
||||
XDG-resolved database path. Their scripts resolve from the Meson-configured
|
||||
repository tools directory rather than from the installed executable's parent
|
||||
layout; `TRAINLOG_TOOLS_DIR` may explicitly override that directory for a
|
||||
packaged user-session deployment. Before every invocation the bounded combined
|
||||
stdout/stderr result is truncated. Resolution, launch, argparse, exception,
|
||||
signal, and non-zero-exit diagnostics therefore describe only the current run
|
||||
and are retained in the synchronization error/history when the run fails.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# Tests and validation
|
||||
|
||||
The `ai_history_export` test covers an empty database, a simple session,
|
||||
distinct multi-occurrences of one exercise, ordered sets, exact occurrence
|
||||
feedback, session-owned follow-up feedback, JSON escaping/UTF-8, MAX history,
|
||||
body measurements, deterministic content, and byte-for-byte database
|
||||
immutability.
|
||||
|
||||
## 1. Principle
|
||||
|
||||
A Trainlog feature is not complete without relevant validation.
|
||||
|
|
@ -115,7 +121,7 @@ app_shell
|
|||
```
|
||||
|
||||
The current desktop suite, including APP_SHELL_V1 production-transition
|
||||
coverage, is 48/48. The following generator-specific checkpoint counts remain
|
||||
coverage and the AI-draft exchange regressions, is 56/56. The following generator-specific checkpoint counts remain
|
||||
historical evidence:
|
||||
|
||||
```text
|
||||
|
|
@ -388,6 +394,43 @@ reprocessing one request as a new one.
|
|||
The TUI also invokes the same engine manually and exposes structured run
|
||||
details.
|
||||
|
||||
`post_sync_ai_drive` validates strict export-before-upload ordering, skips
|
||||
upload after export failure, and reports unavailable/failing rclone. The
|
||||
production sync wiring test exercises successful TUI and Android triggers
|
||||
through `trainlog_sync_run()`, uses an isolated fake rclone, and proves that
|
||||
failed sync runs do not invoke Drive publication. Post-sync failures are
|
||||
non-fatal to the already successful Trainlog sync by contract.
|
||||
|
||||
`ai_session_draft` validates the strict desktop source parser and schema v18
|
||||
transaction: exact/unknown-key rejection, `aid_` identity, alias resolution,
|
||||
target-only SETS compatibility, 64-entry/99-set/999-rep bounds, exact replay
|
||||
skip, changed-payload conflict, deterministic 256-at-a-time publication across
|
||||
257 drafts, failed-publication retry, permanent import ledger, and full rollback on
|
||||
an injected child failure. Its fake-rclone seam also verifies that the exact
|
||||
fetched bytes are archive-copied only after import, same-name inbox replacement
|
||||
is preserved, stale temporary content cannot import, and archive/status failure
|
||||
converges through idempotent logical replay. Production orchestration
|
||||
tests retain `DRIVE_FAIL`, fetched-invalid `REJECTED`, and `ARCHIVE_FAIL` in a
|
||||
successful report/history. Android repository tests cover the 256-proposal
|
||||
companion limit, full validation before replay skip, strict optional-text
|
||||
trimming, replay idempotence, start/delete
|
||||
tombstones, target-only copy to the unchanged singleton active draft, and
|
||||
catalog/profile/equipment prerequisite ordering. Focused Android UI wiring tests
|
||||
also cover pending-only deterministic listing, started/deleted exclusion, the
|
||||
always-visible **Brouillons** count without losing manual/history actions,
|
||||
proposal title/target details, inert display with no automatic active draft,
|
||||
external-revision refresh after companion import, and the existing transactional
|
||||
start path. These are automated fake
|
||||
transport tests; no real Drive automated test exists. The final real Drive plus
|
||||
Android-triggered bidirectional smoke test remains manual and
|
||||
`TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING`.
|
||||
|
||||
A September 2026 connected-device inspection established that the previously
|
||||
installed 2026-09-13 APK still owned a schema-v16 database without
|
||||
`ai_session_drafts`; it could not import the companion. Installing the current
|
||||
APK and synchronizing once is therefore a prerequisite for the remaining real
|
||||
acceptance test, not evidence that the acceptance test has passed.
|
||||
|
||||
## 9. Sanitizers
|
||||
|
||||
For meaningful C checkpoints:
|
||||
|
|
@ -462,7 +505,7 @@ Coverage proves:
|
|||
and footer F6/F7 single-occurrence output at 120x35, 100x30, 80x24, and
|
||||
72x20.
|
||||
|
||||
Historical validation checkpoint (the current desktop suite is 48/48):
|
||||
Historical validation checkpoint (the current desktop suite is 56/56):
|
||||
|
||||
```text
|
||||
39/39 Meson tests PASS
|
||||
|
|
@ -496,12 +539,17 @@ Historical validation checkpoint (the current desktop suite is 48/48):
|
|||
## 13. Android session draft v1
|
||||
|
||||
Android schema v4 introduced one durable active draft; the current additive
|
||||
chain reaches schema v13 without clearing completed history or the draft. The
|
||||
chain reaches schema v17 without clearing completed history or the draft. The
|
||||
explicit v10 -> v11 migration adds optional planning metadata while preserving
|
||||
existing rows with `load_mode=none`, zero rest and NULL targets. The
|
||||
v11 -> v12 migration adds the durable flattened exercise-alias table. Its exact
|
||||
physical-v11 fixture compares every pre-existing table cell before and after
|
||||
migration, requires the new alias table to be empty, and checks foreign keys.
|
||||
The dedicated physical v16 -> v17 fixture preserves representative completed
|
||||
history and the active draft, adds empty AI proposal tables, and checks SQLite
|
||||
integrity and foreign keys. AI companion parser coverage accepts title/notes
|
||||
at their 120/2,000 Unicode-code-point limits (including non-BMP characters)
|
||||
and rejects 121/2,001 code points atomically.
|
||||
Host coverage includes exercise shapes and raw partial text, fresh repository
|
||||
restore, remove/discard, atomic
|
||||
finalization and repeated-finalize rejection, rollback, catalog reconciliation,
|
||||
|
|
@ -728,23 +776,48 @@ import with equal and evolved tracking/recording/data-field profiles, strict
|
|||
snapshot-local payload rejection, unknown identity rejection, unchanged
|
||||
historical values, unchanged current profile history, and idempotent replay.
|
||||
`SyncBundlePublicationTest` exercises the production request coordinator and a
|
||||
synthetic user-authorized SAF directory whose exact BODY ZONES canonical is
|
||||
deliberately unavailable through MediaStore. The exact SAF object is rewritten
|
||||
beside 31 preserved historical copies, no MediaStore insert/pending publication
|
||||
or `(32)` copy occurs, all seven Android-origin companions precede the request
|
||||
signal, and replay reuses canonical objects without stable-feedback churn.
|
||||
synthetic direct exchange directory. The exact BODY ZONES file is rewritten
|
||||
beside 31 preserved historical copies, no `(32)` copy occurs, all seven
|
||||
Android-origin companions precede the request signal, and replay reuses
|
||||
canonical names without stable-feedback churn.
|
||||
It also requires one direct-child enumeration for the entire export-plus-request
|
||||
transaction, one additional enumeration for each later synchronization, index
|
||||
update after a create without rescan, one write per canonical artifact, and
|
||||
non-main dispatcher ownership for snapshot and stream operations.
|
||||
non-main dispatcher ownership for snapshot and stream operations. Focused
|
||||
publisher cases additionally cover an empty directory, exact rewrite,
|
||||
ten successive publications without numbered copies, historical numbered
|
||||
copies alone and beside a canonical object, exact final content, and an
|
||||
explicit canonical-name conflict without writing a wrongly named target.
|
||||
`DirectExchangeStorageTest` verifies the manifest permission and settings
|
||||
action, refusal without an authorized backend, directory creation, real
|
||||
temporary-file replacement and truncation, ten publications without suffixes,
|
||||
and preservation of legacy Download and recovery fixtures.
|
||||
`AiSessionDraftUiWiringTest` additionally requires the delete action to expose
|
||||
the titled confirmation before mutation, verifies Cancel and Back dismissal,
|
||||
then proves that only confirmation removes the pending proposal while retaining
|
||||
its replay-blocking deleted tombstone. Its existing Start coverage remains.
|
||||
Native `sync_body_zone_wiring` verifies the
|
||||
profile pre-pass before mobile import, strict post-pass gating, replay, legacy
|
||||
peer compatibility, and the explicit missing-companion diagnostic even when a
|
||||
stale local temporary profile artifact exists.
|
||||
Every Android outbound artifact and the request use the shared exact-name SAF
|
||||
resolver; app-scoped MediaStore visibility is not part of canonical selection.
|
||||
stale local temporary profile artifact exists. It also receives a valid alias
|
||||
V1 companion through the production MTP orchestration, imports it into an
|
||||
isolated database through the production Python argv path, and proves that the
|
||||
sync continues. Negative cases cover an unresolved helper, argparse failure,
|
||||
Python exception/stderr propagation, sync-history visibility, and rejection of
|
||||
a stale result-file `PASS`; the successful flow exercises the other shared
|
||||
Python helper callers as well.
|
||||
Every Android outbound artifact and the request use the shared direct-storage
|
||||
exact-name resolver; app-scoped MediaStore visibility is irrelevant.
|
||||
`profile_state_sync` includes the production persisted update-trigger dependency
|
||||
on `trainlog_profile_revision`, imports through the same Python connection path
|
||||
used by the PC sync worker, and requires first import plus identical replay to
|
||||
pass without revision-state or history-count churn. It also checks that the
|
||||
central factory installs both profile-revision schema functions.
|
||||
|
||||
`TRAINLOG_ANDROID_DIRECT_STORAGE_V1` coverage fixes Android storage at
|
||||
`/storage/emulated/0/Documents/Trainlog`, preserves canonical rewrite/no-suffix
|
||||
behavior, ignores legacy Download trees, and gives the MTP fixture both
|
||||
Documents and Download roots while failing on any legacy access.
|
||||
Android unit tests and any instrumented tests run on an emulator; the primary
|
||||
personal phone is reserved for manual, non-destructive install-and-sync smoke
|
||||
validation and must not run `connectedDebugAndroidTest`.
|
||||
|
|
|
|||
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()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def main():
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ def main():
|
|||
args = parser.parse_args()
|
||||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17):
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18):
|
||||
raise ValueError("schema desktop v12-v16 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def main():
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18):
|
||||
raise ValueError("schema desktop v11-v16 requis")
|
||||
exercises = []
|
||||
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version != 17:
|
||||
if version not in (17, 18):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if schema_version not in (11, 12, 13, 14, 15, 16, 17) and not (args.version == 2 and schema_version == 10):
|
||||
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18) and not (args.version == 2 and schema_version == 10):
|
||||
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||
known_equipment = supplied_equipment_ids()
|
||||
canonical_names = load_exercise_names()
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ def main():
|
|||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||
db=connect_database(args.database)
|
||||
try:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17): raise ValueError("schema desktop v15/v16/v17 requis")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18): raise ValueError("schema desktop v15-v18 requis")
|
||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||
if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations")
|
||||
|
|
|
|||
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")
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||
fail("schema desktop v8 à v16 requis")
|
||||
known = load_catalog(args.catalog)
|
||||
known.update(row[0] for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ def main():
|
|||
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||
fail("schema desktop v8 à v16 requis")
|
||||
imported = skipped = 0
|
||||
# Validate every same-ID row before inserting any definition.
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ def main():
|
|||
con = connect_database(args.database)
|
||||
try:
|
||||
con.execute("PRAGMA foreign_keys=ON")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17):
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18):
|
||||
fail("schema desktop v12-v16 requis")
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
for source, canonical in aliases:
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ def main():
|
|||
connection = connect_database(args.database)
|
||||
updated = skipped = kept_local = 0
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18):
|
||||
raise ImportFailure("schema desktop v11-v16 requis")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ def main():
|
|||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at")
|
||||
con=connect_database(a.database);con.row_factory=sqlite3.Row
|
||||
if con.execute("PRAGMA user_version").fetchone()[0]!=17: fail("schema desktop v17 requis")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18): fail("schema desktop v17/v18 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE")
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -813,9 +813,9 @@ def require_supported_schema(connection):
|
|||
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# readable for legacy artifacts and are never made to fake that table.
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v17 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v18 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ def main():
|
|||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
try:
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17):fail("schema desktop v15/v16/v17 requis")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18):fail("schema desktop v15-v18 requis")
|
||||
db.execute("BEGIN IMMEDIATE");seen=set()
|
||||
for index,item in enumerate(root["exercise_feedback"]):
|
||||
keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"}
|
||||
|
|
|
|||
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/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 17
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 18
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,16 @@ typedef struct TrainlogSyncDeviceInfo {
|
|||
TrainlogMtpStorage storage;
|
||||
} TrainlogSyncDeviceInfo;
|
||||
|
||||
typedef enum TrainlogAiInboundStatus {
|
||||
TRAINLOG_AI_INBOUND_NOT_RUN = 0,
|
||||
TRAINLOG_AI_INBOUND_NONE,
|
||||
TRAINLOG_AI_INBOUND_IMPORTED,
|
||||
TRAINLOG_AI_INBOUND_ALREADY_IMPORTED,
|
||||
TRAINLOG_AI_INBOUND_REJECTED,
|
||||
TRAINLOG_AI_INBOUND_DRIVE_FAIL,
|
||||
TRAINLOG_AI_INBOUND_ARCHIVE_FAIL
|
||||
} TrainlogAiInboundStatus;
|
||||
|
||||
typedef struct TrainlogSyncReport {
|
||||
bool success;
|
||||
bool request_present;
|
||||
|
|
@ -72,6 +82,7 @@ typedef struct TrainlogSyncReport {
|
|||
size_t equipment_definitions_skipped;
|
||||
|
||||
size_t catalog_published;
|
||||
TrainlogAiInboundStatus ai_inbound_status;
|
||||
|
||||
char summary[
|
||||
TRAINLOG_SYNC_SUMMARY_MAX + 1U
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ session_generation_policy_generated = custom_target(
|
|||
|
||||
strict_c_args = [
|
||||
'-D_POSIX_C_SOURCE=200809L',
|
||||
'-DTRAINLOG_TOOLS_DIR="@0@"'.format(meson.project_source_root() / 'tools'),
|
||||
'-Wconversion',
|
||||
'-Wformat=2',
|
||||
'-Wshadow',
|
||||
|
|
@ -674,6 +675,24 @@ test_feedback_correction = executable(
|
|||
)
|
||||
test('feedback_correction', test_feedback_correction)
|
||||
|
||||
test(
|
||||
'ai_history_export',
|
||||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_ai_history_export.py'],
|
||||
)
|
||||
|
||||
test(
|
||||
'post_sync_ai_drive',
|
||||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_post_sync_ai_drive.py'],
|
||||
)
|
||||
|
||||
test(
|
||||
'ai_session_draft',
|
||||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_ai_session_draft.py'],
|
||||
)
|
||||
|
||||
test_schema_v16_migration = executable(
|
||||
'test_schema_v16_migration',
|
||||
'tests/test_schema_v16_migration.c',
|
||||
|
|
|
|||
|
|
@ -772,6 +772,77 @@ static const char *const MIGRATE_V16_TO_V17_SQL =
|
|||
"INSERT INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;"
|
||||
"PRAGMA user_version=17;COMMIT;";
|
||||
|
||||
/* WHY: generated plans are untrusted inbox data and must never masquerade as
|
||||
* completed occurrence history. CONTRACT: v18 adds an isolated durable inbox,
|
||||
* target-only SETS children, canonical import identity, bounded publication
|
||||
* state, and post-commit Drive archive state. INVARIANT: no existing domain
|
||||
* table or frozen exchange column is changed. */
|
||||
static const char *const MIGRATE_V17_TO_V18_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"CREATE TABLE ai_session_drafts("
|
||||
"id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL,"
|
||||
"planned_for TEXT,session_type TEXT NOT NULL CHECK(session_type='training'),"
|
||||
"title TEXT,notes TEXT,archive_status TEXT NOT NULL DEFAULT 'pending' "
|
||||
"CHECK(archive_status IN('pending','archived','failed')),"
|
||||
"archived_at TEXT,archive_error TEXT,published_at TEXT);"
|
||||
"CREATE TABLE ai_session_draft_entries("
|
||||
"id INTEGER PRIMARY KEY,draft_row_id INTEGER NOT NULL REFERENCES ai_session_drafts(id) ON DELETE CASCADE,"
|
||||
"entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL CHECK(position>=0),exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,"
|
||||
"source_exercise_id TEXT NOT NULL,recording_mode TEXT NOT NULL CHECK(recording_mode='sets'),"
|
||||
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),data_fields INTEGER NOT NULL,"
|
||||
"equipment_id TEXT,load_mode TEXT NOT NULL CHECK(load_mode IN('none','external')),"
|
||||
"target_sets INTEGER NOT NULL CHECK(target_sets BETWEEN 1 AND 99),"
|
||||
"target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,"
|
||||
"rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400),"
|
||||
"UNIQUE(draft_row_id,position));"
|
||||
"CREATE INDEX ai_session_draft_entries_exercise ON ai_session_draft_entries(exercise_row_id);"
|
||||
"CREATE TABLE ai_session_draft_imports("
|
||||
"draft_row_id INTEGER PRIMARY KEY,"
|
||||
"draft_id TEXT NOT NULL UNIQUE,payload_sha256 TEXT NOT NULL UNIQUE,imported_at TEXT NOT NULL);"
|
||||
"CREATE TRIGGER ai_session_draft_import_identity_guard BEFORE DELETE ON ai_session_drafts "
|
||||
"WHEN EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) "
|
||||
"BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;"
|
||||
"PRAGMA user_version=18;COMMIT;";
|
||||
|
||||
/* CONTRACT: v18 was already deployed before bounded draft publication was
|
||||
* added. Keep its version number and add only the nullable lifecycle cursor;
|
||||
* NULL deliberately means retryable/not yet published. */
|
||||
static TrainlogStatus ensure_v18_ai_draft_publication_state(
|
||||
TrainlogDatabase *database
|
||||
)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int rc;
|
||||
bool found = false;
|
||||
|
||||
rc = sqlite3_prepare_v2(database->connection,
|
||||
"PRAGMA table_info(ai_session_drafts);", -1, &statement, NULL);
|
||||
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
const unsigned char *name = sqlite3_column_text(statement, 1);
|
||||
if (name != NULL && strcmp((const char *)name, "published_at") == 0) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (rc != SQLITE_ROW && rc != SQLITE_DONE) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
if (!found && execute_sql(database,
|
||||
"ALTER TABLE ai_session_drafts ADD COLUMN published_at TEXT;") !=
|
||||
TRAINLOG_STATUS_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
/* INVARIANT: older v18 tables may still declare a cascading FK. This
|
||||
* guard runs before it and makes replay identity durable for both shapes. */
|
||||
return execute_sql(database,
|
||||
"CREATE TRIGGER IF NOT EXISTS ai_session_draft_import_identity_guard "
|
||||
"BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM "
|
||||
"ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN "
|
||||
"SELECT RAISE(ABORT,'AI draft import identity is permanent');END;");
|
||||
}
|
||||
|
||||
/* WHY: the published-unreleased v17 prototype may already exist locally.
|
||||
* CONTRACT: opening it adds the bounded durable lineage table without changing
|
||||
* user_version or domain rows. INVARIANT: the guarded trigger records local
|
||||
|
|
@ -1425,7 +1496,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
} else if (version == 10) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
||||
version == 15 || version == 16 || version == 17) {
|
||||
version == 15 || version == 16 || version == 17 || version == 18) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -1586,6 +1657,12 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
if (status == TRAINLOG_STATUS_OK && version < 17) {
|
||||
status = execute_sql(database, MIGRATE_V16_TO_V17_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 18) {
|
||||
status = execute_sql(database, MIGRATE_V17_TO_V18_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = ensure_v18_ai_draft_publication_state(database);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, "BEGIN IMMEDIATE;");
|
||||
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, ENSURE_V17_PROFILE_REVISIONS_SQL);
|
||||
if (status == TRAINLOG_STATUS_OK) status = normalize_v17_prototype_profiles(database);
|
||||
|
|
@ -1598,7 +1675,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
set_open_diagnostic(
|
||||
output_diagnostic,
|
||||
output_diagnostic_capacity,
|
||||
version == 0 ? "create schema v17" : "migrate database to schema v17",
|
||||
version == 0 ? "create schema v18" : "migrate database to schema v18",
|
||||
database->connection,
|
||||
SQLITE_ERROR
|
||||
);
|
||||
|
|
|
|||
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";
|
||||
static const char *const EXERCISE_PROFILE_STATE_NAME =
|
||||
"trainlog-exercise-profile-state-v1.json";
|
||||
static const char *const AI_SESSION_DRAFTS_NAME =
|
||||
"trainlog-ai-session-drafts-v1.json";
|
||||
|
||||
static const char *const SYNC_REQUEST_NAME =
|
||||
"trainlog-sync-request-v1.json";
|
||||
|
|
@ -100,6 +102,8 @@ static const char *const TRAINING_FEEDBACK_LOCAL =
|
|||
"/tmp/trainlog-training-feedback-v2.json";
|
||||
static const char *const EXERCISE_PROFILE_STATE_LOCAL =
|
||||
"/tmp/trainlog-exercise-profile-state-v1.json";
|
||||
static const char *const AI_SESSION_DRAFTS_LOCAL =
|
||||
"/tmp/trainlog-ai-session-drafts-v1.json";
|
||||
|
||||
static const char *const EXERCISE_BODY_ZONES_RESULT =
|
||||
"/tmp/trainlog-exercise-body-zones-result.txt";
|
||||
|
|
@ -126,6 +130,11 @@ static const char *const PC_CATALOG_RESULT =
|
|||
static const char *const EQUIPMENT_ASSOCIATIONS_RESULT =
|
||||
"/tmp/trainlog-equipment-associations-result.txt";
|
||||
|
||||
static const char *const AI_POST_SYNC_RESULT =
|
||||
"/tmp/trainlog-ai-post-sync-result.txt";
|
||||
static const char *const AI_SESSION_DRAFT_RESULT =
|
||||
"/tmp/trainlog-ai-session-draft-result.txt";
|
||||
|
||||
typedef struct SyncSilence {
|
||||
int saved_stdout;
|
||||
int saved_stderr;
|
||||
|
|
@ -165,6 +174,60 @@ static void sync_compose_diagnostic(
|
|||
output[written] = '\0';
|
||||
}
|
||||
|
||||
static const char *sync_ai_inbound_status_text(TrainlogAiInboundStatus status)
|
||||
{
|
||||
switch (status) {
|
||||
case TRAINLOG_AI_INBOUND_NONE: return "NONE";
|
||||
case TRAINLOG_AI_INBOUND_IMPORTED: return "IMPORTED";
|
||||
case TRAINLOG_AI_INBOUND_ALREADY_IMPORTED: return "ALREADY_IMPORTED";
|
||||
case TRAINLOG_AI_INBOUND_REJECTED: return "REJECTED";
|
||||
case TRAINLOG_AI_INBOUND_DRIVE_FAIL: return "DRIVE_FAIL";
|
||||
case TRAINLOG_AI_INBOUND_ARCHIVE_FAIL: return "ARCHIVE_FAIL";
|
||||
case TRAINLOG_AI_INBOUND_NOT_RUN: default: return "NOT_RUN";
|
||||
}
|
||||
}
|
||||
|
||||
static TrainlogAiInboundStatus sync_parse_ai_inbound_status(const char *text)
|
||||
{
|
||||
static const struct {
|
||||
const char *marker;
|
||||
TrainlogAiInboundStatus status;
|
||||
} values[] = {
|
||||
{"AI_SESSION_DRAFT_INBOUND=ALREADY_IMPORTED", TRAINLOG_AI_INBOUND_ALREADY_IMPORTED},
|
||||
{"AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL", TRAINLOG_AI_INBOUND_ARCHIVE_FAIL},
|
||||
{"AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", TRAINLOG_AI_INBOUND_DRIVE_FAIL},
|
||||
{"AI_SESSION_DRAFT_INBOUND=IMPORTED", TRAINLOG_AI_INBOUND_IMPORTED},
|
||||
{"AI_SESSION_DRAFT_INBOUND=REJECTED", TRAINLOG_AI_INBOUND_REJECTED},
|
||||
{"AI_SESSION_DRAFT_INBOUND=NONE", TRAINLOG_AI_INBOUND_NONE},
|
||||
};
|
||||
TrainlogAiInboundStatus result = TRAINLOG_AI_INBOUND_NOT_RUN;
|
||||
const char *line;
|
||||
size_t index;
|
||||
|
||||
if (text == NULL) return TRAINLOG_AI_INBOUND_NOT_RUN;
|
||||
|
||||
/* WHY: helper diagnostics are untrusted text and may quote a status marker.
|
||||
* CONTRACT: only a complete marker line is a helper result; CRLF is accepted.
|
||||
* INVARIANT: scanning through all lines makes the last valid result authoritative. */
|
||||
line = text;
|
||||
while (*line != '\0') {
|
||||
const char *newline = strchr(line, '\n');
|
||||
size_t length = newline != NULL ? (size_t)(newline - line) : strlen(line);
|
||||
|
||||
if (length > 0U && line[length - 1U] == '\r') length -= 1U;
|
||||
for (index = 0U; index < sizeof(values) / sizeof(values[0]); ++index) {
|
||||
size_t marker_length = strlen(values[index].marker);
|
||||
if (length == marker_length &&
|
||||
memcmp(line, values[index].marker, marker_length) == 0) {
|
||||
result = values[index].status;
|
||||
}
|
||||
}
|
||||
if (newline == NULL) break;
|
||||
line = newline + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
TrainlogSyncDirectionPlan trainlog_sync_direction_plan(
|
||||
TrainlogSyncDirection direction
|
||||
)
|
||||
|
|
@ -909,7 +972,7 @@ static TrainlogStatus sync_find_exchange_folder(
|
|||
uint32_t *output_folder_id
|
||||
)
|
||||
{
|
||||
uint32_t download_id = 0U;
|
||||
uint32_t documents_id = 0U;
|
||||
uint64_t ignored_size = 0U;
|
||||
TrainlogStatus status;
|
||||
|
||||
|
|
@ -920,13 +983,16 @@ static TrainlogStatus sync_find_exchange_folder(
|
|||
TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
/* CONTRACT: Documents/Trainlog is the only writable exchange endpoint.
|
||||
* The historical Download/Trainlog tree may remain on the device, but no
|
||||
* publication path may resolve or mutate it. */
|
||||
status =
|
||||
sync_find_child(
|
||||
device,
|
||||
UINT32_MAX,
|
||||
"Download",
|
||||
"Documents",
|
||||
true,
|
||||
&download_id,
|
||||
&documents_id,
|
||||
&ignored_size
|
||||
);
|
||||
|
||||
|
|
@ -940,7 +1006,7 @@ static TrainlogStatus sync_find_exchange_folder(
|
|||
return
|
||||
sync_find_child(
|
||||
device,
|
||||
download_id,
|
||||
documents_id,
|
||||
"Trainlog",
|
||||
true,
|
||||
output_folder_id,
|
||||
|
|
@ -1117,6 +1183,7 @@ static bool sync_resolve_repo_tool(
|
|||
size_t output_size
|
||||
)
|
||||
{
|
||||
const char *configured_directory;
|
||||
char executable[
|
||||
PATH_MAX + 1U
|
||||
];
|
||||
|
|
@ -1134,6 +1201,29 @@ static bool sync_resolve_repo_tool(
|
|||
return false;
|
||||
}
|
||||
|
||||
/* WHY: installed TUI binaries are not necessarily nested under the source
|
||||
* tree, whereas the Python sync helpers remain repository-owned assets.
|
||||
* CONTRACT: an explicit process-local override is accepted for packaged
|
||||
* deployments and tests; otherwise Meson's absolute source tools path is
|
||||
* authoritative. INVARIANT: resolution never invokes a shell and accepts
|
||||
* only the caller-supplied fixed helper filename. */
|
||||
configured_directory = getenv("TRAINLOG_TOOLS_DIR");
|
||||
if (configured_directory != NULL && configured_directory[0] != '\0') {
|
||||
written = snprintf(output, output_size, "%s/%s",
|
||||
configured_directory, tool_name);
|
||||
return written >= 0 && (size_t)written < output_size &&
|
||||
access(output, R_OK) == 0;
|
||||
}
|
||||
configured_directory = TRAINLOG_TOOLS_DIR;
|
||||
written = snprintf(output, output_size, "%s/%s",
|
||||
configured_directory, tool_name);
|
||||
if (written >= 0 && (size_t)written < output_size &&
|
||||
access(output, R_OK) == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Compatibility for binaries from older or relocated build trees. */
|
||||
|
||||
length =
|
||||
readlink(
|
||||
"/proc/self/exe",
|
||||
|
|
@ -1273,17 +1363,6 @@ static TrainlogStatus sync_run_python_tool(
|
|||
|
||||
output[0] = '\0';
|
||||
|
||||
if (
|
||||
!sync_resolve_repo_tool(
|
||||
tool_name,
|
||||
tool,
|
||||
sizeof(tool)
|
||||
)
|
||||
) {
|
||||
return
|
||||
TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
|
||||
result_fd =
|
||||
open(
|
||||
result_path,
|
||||
|
|
@ -1295,10 +1374,21 @@ static TrainlogStatus sync_run_python_tool(
|
|||
);
|
||||
|
||||
if (result_fd < 0) {
|
||||
(void)snprintf(output, output_size,
|
||||
"résultat outil Python inaccessible (errno=%d)", errno);
|
||||
return
|
||||
TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
|
||||
/* INVARIANT: a prior run can never satisfy the current run's PASS check,
|
||||
* including when helper resolution fails before a child is started. */
|
||||
if (!sync_resolve_repo_tool(tool_name, tool, sizeof(tool))) {
|
||||
(void)close(result_fd);
|
||||
(void)snprintf(output, output_size,
|
||||
"outil Python introuvable: %s", tool_name);
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
|
||||
child = fork();
|
||||
|
||||
if (child < (pid_t)0) {
|
||||
|
|
@ -1306,8 +1396,9 @@ static TrainlogStatus sync_run_python_tool(
|
|||
result_fd
|
||||
);
|
||||
|
||||
return
|
||||
TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
(void)snprintf(output, output_size,
|
||||
"lancement outil Python échoué (errno=%d)", errno);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
|
||||
if (child == (pid_t)0) {
|
||||
|
|
@ -1366,6 +1457,8 @@ static TrainlogStatus sync_run_python_tool(
|
|||
);
|
||||
}
|
||||
|
||||
(void)dprintf(STDERR_FILENO,
|
||||
"python3: exécution impossible (errno=%d)\n", errno);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
|
|
@ -1380,15 +1473,16 @@ static TrainlogStatus sync_run_python_tool(
|
|||
0
|
||||
) < (pid_t)0
|
||||
) {
|
||||
return
|
||||
TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
(void)snprintf(output, output_size,
|
||||
"attente outil Python échouée (errno=%d)", errno);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
|
||||
(void)sync_read_text(
|
||||
result_path,
|
||||
output,
|
||||
output_size
|
||||
);
|
||||
if (!sync_read_text(result_path, output, output_size)) {
|
||||
(void)snprintf(output, output_size,
|
||||
"lecture diagnostic outil Python échouée");
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
|
||||
if (
|
||||
!WIFEXITED(
|
||||
|
|
@ -1398,6 +1492,18 @@ static TrainlogStatus sync_run_python_tool(
|
|||
child_status
|
||||
) != 0
|
||||
) {
|
||||
if (output[0] == '\0') {
|
||||
if (WIFSIGNALED(child_status)) {
|
||||
(void)snprintf(output, output_size,
|
||||
"outil Python interrompu par signal %d",
|
||||
WTERMSIG(child_status));
|
||||
} else {
|
||||
(void)snprintf(output, output_size,
|
||||
"outil Python terminé avec code %d",
|
||||
WIFEXITED(child_status)
|
||||
? WEXITSTATUS(child_status) : -1);
|
||||
}
|
||||
}
|
||||
return
|
||||
TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
|
@ -2354,6 +2460,13 @@ bool trainlog_sync_record_local_run(
|
|||
return false;
|
||||
}
|
||||
|
||||
(void)fputs(",\n \"ai_inbound_status\":", json_file);
|
||||
if (!sync_json_write_escaped(json_file,
|
||||
sync_ai_inbound_status_text(report->ai_inbound_status))) {
|
||||
(void)fclose(json_file);
|
||||
return false;
|
||||
}
|
||||
|
||||
(void)fprintf(
|
||||
json_file,
|
||||
",\n \"android_to_pc\":{"
|
||||
|
|
@ -2424,6 +2537,11 @@ bool trainlog_sync_record_local_run(
|
|||
);
|
||||
}
|
||||
|
||||
if (report->ai_inbound_status != TRAINLOG_AI_INBOUND_NOT_RUN) {
|
||||
(void)fprintf(detail_file, "AI entrant : %s\n",
|
||||
sync_ai_inbound_status_text(report->ai_inbound_status));
|
||||
}
|
||||
|
||||
(void)fprintf(
|
||||
detail_file,
|
||||
"\nANDROID → PC\n"
|
||||
|
|
@ -2545,6 +2663,58 @@ void trainlog_sync_build_summary(
|
|||
: "Synchronisation échouée."
|
||||
);
|
||||
}
|
||||
if (report->ai_inbound_status != TRAINLOG_AI_INBOUND_NOT_RUN) {
|
||||
size_t used = strlen(report->summary);
|
||||
if (used < sizeof(report->summary)) {
|
||||
(void)snprintf(report->summary + used,
|
||||
sizeof(report->summary) - used, " · AI_INBOUND=%s",
|
||||
sync_ai_inbound_status_text(report->ai_inbound_status));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void sync_append_post_sync_summary(
|
||||
TrainlogSyncReport *report,
|
||||
const char *tool_output
|
||||
)
|
||||
{
|
||||
char export_status[96] = "AI_EXPORT=FAIL";
|
||||
char upload_status[96] = "GDRIVE_UPLOAD=SKIPPED";
|
||||
const char *line;
|
||||
size_t used;
|
||||
|
||||
if (report == NULL) {
|
||||
return;
|
||||
}
|
||||
line = tool_output != NULL ? strstr(tool_output, "AI_EXPORT=") : NULL;
|
||||
if (line != NULL) {
|
||||
size_t length = strcspn(line, "\r\n");
|
||||
(void)snprintf(export_status, sizeof(export_status), "%.*s",
|
||||
(int)(length < sizeof(export_status) - 1U
|
||||
? length : sizeof(export_status) - 1U), line);
|
||||
}
|
||||
line = tool_output != NULL ? strstr(tool_output, "GDRIVE_UPLOAD=") : NULL;
|
||||
if (line != NULL) {
|
||||
size_t length = strcspn(line, "\r\n");
|
||||
(void)snprintf(upload_status, sizeof(upload_status), "%.*s",
|
||||
(int)(length < sizeof(upload_status) - 1U
|
||||
? length : sizeof(upload_status) - 1U), line);
|
||||
}
|
||||
|
||||
used = strlen(report->summary);
|
||||
if (used < sizeof(report->summary)) {
|
||||
/* WHY: post-sync publication is operational follow-up, not part of
|
||||
* the already committed Trainlog synchronization. CONTRACT: expose
|
||||
* each outcome while preserving report.success and the sync return
|
||||
* status. INVARIANT: the fixed report buffer remains terminated. */
|
||||
(void)snprintf(
|
||||
report->summary + used,
|
||||
sizeof(report->summary) - used,
|
||||
" · SYNC=PASS %s %s",
|
||||
export_status,
|
||||
upload_status
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static TrainlogStatus sync_prepare_run_identity(
|
||||
|
|
@ -2750,7 +2920,7 @@ TrainlogStatus trainlog_sync_run(
|
|||
(void)snprintf(
|
||||
output->error,
|
||||
sizeof(output->error),
|
||||
"Résolution de Download/Trainlog échouée (status=%d).",
|
||||
"Résolution de Documents/Trainlog échouée (status=%d).",
|
||||
(int)status
|
||||
);
|
||||
|
||||
|
|
@ -2882,7 +3052,7 @@ TrainlogStatus trainlog_sync_run(
|
|||
}
|
||||
|
||||
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
|
||||
goto outbound;
|
||||
goto ai_draft_midpoint;
|
||||
}
|
||||
|
||||
/* WHY: aliases must exist before the frozen session/catalog snapshot is
|
||||
|
|
@ -3196,13 +3366,67 @@ TrainlogStatus trainlog_sync_run(
|
|||
goto finalize;
|
||||
}
|
||||
|
||||
ai_draft_midpoint:
|
||||
/* WHY: an Android-triggered bidirectional run must publish a newly fetched
|
||||
* AI draft immediately, not one synchronization later. CONTRACT: the
|
||||
* shell-free helper owns Drive fetch, strict transactional import, and
|
||||
* post-commit archive; transport/archive failures are explicitly nonfatal.
|
||||
* INVARIANT: this midpoint precedes every PC→Android artifact and receipt. */
|
||||
status = sync_run_python_tool("sync_ai_session_draft_drive.py",
|
||||
"trainlog_ai_session_draft_v1.json", database_path, NULL,
|
||||
AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output));
|
||||
output->ai_inbound_status = sync_parse_ai_inbound_status(tool_output);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
|
||||
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
|
||||
sync_compose_diagnostic(output->error, sizeof(output->error),
|
||||
"AI→PC : brouillon séance : ",
|
||||
useful[0] != '\0' ? useful : "import échoué");
|
||||
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finalize;
|
||||
}
|
||||
if (output->ai_inbound_status == TRAINLOG_AI_INBOUND_NOT_RUN) {
|
||||
(void)snprintf(output->error, sizeof(output->error),
|
||||
"AI→PC : statut du brouillon séance absent.");
|
||||
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finalize;
|
||||
}
|
||||
|
||||
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
|
||||
output->success = true;
|
||||
final_status = TRAINLOG_STATUS_OK;
|
||||
goto finalize;
|
||||
}
|
||||
|
||||
outbound:
|
||||
status = sync_run_python_tool("export_ai_session_drafts.py",
|
||||
AI_SESSION_DRAFTS_LOCAL, database_path, NULL,
|
||||
AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output));
|
||||
if (status != TRAINLOG_STATUS_OK ||
|
||||
strstr(tool_output, "AI_SESSION_DRAFT_EXPORT=PASS") == NULL) {
|
||||
(void)snprintf(output->error, sizeof(output->error),
|
||||
"PC→Android : export brouillons IA échoué.");
|
||||
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finalize;
|
||||
}
|
||||
status = sync_publish_named(&device, folder_id,
|
||||
AI_SESSION_DRAFTS_LOCAL, AI_SESSION_DRAFTS_NAME);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
(void)snprintf(output->error, sizeof(output->error),
|
||||
"PC→Android : publication brouillons IA échouée.");
|
||||
final_status = status;
|
||||
goto finalize;
|
||||
}
|
||||
status = sync_run_python_tool("mark_ai_session_drafts_published.py",
|
||||
AI_SESSION_DRAFTS_LOCAL, database_path, NULL,
|
||||
AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output));
|
||||
if (status != TRAINLOG_STATUS_OK ||
|
||||
strstr(tool_output, "AI_SESSION_DRAFT_PUBLISH_MARK=PASS") == NULL) {
|
||||
(void)snprintf(output->error, sizeof(output->error),
|
||||
"PC→Android : enregistrement publication brouillons IA échoué.");
|
||||
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finalize;
|
||||
}
|
||||
|
||||
status = sync_run_python_tool("export_exercise_profile_state.py",
|
||||
EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL,
|
||||
EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output));
|
||||
|
|
@ -3468,11 +3692,6 @@ finalize:
|
|||
}
|
||||
}
|
||||
|
||||
(void)trainlog_sync_record_local_run(
|
||||
trigger,
|
||||
output
|
||||
);
|
||||
|
||||
if (
|
||||
output->request_present &&
|
||||
receipt_published
|
||||
|
|
@ -3482,6 +3701,32 @@ finalize:
|
|||
);
|
||||
}
|
||||
|
||||
if (final_status == TRAINLOG_STATUS_OK && output->success) {
|
||||
TrainlogStatus post_status = sync_run_python_tool(
|
||||
"post_sync_ai_drive.py",
|
||||
"trainlog_ai_export_v1.json",
|
||||
database_path,
|
||||
NULL,
|
||||
AI_POST_SYNC_RESULT,
|
||||
tool_output,
|
||||
sizeof(tool_output)
|
||||
);
|
||||
|
||||
/* A launcher failure has no script output, but it must still remain a
|
||||
* visible post-sync failure rather than changing Trainlog sync state. */
|
||||
if (post_status != TRAINLOG_STATUS_OK && tool_output[0] == '\0') {
|
||||
(void)snprintf(tool_output, sizeof(tool_output),
|
||||
"AI_EXPORT=FAIL launcher status=%d\n"
|
||||
"GDRIVE_UPLOAD=SKIPPED\n", (int)post_status);
|
||||
}
|
||||
sync_append_post_sync_summary(output, tool_output);
|
||||
}
|
||||
|
||||
(void)trainlog_sync_record_local_run(
|
||||
trigger,
|
||||
output
|
||||
);
|
||||
|
||||
done:
|
||||
if (silence_active) {
|
||||
sync_silence_end(
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ static int migration_preserves_identity_and_history(void)
|
|||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
raw = NULL;
|
||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 17);
|
||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 18);
|
||||
CHECK(trainlog_database_list_exercise_body_zones(database,
|
||||
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 2U);
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ static bool run_test(void)
|
|||
(void)fprintf(stderr, "open diagnostic: %s\n", diagnostic);
|
||||
CHECK(false);
|
||||
}
|
||||
CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && version == 17);
|
||||
CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && version == 18);
|
||||
trainlog_database_close(db); db = NULL;
|
||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE s.revision_id IS NULL OR s.revision_id<>'pr_legacy_v1' OR s.legacy_seed<>1", 0));
|
||||
|
|
@ -97,6 +97,10 @@ static bool run_test(void)
|
|||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_feedback_revisions WHERE revision_id='fr_old'", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followups WHERE id=23", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followup_revisions WHERE revision_id='ur_old'", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('ai_session_drafts') WHERE name='archive_status'", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('ai_session_draft_entries') WHERE name='target_sets'", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('ai_session_draft_imports') WHERE name='payload_sha256'", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM ai_session_drafts", 0));
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_update_exercise_profiled(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", "Planche face sol",
|
||||
|
|
|
|||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strncmp(diagnostic, "migrate database to schema v17: SQLite rc=",
|
||||
strlen("migrate database to schema v17: SQLite rc=")) == 0);
|
||||
CHECK(strncmp(diagnostic, "migrate database to schema v18: SQLite rc=",
|
||||
strlen("migrate database to schema v18: SQLite rc=")) == 0);
|
||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
|||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=18;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=19;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 18 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 19 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v17") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v18") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,75 @@ static const char *remote_mobile_name = "trainlog-mobile-export-v2.json";
|
|||
static unsigned int remote_mobile_version = 2U;
|
||||
static const char *remote_tracking_mode = "reps";
|
||||
static bool remote_profile_available = true;
|
||||
static bool remote_alias_available = false;
|
||||
static char rclone_log[4096];
|
||||
|
||||
static bool install_fake_rclone(const char *root)
|
||||
{
|
||||
char bin[4096];
|
||||
char executable[4096];
|
||||
char path[8192];
|
||||
const char *old_path = getenv("PATH");
|
||||
FILE *file;
|
||||
|
||||
CHECK(snprintf(bin, sizeof(bin), "%s/bin", root) > 0);
|
||||
CHECK(mkdir(bin, 0700) == 0);
|
||||
CHECK(snprintf(executable, sizeof(executable), "%s/rclone", bin) > 0);
|
||||
file = fopen(executable, "wb");
|
||||
CHECK(file != NULL);
|
||||
CHECK(fputs("#!/bin/sh\n"
|
||||
"if test \"$1\" = lsf; then\n"
|
||||
" test \"$2\" = 'TrainLog Gdrive:Trainlog/AI/inbox' || exit 65\n"
|
||||
" case \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" in\n"
|
||||
" invalid|archive_fail|imported|already) printf 'trainlog_ai_session_draft_v1.json\\n'; exit 0 ;;\n"
|
||||
" marker_prefix) printf 'diagnostic AI_SESSION_DRAFT_INBOUND=IMPORTED\\n' >&2; exit 65 ;;\n"
|
||||
" marker_suffix) printf 'AI_SESSION_DRAFT_INBOUND=IMPORTED diagnostic\\n' >&2; exit 65 ;;\n"
|
||||
" none) exit 0 ;;\n"
|
||||
" *) exit 65 ;;\n"
|
||||
" esac\n"
|
||||
"fi\n"
|
||||
"test \"$1\" = copyto || exit 64\n"
|
||||
"case \"$2\" in\n"
|
||||
" 'TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json')\n"
|
||||
" case \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" in\n"
|
||||
" invalid) printf '{}\\n' > \"$3\"; exit 0 ;;\n"
|
||||
" archive_fail|imported|already) cp \"$TRAINLOG_TEST_AI_SOURCE\" \"$3\"; exit 0 ;;\n"
|
||||
" *) exit 65 ;;\n"
|
||||
" esac ;;\n"
|
||||
"esac\n"
|
||||
"test -f \"$2\" || exit 65\n"
|
||||
"case \"$3\" in\n"
|
||||
" 'TrainLog Gdrive:Trainlog/AI/archive/'*)\n"
|
||||
" test \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" != archive_fail || exit 9 ;;\n"
|
||||
"esac\n"
|
||||
"printf '%s\\n' \"$3\" >> \"$TRAINLOG_TEST_RCLONE_LOG\"\n",
|
||||
file) >= 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
CHECK(chmod(executable, 0700) == 0);
|
||||
CHECK(snprintf(rclone_log, sizeof(rclone_log), "%s/rclone.log", root) > 0);
|
||||
CHECK(setenv("TRAINLOG_TEST_RCLONE_LOG", rclone_log, 1) == 0);
|
||||
CHECK(snprintf(path, sizeof(path), "%s:%s", bin,
|
||||
old_path != NULL ? old_path : "") > 0);
|
||||
CHECK(setenv("PATH", path, 1) == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
static size_t rclone_upload_count(void)
|
||||
{
|
||||
FILE *file = fopen(rclone_log, "rb");
|
||||
size_t count = 0U;
|
||||
int byte;
|
||||
if (file == NULL) {
|
||||
return 0U;
|
||||
}
|
||||
while ((byte = fgetc(file)) != EOF) {
|
||||
if (byte == '\n') {
|
||||
count += 1U;
|
||||
}
|
||||
}
|
||||
CHECK(fclose(file) == 0);
|
||||
return count;
|
||||
}
|
||||
|
||||
static bool copy_file(const char *source, const char *target)
|
||||
{
|
||||
|
|
@ -134,13 +203,20 @@ TrainlogStatus trainlog_mtp_list_folder(
|
|||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
if (parent_folder_id == UINT32_MAX) {
|
||||
if (capacity < 1U) {
|
||||
if (capacity < 2U) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
set_entry(&output[0], 1U, UINT32_MAX, "Download", true);
|
||||
*output_count = 1U;
|
||||
set_entry(&output[0], 1U, UINT32_MAX, "Documents", true);
|
||||
/* CONTRACT fixture: the historical exchange tree remains present but
|
||||
* must never be selected for reads or publications after migration. */
|
||||
set_entry(&output[1], 90U, UINT32_MAX, "Download", true);
|
||||
*output_count = 2U;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
if (parent_folder_id == 90U) {
|
||||
fprintf(stderr, "legacy Download tree was accessed\n");
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
if (parent_folder_id == 1U) {
|
||||
if (capacity < 1U) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
|
|
@ -150,7 +226,9 @@ TrainlogStatus trainlog_mtp_list_folder(
|
|||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
if (parent_folder_id == 2U) {
|
||||
size_t count = remote_profile_available ? 4U : 3U;
|
||||
size_t count = 3U + (remote_profile_available ? 1U : 0U) +
|
||||
(remote_alias_available ? 1U : 0U);
|
||||
size_t next = 3U;
|
||||
if (capacity < count) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
|
|
@ -160,8 +238,13 @@ TrainlogStatus trainlog_mtp_list_folder(
|
|||
set_entry(&output[2], 12U, 2U,
|
||||
"trainlog-equipment-associations-v2.json", false);
|
||||
if (remote_profile_available) {
|
||||
set_entry(&output[3], 13U, 2U,
|
||||
set_entry(&output[next], 13U, 2U,
|
||||
"trainlog-exercise-profile-state-v1.json", false);
|
||||
next += 1U;
|
||||
}
|
||||
if (remote_alias_available) {
|
||||
set_entry(&output[next], 14U, 2U,
|
||||
"trainlog-exercise-aliases-v1.json", false);
|
||||
}
|
||||
*output_count = count;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
|
|
@ -186,7 +269,8 @@ TrainlogStatus trainlog_mtp_receive_file(
|
|||
name = item_id == 10U ? remote_mobile_name :
|
||||
item_id == 11U ? "trainlog-exercise-body-zones-v1.json" :
|
||||
item_id == 12U ? "trainlog-equipment-associations-v2.json" :
|
||||
item_id == 13U ? "trainlog-exercise-profile-state-v1.json" : NULL;
|
||||
item_id == 13U ? "trainlog-exercise-profile-state-v1.json" :
|
||||
item_id == 14U ? "trainlog-exercise-aliases-v1.json" : NULL;
|
||||
if (name == NULL || local_path == NULL) {
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
|
|
@ -289,6 +373,18 @@ static bool write_artifacts(
|
|||
}
|
||||
CHECK(fclose(file) == 0);
|
||||
|
||||
written = snprintf(path, sizeof(path),
|
||||
"%s/trainlog-exercise-aliases-v1.json", remote_root);
|
||||
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
||||
file = fopen(path, "wb");
|
||||
CHECK(file != NULL);
|
||||
CHECK(fprintf(file,
|
||||
"{\"format\":\"trainlog-exercise-aliases\",\"version\":1,"
|
||||
"\"aliases\":[{\"source_exercise_id\":\"%s\","
|
||||
"\"canonical_exercise_id\":\"%s\"}]}",
|
||||
SOURCE_ID, CANONICAL_ID) > 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
|
||||
written = snprintf(path, sizeof(path),
|
||||
"%s/trainlog-exercise-profile-state-v1.json", remote_root);
|
||||
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
||||
|
|
@ -382,6 +478,18 @@ static bool prepare_database(
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool add_live_source_exercise(const char *database_path)
|
||||
{
|
||||
TrainlogDatabase *database = NULL;
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise_profiled(
|
||||
database, SOURCE_ID, "Retired sync wiring exercise",
|
||||
"retired sync wiring exercise", TRAINLOG_TRACKING_REPS,
|
||||
TRAINLOG_RECORDING_SETS, UINT32_C(0)) == TRAINLOG_STATUS_OK);
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool database_state(
|
||||
const char *database_path,
|
||||
bool source_should_resolve,
|
||||
|
|
@ -466,6 +574,24 @@ static bool successful_journal(const char *case_root, const char *sync_id)
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool history_contains(const char *case_root, const char *needle)
|
||||
{
|
||||
char path[4096];
|
||||
char content[65536];
|
||||
FILE *file;
|
||||
size_t count;
|
||||
CHECK(snprintf(path, sizeof(path), "%s/trainlog/sync_history.log",
|
||||
case_root) > 0);
|
||||
file = fopen(path, "rb");
|
||||
CHECK(file != NULL);
|
||||
count = fread(content, 1U, sizeof(content) - 1U, file);
|
||||
CHECK(ferror(file) == 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
content[count] = '\0';
|
||||
CHECK(strstr(content, needle) != NULL);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool file_contains(const char *name, const char *needle, bool expected)
|
||||
{
|
||||
char path[4096];
|
||||
|
|
@ -521,7 +647,9 @@ static bool run_success_case(
|
|||
database_path, sizeof(database_path)));
|
||||
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, persistent_alias));
|
||||
{
|
||||
TrainlogStatus status = trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||
TrainlogSyncTrigger trigger = persistent_alias
|
||||
? TRAINLOG_SYNC_TRIGGER_ANDROID : TRAINLOG_SYNC_TRIGGER_TUI;
|
||||
TrainlogStatus status = trainlog_sync_run(trigger, false,
|
||||
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
|
|
@ -530,6 +658,9 @@ static bool run_success_case(
|
|||
CHECK(status == TRAINLOG_STATUS_OK);
|
||||
}
|
||||
CHECK(report.success);
|
||||
CHECK(strstr(report.summary, "AI_INBOUND=DRIVE_FAIL") != NULL);
|
||||
CHECK(strstr(report.summary,
|
||||
"SYNC=PASS AI_EXPORT=PASS GDRIVE_UPLOAD=PASS") != NULL);
|
||||
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
|
||||
CHECK(successful_journal(case_root, report.sync_id));
|
||||
if (persistent_alias) {
|
||||
|
|
@ -551,10 +682,13 @@ static bool run_success_case(
|
|||
}
|
||||
|
||||
/* Replay exercises the same production argv path and sync baseline. */
|
||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||
CHECK(trainlog_sync_run(persistent_alias ? TRAINLOG_SYNC_TRIGGER_ANDROID :
|
||||
TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
||||
CHECK(report.success);
|
||||
CHECK(strstr(report.summary,
|
||||
"SYNC=PASS AI_EXPORT=PASS GDRIVE_UPLOAD=PASS") != NULL);
|
||||
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
|
||||
CHECK(successful_journal(case_root, report.sync_id));
|
||||
if (persistent_alias) {
|
||||
|
|
@ -628,7 +762,7 @@ static bool run_missing_profile_companion_case(const char *case_root)
|
|||
remote_profile_available = false;
|
||||
CHECK(prepare_database(case_root, false, false,
|
||||
database_path, sizeof(database_path)));
|
||||
CHECK(write_artifacts(CANONICAL_ID, CANONICAL_ID, false));
|
||||
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, false));
|
||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
|
|
@ -642,6 +776,139 @@ static bool run_missing_profile_companion_case(const char *case_root)
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool run_incoming_alias_case(const char *case_root)
|
||||
{
|
||||
TrainlogSyncReport report;
|
||||
char database_path[4096];
|
||||
remote_alias_available = true;
|
||||
remote_mobile_name = "trainlog-mobile-export-v3.json";
|
||||
remote_mobile_version = 3U;
|
||||
CHECK(prepare_database(case_root, false, false,
|
||||
database_path, sizeof(database_path)));
|
||||
CHECK(add_live_source_exercise(database_path));
|
||||
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, true));
|
||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, false,
|
||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
||||
CHECK(report.success);
|
||||
CHECK(database_state(database_path, true, "back", "arms", 2U));
|
||||
CHECK(successful_journal(case_root, report.sync_id));
|
||||
remote_alias_available = false;
|
||||
remote_mobile_name = "trainlog-mobile-export-v2.json";
|
||||
remote_mobile_version = 2U;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool write_fake_alias_tool(const char *directory, const char *source)
|
||||
{
|
||||
char path[4096];
|
||||
FILE *file;
|
||||
CHECK(snprintf(path, sizeof(path), "%s/import_exercise_aliases.py",
|
||||
directory) > 0);
|
||||
file = fopen(path, "wb");
|
||||
CHECK(file != NULL);
|
||||
CHECK(fputs(source, file) >= 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool run_alias_diagnostic_case(
|
||||
const char *case_root,
|
||||
const char *tools_directory,
|
||||
const char *script,
|
||||
const char *expected
|
||||
)
|
||||
{
|
||||
static const char *const result_path =
|
||||
"/tmp/trainlog-exercise-aliases-result.txt";
|
||||
TrainlogSyncReport report;
|
||||
char database_path[4096];
|
||||
char result[256];
|
||||
FILE *file;
|
||||
size_t count;
|
||||
|
||||
CHECK(prepare_database(case_root, false, false,
|
||||
database_path, sizeof(database_path)));
|
||||
CHECK(write_artifacts(CANONICAL_ID, CANONICAL_ID, false));
|
||||
if (script != NULL) {
|
||||
CHECK(write_fake_alias_tool(tools_directory, script));
|
||||
}
|
||||
file = fopen(result_path, "wb");
|
||||
CHECK(file != NULL);
|
||||
CHECK(fputs("EXERCISE_ALIAS_EXPORT=PASS aliases=99\n", file) >= 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
CHECK(setenv("TRAINLOG_TOOLS_DIR", tools_directory, 1) == 0);
|
||||
remote_alias_available = true;
|
||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(!report.success);
|
||||
CHECK(strstr(report.error, expected) != NULL);
|
||||
CHECK(history_contains(case_root, expected));
|
||||
file = fopen(result_path, "rb");
|
||||
CHECK(file != NULL);
|
||||
count = fread(result, 1U, sizeof(result) - 1U, file);
|
||||
CHECK(ferror(file) == 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
result[count] = '\0';
|
||||
CHECK(strstr(result, "EXERCISE_ALIAS_EXPORT=PASS") == NULL);
|
||||
CHECK(unsetenv("TRAINLOG_TOOLS_DIR") == 0);
|
||||
remote_alias_available = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool run_ai_inbound_diagnostic_case(
|
||||
const char *case_root,
|
||||
const char *mode,
|
||||
const char *expected,
|
||||
bool repeat
|
||||
)
|
||||
{
|
||||
TrainlogSyncReport report;
|
||||
char database_path[4096];
|
||||
char source_path[4096];
|
||||
FILE *file;
|
||||
|
||||
CHECK(prepare_database(case_root, false, false,
|
||||
database_path, sizeof(database_path)));
|
||||
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, false));
|
||||
CHECK(snprintf(source_path, sizeof(source_path), "%s/ai-source.json",
|
||||
case_root) > 0);
|
||||
file = fopen(source_path, "wb");
|
||||
CHECK(file != NULL);
|
||||
CHECK(fprintf(file,
|
||||
"{\"format\":\"TRAINLOG_AI_SESSION_DRAFT\",\"version\":1,\"draft\":{"
|
||||
"\"draft_id\":\"aid_12345678-1234-4abc-8abc-123456789abc\","
|
||||
"\"created_at\":\"2032-01-01T02:00:00Z\",\"planned_for\":null,"
|
||||
"\"session_type\":\"training\",\"title\":null,\"notes\":null,"
|
||||
"\"entries\":[{\"position\":0,\"exercise_id\":\"%s\","
|
||||
"\"target_sets\":3,\"target_reps\":8,"
|
||||
"\"target_duration_seconds\":null,\"target_weight_kg\":null,"
|
||||
"\"rest_seconds\":60}]}}", CANONICAL_ID) > 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
CHECK(setenv("TRAINLOG_TEST_AI_SOURCE", source_path, 1) == 0);
|
||||
CHECK(setenv("TRAINLOG_TEST_AI_INBOUND_MODE", mode, 1) == 0);
|
||||
{
|
||||
TrainlogStatus status = trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI,
|
||||
false, TRAINLOG_SYNC_ANDROID_TO_PC, &report);
|
||||
if (status != TRAINLOG_STATUS_OK)
|
||||
(void)fprintf(stderr, "AI diagnostic sync failure: %s\n", report.error);
|
||||
CHECK(status == TRAINLOG_STATUS_OK);
|
||||
}
|
||||
CHECK(report.success);
|
||||
CHECK(strstr(report.summary, expected) != NULL);
|
||||
CHECK(history_contains(case_root, expected));
|
||||
if (repeat) {
|
||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
||||
CHECK(report.success);
|
||||
CHECK(strstr(report.summary, "AI_INBOUND=ALREADY_IMPORTED") != NULL);
|
||||
CHECK(history_contains(case_root, "AI_INBOUND=ALREADY_IMPORTED"));
|
||||
}
|
||||
CHECK(unsetenv("TRAINLOG_TEST_AI_INBOUND_MODE") == 0);
|
||||
CHECK(unsetenv("TRAINLOG_TEST_AI_SOURCE") == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool run_all(void)
|
||||
{
|
||||
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
|
||||
|
|
@ -650,9 +917,21 @@ static bool run_all(void)
|
|||
char case_c[4096];
|
||||
char case_d[4096];
|
||||
char case_e[4096];
|
||||
char case_f[4096];
|
||||
char case_g[4096];
|
||||
char case_h[4096];
|
||||
char case_i[4096];
|
||||
char case_j[4096];
|
||||
char case_k[4096];
|
||||
char case_l[4096];
|
||||
char case_m[4096];
|
||||
char case_n[4096];
|
||||
char case_o[4096];
|
||||
char fake_tools[4096];
|
||||
char *root = mkdtemp(temporary);
|
||||
|
||||
CHECK(root != NULL);
|
||||
CHECK(install_fake_rclone(root));
|
||||
CHECK(snprintf(remote_root, sizeof(remote_root), "%s/remote", root) > 0);
|
||||
CHECK(mkdir(remote_root, 0700) == 0);
|
||||
CHECK(snprintf(case_a, sizeof(case_a), "%s/case-a", root) > 0);
|
||||
|
|
@ -660,12 +939,51 @@ static bool run_all(void)
|
|||
CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", root) > 0);
|
||||
CHECK(snprintf(case_d, sizeof(case_d), "%s/case-d", root) > 0);
|
||||
CHECK(snprintf(case_e, sizeof(case_e), "%s/case-e", root) > 0);
|
||||
CHECK(snprintf(case_f, sizeof(case_f), "%s/case-f", root) > 0);
|
||||
CHECK(snprintf(case_g, sizeof(case_g), "%s/case-g", root) > 0);
|
||||
CHECK(snprintf(case_h, sizeof(case_h), "%s/case-h", root) > 0);
|
||||
CHECK(snprintf(case_i, sizeof(case_i), "%s/case-i", root) > 0);
|
||||
CHECK(snprintf(case_j, sizeof(case_j), "%s/case-j", root) > 0);
|
||||
CHECK(snprintf(case_k, sizeof(case_k), "%s/case-k", root) > 0);
|
||||
CHECK(snprintf(case_l, sizeof(case_l), "%s/case-l", root) > 0);
|
||||
CHECK(snprintf(case_m, sizeof(case_m), "%s/case-m", root) > 0);
|
||||
CHECK(snprintf(case_n, sizeof(case_n), "%s/case-n", root) > 0);
|
||||
CHECK(snprintf(case_o, sizeof(case_o), "%s/case-o", root) > 0);
|
||||
CHECK(snprintf(fake_tools, sizeof(fake_tools), "%s/fake-tools", root) > 0);
|
||||
CHECK(mkdir(fake_tools, 0700) == 0);
|
||||
|
||||
CHECK(run_success_case(case_a, false));
|
||||
CHECK(run_success_case(case_b, true));
|
||||
CHECK(rclone_upload_count() == 4U);
|
||||
CHECK(run_ai_inbound_diagnostic_case(case_j, "invalid",
|
||||
"AI_INBOUND=REJECTED", false));
|
||||
CHECK(run_ai_inbound_diagnostic_case(case_k, "archive_fail",
|
||||
"AI_INBOUND=ARCHIVE_FAIL", false));
|
||||
CHECK(run_ai_inbound_diagnostic_case(case_l, "none",
|
||||
"AI_INBOUND=NONE", false));
|
||||
CHECK(run_ai_inbound_diagnostic_case(case_m, "already",
|
||||
"AI_INBOUND=IMPORTED", true));
|
||||
/* Marker-like diagnostic text is not a result line; the final exact
|
||||
* helper marker, including a later failure, remains authoritative. */
|
||||
CHECK(run_ai_inbound_diagnostic_case(case_n, "marker_prefix",
|
||||
"AI_INBOUND=DRIVE_FAIL", false));
|
||||
CHECK(run_ai_inbound_diagnostic_case(case_o, "marker_suffix",
|
||||
"AI_INBOUND=DRIVE_FAIL", false));
|
||||
CHECK(run_unknown_without_proof_case(case_c));
|
||||
CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d));
|
||||
CHECK(run_missing_profile_companion_case(case_e));
|
||||
CHECK(run_incoming_alias_case(case_f));
|
||||
CHECK(run_alias_diagnostic_case(case_g, fake_tools, NULL,
|
||||
"outil Python introuvable"));
|
||||
CHECK(run_alias_diagnostic_case(case_h, fake_tools,
|
||||
"import argparse\nargparse.ArgumentParser().parse_args()\n",
|
||||
"unrecognized arguments"));
|
||||
CHECK(run_alias_diagnostic_case(case_i, fake_tools,
|
||||
"raise RuntimeError('alias importer synthetic exception')\n",
|
||||
"RuntimeError: alias importer synthetic exception"));
|
||||
/* INVARIANT: none of the three failed synchronization paths may start a
|
||||
* post-sync Drive upload, even when an older export remains on disk. */
|
||||
CHECK(rclone_upload_count() == 14U);
|
||||
(void)puts("PASS production sync body-zone V2 proof wiring");
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ int main(void)
|
|||
|
||||
size_t device_count = 0U;
|
||||
size_t storage_count = 0U;
|
||||
uint32_t download_id = 0U;
|
||||
uint32_t documents_id = 0U;
|
||||
uint32_t trainlog_id = 0U;
|
||||
uint32_t export_id = 0U;
|
||||
uint64_t export_size = 0U;
|
||||
|
|
@ -240,14 +240,14 @@ int main(void)
|
|||
&devices[0],
|
||||
storages[0].storage_id,
|
||||
UINT32_MAX,
|
||||
"Download",
|
||||
&download_id
|
||||
"Documents",
|
||||
&documents_id
|
||||
);
|
||||
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
(void)fprintf(
|
||||
stderr,
|
||||
"Download folder not found\n"
|
||||
"Documents folder not found\n"
|
||||
);
|
||||
|
||||
return 1;
|
||||
|
|
@ -257,7 +257,7 @@ int main(void)
|
|||
find_child_folder(
|
||||
&devices[0],
|
||||
storages[0].storage_id,
|
||||
download_id,
|
||||
documents_id,
|
||||
"Trainlog",
|
||||
&trainlog_id
|
||||
);
|
||||
|
|
@ -265,7 +265,7 @@ int main(void)
|
|||
if (status != TRAINLOG_STATUS_OK) {
|
||||
(void)fprintf(
|
||||
stderr,
|
||||
"Download/Trainlog not found\n"
|
||||
"Documents/Trainlog not found\n"
|
||||
);
|
||||
|
||||
return 1;
|
||||
|
|
@ -329,7 +329,7 @@ int main(void)
|
|||
);
|
||||
|
||||
(void)printf(
|
||||
"remote=Download/Trainlog/"
|
||||
"remote=Documents/Trainlog/"
|
||||
"trainlog-mobile-export-v1.json\n"
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue