diff --git a/CHANGELOG.md b/CHANGELOG.md index e587d0b..aa19e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -185,3 +185,17 @@ Next: - added persistent body measurement recording; - validated the application on a real Samsung device through ADB. + + +### Bidirectional synchronization foundation + +- validated Android-to-PC domain snapshot transfer through direct MTP; +- added strict transactional and idempotent desktop mobile import; +- added canonical PC exercise-catalog export; +- validated PC-to-Android catalog publication through direct MTP; +- added Android Storage Access Framework access for PC-created catalog files; +- made the Android synchronization folder selection recoverable/changeable; +- kept synchronization artifacts separate from frozen Trainlog session JSON v1; +- documented the next synchronization architecture: structured history, + detailed sync inspection, common sync engine and PC-side `trainlog-syncd`. + diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 59a3038..2985b69 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -48,6 +48,8 @@ dependencies { "androidx.compose.ui:ui-tooling-preview" ) + implementation("androidx.documentfile:documentfile:1.1.0") + debugImplementation( "androidx.compose.ui:ui-tooling" ) diff --git a/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt index 1890355..1e9a62a 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt @@ -4,6 +4,9 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.data.SyncExporter +import com.labfytools.trainlog.data.SyncCatalogInbox +import com.labfytools.trainlog.data.SyncRequestOutbox import com.labfytools.trainlog.ui.TrainlogApp import com.labfytools.trainlog.ui.theme.TrainlogTheme @@ -18,10 +21,31 @@ class MainActivity : ComponentActivity() { applicationContext ) + val exporter = + SyncExporter( + applicationContext, + repository, + ) + + val inbox = + SyncCatalogInbox( + applicationContext, + repository, + ) + + val requestOutbox = + SyncRequestOutbox( + applicationContext + ) + setContent { TrainlogTheme { TrainlogApp( - repository = repository + repository = repository, + exporter = exporter, + inbox = inbox, + requestOutbox = + requestOutbox, ) } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt index 93b1be2..15c1705 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt @@ -31,6 +31,7 @@ import com.labfytools.trainlog.ui.theme.TrainlogTypography @Composable fun BodyScreen( repository: TrainlogRepository, + onBodySaved: () -> Unit, onBack: () -> Unit, ) { val colors = @@ -415,6 +416,8 @@ fun BodyScreen( revision += 1 message = "Mensurations enregistrées." + + onBodySaved() } SaveBodyObservationResult.Invalid -> { diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt index 2de7c76..50a56a2 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt @@ -8,6 +8,7 @@ fun HomeScreen( onExercise: () -> Unit, onBody: () -> Unit, onHistory: () -> Unit, + onSync: () -> Unit, ) { TrainlogScreen( subtitle = "A C C U E I L" @@ -52,6 +53,16 @@ fun HomeScreen( ) } + TrainlogFrame( + title = "SYNCHRONISATION" + ) { + TrainlogAction( + label = "Synchroniser avec le PC", + description = "Préparer les données pour le transport MTP.", + onClick = onSync, + ) + } + TrainlogFrame( title = "STATUT", active = false, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index bcd0e8a..f6e75a1 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -36,6 +36,7 @@ fun SessionScreen( catalogRevision: Int, onBack: () -> Unit, onCreateExercise: () -> Unit, + onSessionSaved: () -> Unit, ) { val colors = LocalTrainlogColors.current @@ -240,6 +241,8 @@ fun SessionScreen( message = "Séance enregistrée." + + onSessionSaved() } SaveSessionResult.Invalid -> { diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt new file mode 100644 index 0000000..8cf747b --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt @@ -0,0 +1,297 @@ +package com.labfytools.trainlog.ui + +/* TRAINLOG_SYNC_AUTO_APPLY */ + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.labfytools.trainlog.data.CatalogInboxResult +import com.labfytools.trainlog.data.SyncCatalogInbox +import com.labfytools.trainlog.data.SyncRequestOutbox +import com.labfytools.trainlog.data.SyncRequestResult +import com.labfytools.trainlog.ui.theme.LocalTrainlogColors + +@Composable +fun SyncScreen( + inbox: SyncCatalogInbox, + requestOutbox: SyncRequestOutbox, + onCatalogChanged: () -> Unit, + onBack: () -> Unit, +) { + val colors = + LocalTrainlogColors.current + + var status by + remember { + mutableStateOf( + null + ) + } + + var success by + remember { + mutableStateOf(false) + } + + var folderAuthorized by + remember { + mutableStateOf( + inbox.hasFolderAccess() + ) + } + + LaunchedEffect(Unit) { + when ( + val result = + inbox.importPcCatalog() + ) { + is CatalogInboxResult.Imported -> { + if ( + result.imported > 0 || + result.reconciled > 0 + ) { + success = true + + status = + ( + "Catalogue PC appliqué automatiquement : " + + "${result.imported} nouveau(x), " + + "${result.reconciled} réconcilié(s)." + ) + + onCatalogChanged() + } + } + + CatalogInboxResult.FolderNotAuthorized, + CatalogInboxResult.FileNotFound, + is CatalogInboxResult.Error -> { + /* Nothing to import yet. */ + } + } + } + + val folderLauncher = + rememberLauncherForActivityResult( + contract = + ActivityResultContracts + .OpenDocumentTree(), + ) { + 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) { + when ( + val result = + 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 the permission status already shown. */ + } + } + } + } + } + + TrainlogScreen( + subtitle = + "S Y N C H R O N I S A T I O N" + ) { + TrainlogAction( + label = "< Retour", + description = + "Revenir à l'accueil.", + onClick = onBack, + accent = colors.muted, + ) + + TrainlogFrame( + title = "SYNCHRONISER" + ) { + TrainlogInfo( + "Le snapshot Android est maintenu automatiquement.", + color = colors.accent, + ) + + TrainlogAction( + label = + "Synchroniser maintenant", + description = + "Envoie une demande au service Trainlog du PC.", + accent = + colors.success, + onClick = { + when ( + val result = + requestOutbox + .requestSync() + ) { + is SyncRequestResult.Requested -> { + success = true + + status = + ( + "Demande envoyée : " + + result.requestId + ) + } + + SyncRequestResult.Unsupported -> { + success = false + + status = + "Android non supporté." + } + + is SyncRequestResult.Error -> { + success = false + + status = + result.message + } + } + }, + ) + } + + TrainlogFrame( + title = + "CATALOGUE PC → ANDROID" + ) { + if (folderAuthorized) { + TrainlogInfo( + "Dossier Trainlog autorisé.", + color = + colors.success, + ) + } + + TrainlogAction( + label = + if (folderAuthorized) { + "Changer le dossier Trainlog" + } else { + "Autoriser le dossier Trainlog" + }, + description = + "Choisir Téléchargements/Trainlog.", + onClick = { + folderLauncher.launch( + null + ) + }, + accent = + colors.warning, + ) + + TrainlogAction( + label = + "Appliquer le dernier catalogue PC", + description = + "Réconcilie les exercices publiés par le PC.", + onClick = { + when ( + val result = + inbox + .importPcCatalog() + ) { + is CatalogInboxResult.Imported -> { + success = true + + status = + ( + "Catalogue PC : " + + "${result.imported} nouveau(x), " + + "${result.reconciled} réconcilié(s), " + + "${result.skipped} déjà présent(s)." + ) + + onCatalogChanged() + } + + CatalogInboxResult.FolderNotAuthorized -> { + success = false + + status = + "Autorisez d'abord Download/Trainlog." + } + + CatalogInboxResult.FileNotFound -> { + success = false + + status = + "Aucun catalogue PC reçu." + } + + is CatalogInboxResult.Error -> { + success = false + + status = + result.message + } + } + }, + ) + } + + if (status != null) { + TrainlogFrame( + title = "ETAT", + active = false, + ) { + TrainlogInfo( + text = + status.orEmpty(), + color = + if (success) { + colors.success + } else { + colors.error + }, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index cd57cab..60b6b60 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -1,13 +1,20 @@ package com.labfytools.trainlog.ui +/* TRAINLOG_PC_CATALOG_AUTO_APPLY */ + import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.data.SyncExporter +import com.labfytools.trainlog.data.CatalogInboxResult +import com.labfytools.trainlog.data.SyncCatalogInbox +import com.labfytools.trainlog.data.SyncRequestOutbox private enum class TrainlogScreenId { HOME, @@ -16,11 +23,15 @@ private enum class TrainlogScreenId { BODY, HISTORY, SESSION_DETAIL, + SYNC, } @Composable fun TrainlogApp( repository: TrainlogRepository, + exporter: SyncExporter, + inbox: SyncCatalogInbox, + requestOutbox: SyncRequestOutbox, ) { var screen by remember { @@ -48,6 +59,26 @@ fun TrainlogApp( ) } + LaunchedEffect(Unit) { + when ( + inbox.importPcCatalog() + ) { + is CatalogInboxResult.Imported -> { + catalogRevision += 1 + } + + CatalogInboxResult.FolderNotAuthorized, + CatalogInboxResult.FileNotFound, + is CatalogInboxResult.Error -> { + /* Nothing to import yet. */ + } + } + } + + LaunchedEffect(Unit) { + exporter.exportMobileBundle() + } + if ( screen != TrainlogScreenId.HOME @@ -89,6 +120,10 @@ fun TrainlogApp( screen = TrainlogScreenId.HISTORY }, + onSync = { + screen = + TrainlogScreenId.SYNC + }, ) TrainlogScreenId.SESSION -> @@ -107,6 +142,9 @@ fun TrainlogApp( screen = TrainlogScreenId.EXERCISE }, + onSessionSaved = { + exporter.exportMobileBundle() + }, ) TrainlogScreenId.EXERCISE -> @@ -120,6 +158,8 @@ fun TrainlogApp( exerciseReturnTarget }, onSaved = { + exporter.exportMobileBundle() + catalogRevision += 1 screen = @@ -130,6 +170,9 @@ fun TrainlogApp( TrainlogScreenId.BODY -> BodyScreen( repository = repository, + onBodySaved = { + exporter.exportMobileBundle() + }, onBack = { screen = TrainlogScreenId.HOME @@ -163,5 +206,21 @@ fun TrainlogApp( TrainlogScreenId.HISTORY }, ) + + TrainlogScreenId.SYNC -> + SyncScreen( + inbox = inbox, + requestOutbox = + requestOutbox, + onCatalogChanged = { + exporter.exportMobileBundle() + + catalogRevision += 1 + }, + onBack = { + screen = + TrainlogScreenId.HOME + }, + ) } } diff --git a/docs/android.md b/docs/android.md index 60f013b..dd2a93e 100644 --- a/docs/android.md +++ b/docs/android.md @@ -630,3 +630,81 @@ Next: ANDROID_MTP_SYNC=NEXT ``` + + +## MTP mobile export v1 + +Android now prepares a versioned full mobile snapshot at: + +```text +Download/Trainlog/trainlog-mobile-export-v1.json +``` + +The file contains: + +```text +exercise profiles +sessions +body observations +``` + +It is explicitly separate from frozen `TRAINLOG_FORMAT_V1`. + +Desktop direct-MTP validation is available through: + +```text +./build/tui/trainlog-mtp-mobile-export-probe +``` + +The probe traverses: + +```text +internal storage +→ Download +→ Trainlog +→ trainlog-mobile-export-v1.json +``` + +and downloads it directly through libmtp without a mount. + +Next after hardware PASS: + +```text +DESKTOP_MOBILE_EXPORT_IMPORT=NEXT +PC_TO_ANDROID_CATALOG=AFTER +``` + + + +## Android synchronization folder + +PC-created synchronization artifacts are consumed through a persistent Storage +Access Framework grant. + +Canonical selected folder: + +```text +Download/Trainlog +``` + +The Sync screen always exposes the folder-selection action. + +When a folder is already authorized, the action becomes: + +```text +Changer le dossier Trainlog +``` + +This is required so a wrong persisted folder selection can be corrected without +clearing the Android application database. + +Validated PC catalog publication: + +```text +trainlog-pc-catalog-v1.json +``` + +The final Android synchronization workflow must evolve toward a single +`Synchroniser maintenant` action backed by a PC-side synchronization agent, +rather than manual export/import steps. + diff --git a/docs/reviews/bidirectional_sync_checkpoint.md b/docs/reviews/bidirectional_sync_checkpoint.md new file mode 100644 index 0000000..d1b606b --- /dev/null +++ b/docs/reviews/bidirectional_sync_checkpoint.md @@ -0,0 +1,223 @@ +# Bidirectional synchronization checkpoint + +## Validated status + +```text +ANDROID_LOCAL_WORKFLOWS=PASS + +ANDROID_TO_PC_MTP=PASS +DESKTOP_MOBILE_IMPORT_V1=PASS +DESKTOP_MOBILE_IMPORT_IDEMPOTENT=PASS + +PC_CATALOG_EXPORT_V1=PASS +PC_TO_ANDROID_MTP_PUBLISH=PASS + +ANDROID_SAF_FOLDER_SELECTION=PASS +ANDROID_SAF_FOLDER_CHANGE=PASS +``` + +This checkpoint represents the validated synchronization foundation on the +physical Samsung device. + +## Android → PC + +Android produces: + +```text +Download/Trainlog/trainlog-mobile-export-v1.json +``` + +The desktop retrieves it through direct libmtp. + +No GVFS/FUSE mount is used. + +The desktop importer: + +```text +tools/import_mobile_export.py +``` + +is: + +```text +strict +transactional +idempotent by stable IDs +profile-aware +``` + +Validated behavior: + +```text +first import: + new exercise/session/body observation imported + +second import: + no duplicates + existing stable IDs skipped +``` + +## PC → Android + +The desktop produces: + +```text +/tmp/trainlog-pc-catalog-v1.json +``` + +with: + +```text +format = trainlog-pc-catalog +version = 1 +``` + +The catalog is published by direct MTP to: + +```text +Download/Trainlog/trainlog-pc-catalog-v1.json +``` + +Physical-device publication has been validated. + +The Android application accesses the PC-created file through one persistent +Storage Access Framework grant. + +The selected folder must be: + +```text +Download/Trainlog +``` + +The Sync screen must always allow changing this folder because a wrong +persisted SAF grant must be recoverable without clearing application data. + +## Important semantics + +The synchronization layer exchanges versioned Trainlog domain artifacts. + +It does not synchronize SQLite files. + +The frozen legacy session format remains: + +```text +TRAINLOG_FORMAT_V1=FROZEN +``` + +Profile-aware synchronization uses separate explicit synchronization artifacts. + +## Current TUI state + +The existing desktop Sync action can: + +```text +Android → PC import +PC → Android catalog publication +``` + +The current status/count-oriented Sync presentation is temporary. + +A snapshot file remaining on Android is not a pending queue item. Therefore +labels such as: + +```text +1 JSON candidate +``` + +must not be considered a final synchronization UX. + +## Next synchronization UX + +The TUI Sync page must become a persistent synchronization history, similar to: + +```text +git log +``` + +Example: + +```text +06/09/2026 16:00 ✓ Android +1 session · PC catalog 3 exercises +06/09/2026 15:42 ✓ no changes +06/09/2026 15:31 ! device disconnected +``` + +A synchronization entry must be selectable. + +`Enter` opens a detailed view analogous to: + +```text +git show +``` + +The detail must include: + +```text +sync ID +trigger +start/end local time +status + +Android → PC + exercises imported/reconciled/skipped + sessions imported/skipped + body observations imported/skipped + +PC → Android + catalog exercises published + Android catalog apply result when available + +transport/artifact diagnostics +``` + +## Android-triggered synchronization + +The final Android Sync UX must not require: + +```text +Prepare export +then use the PC manually +``` + +Target behavior: + +```text +Android data change + -> mobile snapshot maintained automatically + +Android "Synchronize now" + -> synchronization request + +PC trainlog-syncd + -> detects request over direct MTP + -> runs the same bidirectional sync engine + -> writes synchronization receipt + +Android + -> reads receipt + -> applies PC catalog + -> displays final synchronization result +``` + +MTP remains host-initiated. Therefore Android-triggered synchronization needs +a small PC-side agent; it cannot directly command libmtp operations on the PC. + +## Display normalization + +User-facing session history must use local presentation: + +```text +DD/MM/YYYY HH:MM +``` + +Canonical RFC3339 timestamps remain unchanged in persistence and exchange. + +## Next cursor + +```text +SYNC_HISTORY_GIT_LIKE=NEXT +COMMON_SYNC_ENGINE=NEXT +TRAINLOG_SYNCD=NEXT +ANDROID_SYNC_REQUEST_RECEIPT=AFTER +ANDROID_AUTO_OUTBOX=AFTER_AGENT_FOUNDATION +``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 028c84b..f77cf0a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -376,3 +376,130 @@ TRAINLOG_FORMAT_V1 remains frozen profile-aware data must not be forced into v1 ``` + + +## MTP mobile export v1 + +Android now prepares a versioned full mobile snapshot at: + +```text +Download/Trainlog/trainlog-mobile-export-v1.json +``` + +The file contains: + +```text +exercise profiles +sessions +body observations +``` + +It is explicitly separate from frozen `TRAINLOG_FORMAT_V1`. + +Desktop direct-MTP validation is available through: + +```text +./build/tui/trainlog-mtp-mobile-export-probe +``` + +The probe traverses: + +```text +internal storage +→ Download +→ Trainlog +→ trainlog-mobile-export-v1.json +``` + +and downloads it directly through libmtp without a mount. + +Next after hardware PASS: + +```text +DESKTOP_MOBILE_EXPORT_IMPORT=NEXT +PC_TO_ANDROID_CATALOG=AFTER +``` + + + +## Mobile import cursor + +```text +MOBILE_EXPORT_MTP=PASS +DESKTOP_MOBILE_IMPORT_V1=IMPLEMENTED +TUI_SYNC_ACTION=NEXT +PC_TO_ANDROID_CATALOG=AFTER +``` + +The CLI importer is the reference import engine for the next TUI Sync action. + + + +## Sync implementation cursor + +```text +MOBILE_EXPORT_MTP=PASS +DESKTOP_MOBILE_IMPORT_V1=PASS +TUI_ANDROID_TO_PC_SYNC_ACTION=IMPLEMENTED +TUI_ANDROID_TO_PC_SYNC_HARDWARE_VALIDATION=NEXT +PC_TO_ANDROID_CATALOG=AFTER +``` + + + +## Bidirectional sync cursor + +```text +ANDROID_TO_PC_MTP=PASS +DESKTOP_MOBILE_IMPORT=PASS +PC_TO_ANDROID_CATALOG=IMPLEMENTED +SYNC_HISTORY_UI=IMPLEMENTED +BIDIRECTIONAL_HARDWARE_VALIDATION=NEXT +``` + + + +## Sync agent cursor + +```text +ANDROID_AUTO_OUTBOX=IMPLEMENTED +ANDROID_SYNC_REQUEST=IMPLEMENTED + +TRAINLOG_SYNCD=NEXT +ANDROID_SYNC_RECEIPT=AFTER +TUI_SYNC_LOG_SHOW=AFTER_AGENT_FOUNDATION +``` + + + +## Synchronization cursor + +```text +ANDROID_LOCAL_WORKFLOWS=PASS + +ANDROID_TO_PC_MTP=PASS +DESKTOP_MOBILE_IMPORT_V1=PASS +DESKTOP_MOBILE_IMPORT_IDEMPOTENT=PASS + +PC_CATALOG_EXPORT_V1=PASS +PC_TO_ANDROID_MTP_PUBLISH=PASS + +ANDROID_SAF_FOLDER_CHANGE=PASS + +SYNC_HISTORY_GIT_LIKE=NEXT +COMMON_SYNC_ENGINE=NEXT +TRAINLOG_SYNCD=NEXT +ANDROID_REQUEST_RECEIPT=AFTER +AUTO_OUTBOX=AFTER_AGENT_FOUNDATION +``` + +Do not regress to: + +```text +SQLite file synchronization +filesystem mounts +exercise-name heuristics +manual fake sets for continuous activities +overloading frozen Trainlog JSON v1 +``` + diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md new file mode 100644 index 0000000..5f6f93b --- /dev/null +++ b/docs/sync_exchange.md @@ -0,0 +1,284 @@ +# Trainlog synchronization exchange + +## Status + +```text +MOBILE_EXPORT_V1=FROZEN_FOR_IMPLEMENTATION +TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED +``` + +This document defines a synchronization artifact. It is not the frozen +Trainlog session JSON v1 format. + +## Android → PC artifact + +Shared-storage path: + +```text +Download/Trainlog/trainlog-mobile-export-v1.json +``` + +Format header: + +```json +{ + "format": "trainlog-mobile-export", + "version": 1 +} +``` + +The artifact is a complete idempotent mobile snapshot containing: + +```text +exercises +sessions +body_observations +``` + +### Exercises + +Each exercise contains: + +```text +exercise_id +name +recording_mode +tracking_mode +data_fields +``` + +### Sessions + +Each session contains: + +```text +session_id +started_at +session_type +exercises +``` + +Each session exercise snapshots: + +```text +exercise_id +name +recording_mode +tracking_mode +data_fields +load_mode +rest_seconds +``` + +For `SETS`: + +```text +sets[] + reps +or + duration_seconds +``` + +For `CONTINUOUS`: + +```text +continuous + duration_seconds + speed_kmh optional + distance_km optional +``` + +A continuous exercise has no synthetic set. + +### Body observations + +Each body observation contains: + +```text +observation_id +observed_at +only the metrics actually measured +``` + +## Transport + +Android writes its own export into shared Downloads storage. + +Desktop reads the artifact through direct libmtp transport. + +No filesystem mount is required. + +No SQLite database file is transferred. + +## PC → Android + +A separate canonical catalog artifact will be defined and implemented after +Android → PC export is validated on physical hardware. + +The PC → Android path must not overload frozen Trainlog JSON v1. + + +## Desktop import of mobile export v1 + +The desktop importer is: + +```text +tools/import_mobile_export.py +``` + +It validates the complete mobile snapshot before opening a write transaction. + +Properties: + +```text +transactional +idempotent by stable IDs +exercise reconciliation by normalized name +profile conflicts rejected +unknown JSON fields rejected +no SQLite file copying +``` + +For mobile `SETS` v1, the Android form records one uniform set metric. The +desktop importer derives: + +```text +target_sets = number of logged sets +target_reps or target_duration = uniform logged value +``` + +and preserves all performed sets separately. + +A v1 mobile session with heterogeneous set metrics or `0 reps` is rejected +rather than inventing a desktop target. + +Continuous activities remain target-less and are imported only into +`continuous_activity`. + +Recommended validation sequence: + +```bash +python tools/import_mobile_export.py /tmp/trainlog-mobile-export-v1.json --dry-run + +python tools/import_mobile_export.py /tmp/trainlog-mobile-export-v1.json +``` + +Running the real import a second time must import nothing new and report the +existing IDs as skipped. + + + +## Bidirectional synchronization v1 + +One desktop Sync action now performs both directions: + +```text +Android → PC + direct-MTP download + strict transactional import + +PC → Android + canonical PC exercise catalog export + direct-MTP publication +``` + +The Android app obtains one persistent Storage Access Framework grant for: + +```text +Download/Trainlog +``` + +After this one-time grant, Android can import the PC-created catalog without +broad storage permissions. + +The Sync page displays persistent synchronization history instead of remote +snapshot counts. A snapshot remaining present is not a pending queue item and +must not be shown as a "candidate". + +User-facing session history timestamps are displayed as: + +```text +DD/MM/YYYY HH:MM +``` + +Canonical RFC3339 storage remains unchanged. + + + +## Automatic Android outbox and sync request + +Android no longer requires a manual export action. + +The mobile snapshot is refreshed automatically on: + +```text +application start +exercise save +session save +body observation save +PC catalog apply +``` + +The Android Sync screen exposes: + +```text +Synchroniser maintenant +``` + +This writes: + +```text +Download/Trainlog/trainlog-sync-request-v1.json +``` + +with a stable request ID and timestamp. + +The next PC-agent slice consumes this request and writes a sync receipt. + + + +## Validated bidirectional transport checkpoint + +Validated on the physical Samsung device: + +```text +ANDROID_TO_PC_MTP=PASS +DESKTOP_MOBILE_IMPORT_V1=PASS +DESKTOP_MOBILE_IMPORT_IDEMPOTENT=PASS + +PC_CATALOG_EXPORT_V1=PASS +PC_TO_ANDROID_MTP_PUBLISH=PASS +``` + +Artifacts: + +```text +Android → PC + Download/Trainlog/trainlog-mobile-export-v1.json + +PC → Android + Download/Trainlog/trainlog-pc-catalog-v1.json +``` + +Both are synchronization artifacts and remain separate from frozen +`TRAINLOG_FORMAT_V1`. + +The Android Storage Access Framework folder grant must target: + +```text +Download/Trainlog +``` + +and the UI must permit changing the stored folder selection. + +Remaining synchronization work: + +```text +persistent structured sync history +selectable sync detail +common sync engine +trainlog-syncd +Android-triggered request/receipt workflow +automatic mobile snapshot maintenance +``` + diff --git a/docs/tui.md b/docs/tui.md index 206333a..3885515 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -763,3 +763,76 @@ Réalisé : activité continue Do not render set-oriented labels for a valid continuous activity. + + +## Android → PC synchronization action + +The Sync page now exposes: + +```text +s synchroniser +``` + +The action performs the complete validated chain: + +```text +detect exact MTP device +→ locate Download/Trainlog/trainlog-mobile-export-v1.json +→ direct libmtp download +→ transactional mobile-export importer +→ refresh desktop overview +``` + +No mount is used. + +The TUI resolves the reference importer relative to `/proc/self/exe`. In the +development layout this means: + +```text +build/tui/trainlog +→ ../../tools/import_mobile_export.py +``` + +and therefore also works when `trainlog` is launched through the user's +`~/.local/bin/trainlog` symlink. + + + +## Sync foundation checkpoint + +The desktop Sync backend has validated physical transport in both directions: + +```text +Android → PC mobile snapshot import +PC → Android canonical exercise catalog publication +``` + +Direct libmtp remains mandatory; no mount is introduced. + +The current category/count presentation is transitional. + +A persistent mobile snapshot is not a pending item, so a displayed +`JSON candidate count` must not be treated as the final synchronization model. + +Next TUI design: + +```text +HISTORIQUE DES SYNCHRONISATIONS + +↑/↓ select +Enter detail +s synchronize +r refresh +``` + +The history/detail interaction should follow the conceptual model of +`git log` / `git show`. + +User-facing session timestamps should be normalized to: + +```text +DD/MM/YYYY HH:MM +``` + +while stored timestamps remain RFC3339. + diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py new file mode 100755 index 0000000..5ff240f --- /dev/null +++ b/tools/export_pc_catalog.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import sqlite3 +from datetime import datetime +from pathlib import Path + + +def default_database_path(): + data_home = os.environ.get("XDG_DATA_HOME") + + if data_home: + return ( + Path(data_home) + / "trainlog" + / "trainlog.db" + ) + + return ( + Path.home() + / ".local" + / "share" + / "trainlog" + / "trainlog.db" + ) + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Export du catalogue canonique PC " + "vers un artifact Trainlog versionné." + ) + ) + + parser.add_argument( + "output", + type=Path, + ) + + parser.add_argument( + "--database", + type=Path, + default=default_database_path(), + ) + + args = parser.parse_args() + + if not args.database.exists(): + raise SystemExit( + "PC_CATALOG_EXPORT=FAIL database not found" + ) + + connection = sqlite3.connect( + args.database + ) + + try: + version = connection.execute( + "PRAGMA user_version;" + ).fetchone()[0] + + if version != 4: + raise SystemExit( + "PC_CATALOG_EXPORT=FAIL " + f"schema={version}" + ) + + rows = connection.execute( + ''' + SELECT + exercise_id, + name, + recording_mode, + tracking_mode, + data_fields + FROM exercises + ORDER BY + name COLLATE NOCASE, + exercise_id; + ''' + ).fetchall() + + payload = { + "format": "trainlog-pc-catalog", + "version": 1, + "generated_at": + datetime.now() + .astimezone() + .isoformat(), + "exercises": [ + { + "exercise_id": row[0], + "name": row[1], + "recording_mode": row[2], + "tracking_mode": row[3], + "data_fields": row[4], + } + for row in rows + ], + } + + args.output.parent.mkdir( + parents=True, + exist_ok=True, + ) + + args.output.write_text( + json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + ), + encoding="utf-8", + ) + + print("PC_CATALOG_EXPORT=PASS") + print(f"exercises={len(rows)}") + print(f"output={args.output}") + finally: + connection.close() + + +if __name__ == "__main__": + main() diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py new file mode 100755 index 0000000..ef6b424 --- /dev/null +++ b/tools/import_mobile_export.py @@ -0,0 +1,1336 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import sqlite3 +import sys +import unicodedata +from pathlib import Path + + +FORMAT = "trainlog-mobile-export" +VERSION = 1 +KNOWN_DATA_FIELDS = 3 + +TOP_LEVEL_KEYS = { + "format", + "version", + "generated_at", + "exercises", + "sessions", + "body_observations", +} + +EXERCISE_KEYS = { + "exercise_id", + "name", + "recording_mode", + "tracking_mode", + "data_fields", +} + +SESSION_KEYS = { + "session_id", + "started_at", + "session_type", + "exercises", +} + +SESSION_EXERCISE_KEYS = { + "exercise_id", + "name", + "recording_mode", + "tracking_mode", + "data_fields", + "load_mode", + "rest_seconds", + "sets", + "continuous", +} + +BODY_BASE_KEYS = { + "observation_id", + "observed_at", +} + +BODY_METRIC_KEYS = { + "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", +} + + +class ImportFailure(RuntimeError): + pass + + +def default_database_path(): + data_home = os.environ.get("XDG_DATA_HOME") + if data_home: + return Path(data_home) / "trainlog" / "trainlog.db" + + return ( + Path.home() + / ".local" + / "share" + / "trainlog" + / "trainlog.db" + ) + + +def require_exact_keys(value, allowed, required, label): + if not isinstance(value, dict): + raise ImportFailure(f"{label}: objet JSON attendu") + + actual = set(value.keys()) + unknown = actual - allowed + missing = required - actual + + if unknown: + names = ", ".join(sorted(unknown)) + raise ImportFailure( + f"{label}: champ(s) inconnu(s): {names}" + ) + + if missing: + names = ", ".join(sorted(missing)) + raise ImportFailure( + f"{label}: champ(s) manquant(s): {names}" + ) + + +def require_nonempty_string(value, label): + if not isinstance(value, str) or not value: + raise ImportFailure( + f"{label}: chaîne non vide attendue" + ) + + return value + + +def require_int(value, minimum, maximum, label): + if isinstance(value, bool) or not isinstance(value, int): + raise ImportFailure( + f"{label}: entier attendu" + ) + + if value < minimum or value > maximum: + raise ImportFailure( + f"{label}: hors bornes" + ) + + return value + + +def require_positive_number(value, label): + if isinstance(value, bool) or not isinstance( + value, + (int, float), + ): + raise ImportFailure( + f"{label}: nombre attendu" + ) + + parsed = float(value) + + if parsed <= 0.0: + raise ImportFailure( + f"{label}: nombre positif attendu" + ) + + return parsed + + +def normalize_name(value): + folded = unicodedata.normalize( + "NFC", + value.casefold(), + ) + + output = [] + pending_space = False + wrote_content = False + + for char in folded: + if char.isspace(): + if wrote_content: + pending_space = True + continue + + if pending_space: + output.append(" ") + pending_space = False + + output.append(char) + wrote_content = True + + normalized = "".join(output) + + if not normalized: + raise ImportFailure( + "nom d'exercice vide après normalisation" + ) + + return normalized + + +def load_payload(path): + try: + with path.open( + "r", + encoding="utf-8", + ) as handle: + payload = json.load(handle) + except (OSError, json.JSONDecodeError) as error: + raise ImportFailure( + f"lecture JSON impossible: {error}" + ) from error + + require_exact_keys( + payload, + TOP_LEVEL_KEYS, + TOP_LEVEL_KEYS, + "racine", + ) + + if payload["format"] != FORMAT: + raise ImportFailure( + "format mobile export invalide" + ) + + if payload["version"] != VERSION: + raise ImportFailure( + "version mobile export non supportée" + ) + + require_nonempty_string( + payload["generated_at"], + "generated_at", + ) + + for key in ( + "exercises", + "sessions", + "body_observations", + ): + if not isinstance(payload[key], list): + raise ImportFailure( + f"{key}: tableau attendu" + ) + + return payload + + +def validate_profile( + recording_mode, + tracking_mode, + data_fields, + label, +): + if recording_mode not in ( + "sets", + "continuous", + ): + raise ImportFailure( + f"{label}.recording_mode invalide" + ) + + if tracking_mode not in ( + "reps", + "duration", + ): + raise ImportFailure( + f"{label}.tracking_mode invalide" + ) + + require_int( + data_fields, + 0, + KNOWN_DATA_FIELDS, + f"{label}.data_fields", + ) + + if data_fields & ~KNOWN_DATA_FIELDS: + raise ImportFailure( + f"{label}.data_fields inconnu" + ) + + if ( + recording_mode == "continuous" + and tracking_mode != "duration" + ): + raise ImportFailure( + f"{label}: continuous exige duration" + ) + + +def validate_exercises(payload): + seen_ids = set() + + for index, item in enumerate( + payload["exercises"] + ): + label = f"exercises[{index}]" + + require_exact_keys( + item, + EXERCISE_KEYS, + EXERCISE_KEYS, + label, + ) + + exercise_id = require_nonempty_string( + item["exercise_id"], + f"{label}.exercise_id", + ) + + if exercise_id in seen_ids: + raise ImportFailure( + f"{label}: exercise_id dupliqué" + ) + + seen_ids.add(exercise_id) + + require_nonempty_string( + item["name"], + f"{label}.name", + ) + + validate_profile( + item["recording_mode"], + item["tracking_mode"], + item["data_fields"], + label, + ) + + return seen_ids + + +def validate_set_item( + value, + tracking_mode, + label, +): + if tracking_mode == "reps": + require_exact_keys( + value, + {"reps"}, + {"reps"}, + label, + ) + + reps = require_int( + value["reps"], + 0, + 10000, + f"{label}.reps", + ) + + return ("reps", reps) + + require_exact_keys( + value, + {"duration_seconds"}, + {"duration_seconds"}, + label, + ) + + duration = require_int( + value["duration_seconds"], + 1, + 86400, + f"{label}.duration_seconds", + ) + + return ("duration", duration) + + +def validate_session_exercise( + item, + label, + known_exercise_ids, +): + require_exact_keys( + item, + SESSION_EXERCISE_KEYS, + SESSION_EXERCISE_KEYS + - {"sets", "continuous"}, + label, + ) + + exercise_id = require_nonempty_string( + item["exercise_id"], + f"{label}.exercise_id", + ) + + if exercise_id not in known_exercise_ids: + raise ImportFailure( + f"{label}: exercice absent du snapshot" + ) + + require_nonempty_string( + item["name"], + f"{label}.name", + ) + + recording_mode = item["recording_mode"] + tracking_mode = item["tracking_mode"] + + validate_profile( + recording_mode, + tracking_mode, + item["data_fields"], + label, + ) + + if item["load_mode"] != "none": + raise ImportFailure( + f"{label}: mobile export v1 exige load_mode=none" + ) + + if item["rest_seconds"] != 0: + raise ImportFailure( + f"{label}: mobile export v1 exige rest_seconds=0" + ) + + if recording_mode == "continuous": + if "sets" in item: + raise ImportFailure( + f"{label}: continuous ne doit pas avoir sets" + ) + + if "continuous" not in item: + raise ImportFailure( + f"{label}: continuous manquant" + ) + + continuous = item["continuous"] + + allowed = { + "duration_seconds", + "speed_kmh", + "distance_km", + } + + require_exact_keys( + continuous, + allowed, + {"duration_seconds"}, + f"{label}.continuous", + ) + + require_int( + continuous["duration_seconds"], + 1, + 86400, + f"{label}.continuous.duration_seconds", + ) + + wants_speed = ( + item["data_fields"] & 1 + ) != 0 + + wants_distance = ( + item["data_fields"] & 2 + ) != 0 + + has_speed = "speed_kmh" in continuous + has_distance = ( + "distance_km" in continuous + ) + + if wants_speed != has_speed: + raise ImportFailure( + f"{label}: présence speed_kmh incohérente" + ) + + if wants_distance != has_distance: + raise ImportFailure( + f"{label}: présence distance_km incohérente" + ) + + if has_speed: + require_positive_number( + continuous["speed_kmh"], + f"{label}.continuous.speed_kmh", + ) + + if has_distance: + require_positive_number( + continuous["distance_km"], + f"{label}.continuous.distance_km", + ) + + return + + if "continuous" in item: + raise ImportFailure( + f"{label}: sets ne doit pas avoir continuous" + ) + + if "sets" not in item: + raise ImportFailure( + f"{label}: sets manquant" + ) + + sets = item["sets"] + + if not isinstance(sets, list) or not sets: + raise ImportFailure( + f"{label}.sets: tableau non vide attendu" + ) + + metric_values = [] + + for set_index, set_item in enumerate(sets): + _, metric = validate_set_item( + set_item, + tracking_mode, + f"{label}.sets[{set_index}]", + ) + + metric_values.append(metric) + + if len(set(metric_values)) != 1: + raise ImportFailure( + f"{label}: mobile export v1 exige des séries uniformes" + ) + + if ( + tracking_mode == "reps" + and metric_values[0] < 1 + ): + raise ImportFailure( + f"{label}: mobile export v1 ne peut pas dériver une cible depuis 0 reps" + ) + + +def validate_sessions( + payload, + known_exercise_ids, +): + seen_ids = set() + + for index, session in enumerate( + payload["sessions"] + ): + label = f"sessions[{index}]" + + require_exact_keys( + session, + SESSION_KEYS, + SESSION_KEYS, + label, + ) + + session_id = require_nonempty_string( + session["session_id"], + f"{label}.session_id", + ) + + if session_id in seen_ids: + raise ImportFailure( + f"{label}: session_id dupliqué" + ) + + seen_ids.add(session_id) + + require_nonempty_string( + session["started_at"], + f"{label}.started_at", + ) + + if session["session_type"] not in ( + "training", + "max_test", + ): + raise ImportFailure( + f"{label}.session_type invalide" + ) + + exercises = session["exercises"] + + if ( + not isinstance(exercises, list) + or not exercises + ): + raise ImportFailure( + f"{label}.exercises: tableau non vide attendu" + ) + + seen_session_exercises = set() + + for exercise_index, exercise in enumerate( + exercises + ): + exercise_label = ( + f"{label}.exercises[{exercise_index}]" + ) + + validate_session_exercise( + exercise, + exercise_label, + known_exercise_ids, + ) + + exercise_id = exercise["exercise_id"] + + if exercise_id in seen_session_exercises: + raise ImportFailure( + f"{exercise_label}: exercice dupliqué dans la séance" + ) + + seen_session_exercises.add( + exercise_id + ) + + +def validate_body(payload): + seen_ids = set() + allowed = BODY_BASE_KEYS | BODY_METRIC_KEYS + + for index, observation in enumerate( + payload["body_observations"] + ): + label = f"body_observations[{index}]" + + require_exact_keys( + observation, + allowed, + BODY_BASE_KEYS, + label, + ) + + observation_id = require_nonempty_string( + observation["observation_id"], + f"{label}.observation_id", + ) + + if observation_id in seen_ids: + raise ImportFailure( + f"{label}: observation_id dupliqué" + ) + + seen_ids.add(observation_id) + + require_nonempty_string( + observation["observed_at"], + f"{label}.observed_at", + ) + + present_metrics = ( + set(observation.keys()) + & BODY_METRIC_KEYS + ) + + if not present_metrics: + raise ImportFailure( + f"{label}: au moins une mesure requise" + ) + + for metric in present_metrics: + require_positive_number( + observation[metric], + f"{label}.{metric}", + ) + + +def validate_payload(payload): + exercise_ids = validate_exercises( + payload + ) + + validate_sessions( + payload, + exercise_ids, + ) + + validate_body(payload) + + +def require_schema_v4(connection): + version = connection.execute( + "PRAGMA user_version;" + ).fetchone()[0] + + if version != 4: + raise ImportFailure( + f"base desktop schema v4 attendue, version trouvée: {version}" + ) + + +def lookup_exercise_by_id( + connection, + exercise_id, +): + return connection.execute( + """ + SELECT + id, + exercise_id, + name, + normalized_name, + tracking_mode, + recording_mode, + data_fields + FROM exercises + WHERE exercise_id = ?; + """, + (exercise_id,), + ).fetchone() + + +def lookup_exercise_by_normalized( + connection, + normalized, +): + return connection.execute( + """ + SELECT + id, + exercise_id, + name, + normalized_name, + tracking_mode, + recording_mode, + data_fields + FROM exercises + WHERE normalized_name = ?; + """, + (normalized,), + ).fetchone() + + +def profile_matches( + row, + exercise, +): + return ( + row["tracking_mode"] + == exercise["tracking_mode"] + and row["recording_mode"] + == exercise["recording_mode"] + and row["data_fields"] + == exercise["data_fields"] + ) + + +def import_exercises( + connection, + payload, + report, +): + mapping = {} + + for exercise in payload["exercises"]: + exercise_id = exercise["exercise_id"] + normalized = normalize_name( + exercise["name"] + ) + + by_id = lookup_exercise_by_id( + connection, + exercise_id, + ) + + if by_id is not None: + if not profile_matches( + by_id, + exercise, + ): + raise ImportFailure( + f"profil incompatible pour {exercise_id}" + ) + + mapping[exercise_id] = ( + by_id["exercise_id"] + ) + + report["exercises_skipped"] += 1 + continue + + by_name = lookup_exercise_by_normalized( + connection, + normalized, + ) + + if by_name is not None: + if not profile_matches( + by_name, + exercise, + ): + raise ImportFailure( + "conflit de profil pour le nom " + + exercise["name"] + ) + + mapping[exercise_id] = ( + by_name["exercise_id"] + ) + + report["exercises_reconciled"] += 1 + continue + + connection.execute( + """ + INSERT INTO exercises( + exercise_id, + name, + normalized_name, + tracking_mode, + recording_mode, + data_fields + ) VALUES(?, ?, ?, ?, ?, ?); + """, + ( + exercise_id, + exercise["name"], + normalized, + exercise["tracking_mode"], + exercise["recording_mode"], + exercise["data_fields"], + ), + ) + + mapping[exercise_id] = exercise_id + report["exercises_imported"] += 1 + + return mapping + + +def exercise_row_id( + connection, + desktop_exercise_id, +): + row = connection.execute( + """ + SELECT id + FROM exercises + WHERE exercise_id = ?; + """, + (desktop_exercise_id,), + ).fetchone() + + if row is None: + raise ImportFailure( + "exercice desktop introuvable après reconciliation" + ) + + return row[0] + + +def session_exists( + connection, + session_id, +): + return ( + connection.execute( + """ + SELECT 1 + FROM sessions + WHERE session_id = ?; + """, + (session_id,), + ).fetchone() + is not None + ) + + +def import_set_session_exercise( + connection, + session_row_id, + position, + item, + exercise_row, +): + sets = item["sets"] + tracking = item["tracking_mode"] + metric_values = [] + + for set_item in sets: + if tracking == "reps": + metric_values.append( + set_item["reps"] + ) + else: + metric_values.append( + set_item["duration_seconds"] + ) + + target_metric = metric_values[0] + + if tracking == "reps": + target_reps = target_metric + target_duration = None + else: + target_reps = None + target_duration = target_metric + + cursor = connection.execute( + """ + INSERT INTO session_exercises( + session_row_id, + exercise_row_id, + recording_mode, + data_fields, + position, + load_mode, + rest_seconds, + target_sets, + target_reps, + target_duration_seconds, + target_weight_kg, + notes + ) VALUES( + ?, ?, 'sets', ?, ?, 'none', 0, + ?, ?, ?, NULL, NULL + ); + """, + ( + session_row_id, + exercise_row, + item["data_fields"], + position, + len(sets), + target_reps, + target_duration, + ), + ) + + session_exercise_row_id = ( + cursor.lastrowid + ) + + for set_index, set_item in enumerate(sets): + if tracking == "reps": + reps = set_item["reps"] + duration = None + else: + reps = None + duration = ( + set_item["duration_seconds"] + ) + + connection.execute( + """ + INSERT INTO performed_sets( + session_exercise_row_id, + position, + reps, + duration_seconds, + weight_kg + ) VALUES(?, ?, ?, ?, NULL); + """, + ( + session_exercise_row_id, + set_index, + reps, + duration, + ), + ) + + +def import_continuous_session_exercise( + connection, + session_row_id, + position, + item, + exercise_row, +): + cursor = connection.execute( + """ + INSERT INTO session_exercises( + session_row_id, + exercise_row_id, + recording_mode, + data_fields, + position, + load_mode, + rest_seconds, + target_sets, + target_reps, + target_duration_seconds, + target_weight_kg, + notes + ) VALUES( + ?, ?, 'continuous', ?, ?, 'none', 0, + NULL, NULL, NULL, NULL, NULL + ); + """, + ( + session_row_id, + exercise_row, + item["data_fields"], + position, + ), + ) + + continuous = item["continuous"] + + connection.execute( + """ + INSERT INTO continuous_activity( + session_exercise_row_id, + duration_seconds, + speed_kmh, + distance_km + ) VALUES(?, ?, ?, ?); + """, + ( + cursor.lastrowid, + continuous["duration_seconds"], + continuous.get("speed_kmh"), + continuous.get("distance_km"), + ), + ) + + +def import_sessions( + connection, + payload, + exercise_mapping, + report, +): + for session in payload["sessions"]: + if session_exists( + connection, + session["session_id"], + ): + report["sessions_skipped"] += 1 + continue + + cursor = connection.execute( + """ + INSERT INTO sessions( + session_id, + started_at, + ended_at, + session_type, + notes + ) VALUES(?, ?, NULL, ?, NULL); + """, + ( + session["session_id"], + session["started_at"], + session["session_type"], + ), + ) + + session_row_id = cursor.lastrowid + + for position, item in enumerate( + session["exercises"] + ): + mobile_id = item["exercise_id"] + + desktop_id = exercise_mapping.get( + mobile_id + ) + + if desktop_id is None: + raise ImportFailure( + f"mapping exercice absent: {mobile_id}" + ) + + row = lookup_exercise_by_id( + connection, + desktop_id, + ) + + if row is None: + raise ImportFailure( + f"exercice desktop absent: {desktop_id}" + ) + + if ( + row["tracking_mode"] + != item["tracking_mode"] + or row["recording_mode"] + != item["recording_mode"] + or row["data_fields"] + != item["data_fields"] + ): + raise ImportFailure( + "snapshot de séance incompatible avec le catalogue desktop" + ) + + row_id = exercise_row_id( + connection, + desktop_id, + ) + + if item["recording_mode"] == "continuous": + import_continuous_session_exercise( + connection, + session_row_id, + position, + item, + row_id, + ) + else: + import_set_session_exercise( + connection, + session_row_id, + position, + item, + row_id, + ) + + report["sessions_imported"] += 1 + + +def body_exists( + connection, + observation_id, +): + return ( + connection.execute( + """ + SELECT 1 + FROM body_observations + WHERE observation_id = ?; + """, + (observation_id,), + ).fetchone() + is not None + ) + + +def import_body( + connection, + payload, + report, +): + metric_order = [ + "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", + ] + + placeholders = ", ".join( + "?" for _ in range( + 2 + len(metric_order) + ) + ) + + columns = ( + "observation_id, observed_at, " + + ", ".join(metric_order) + ) + + sql = ( + f"INSERT INTO body_observations(" + f"{columns}" + f") VALUES({placeholders});" + ) + + for observation in payload[ + "body_observations" + ]: + if body_exists( + connection, + observation["observation_id"], + ): + report["body_skipped"] += 1 + continue + + values = [ + observation["observation_id"], + observation["observed_at"], + ] + + values.extend( + observation.get(metric) + for metric in metric_order + ) + + connection.execute( + sql, + values, + ) + + report["body_imported"] += 1 + + +def run_import( + payload, + database_path, + dry_run, +): + if not database_path.exists(): + raise ImportFailure( + f"base desktop introuvable: {database_path}" + ) + + connection = sqlite3.connect( + database_path + ) + + connection.row_factory = sqlite3.Row + + report = { + "exercises_imported": 0, + "exercises_reconciled": 0, + "exercises_skipped": 0, + "sessions_imported": 0, + "sessions_skipped": 0, + "body_imported": 0, + "body_skipped": 0, + } + + try: + connection.execute( + "PRAGMA foreign_keys = ON;" + ) + + require_schema_v4( + connection + ) + + connection.execute( + "BEGIN IMMEDIATE;" + ) + + mapping = import_exercises( + connection, + payload, + report, + ) + + import_sessions( + connection, + payload, + mapping, + report, + ) + + import_body( + connection, + payload, + report, + ) + + if dry_run: + connection.rollback() + else: + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + + return report + + +def print_report( + report, + dry_run, +): + prefix = ( + "MOBILE_IMPORT_DRY_RUN=PASS" + if dry_run + else "MOBILE_IMPORT=PASS" + ) + + print(prefix) + + for key in ( + "exercises_imported", + "exercises_reconciled", + "exercises_skipped", + "sessions_imported", + "sessions_skipped", + "body_imported", + "body_skipped", + ): + print( + f"{key}={report[key]}" + ) + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Import idempotent d'un snapshot " + "Trainlog Android dans la DB desktop." + ) + ) + + parser.add_argument( + "json_path", + type=Path, + ) + + parser.add_argument( + "--database", + type=Path, + default=default_database_path(), + ) + + parser.add_argument( + "--dry-run", + action="store_true", + ) + + args = parser.parse_args() + + try: + payload = load_payload( + args.json_path + ) + + validate_payload( + payload + ) + + report = run_import( + payload, + args.database, + args.dry_run, + ) + + print_report( + report, + args.dry_run, + ) + except ImportFailure as error: + print( + f"MOBILE_IMPORT=FAIL: {error}", + file=sys.stderr, + ) + + raise SystemExit(1) + except sqlite3.Error as error: + print( + f"MOBILE_IMPORT=FAIL: SQLite: {error}", + file=sys.stderr, + ) + + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tui/include/trainlog/mtp.h b/tui/include/trainlog/mtp.h index 5ea88b5..1abef98 100644 --- a/tui/include/trainlog/mtp.h +++ b/tui/include/trainlog/mtp.h @@ -104,4 +104,10 @@ TrainlogStatus trainlog_mtp_receive_file( const char *local_path ); +TrainlogStatus trainlog_mtp_delete_object( + unsigned int bus_number, + unsigned int device_number, + uint32_t item_id +); + #endif diff --git a/tui/meson.build b/tui/meson.build index adcff0f..2ab70ef 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -279,3 +279,11 @@ test( 'continuous_detail', test_continuous_detail, ) + +trainlog_mtp_mobile_export_probe = executable( + 'trainlog-mtp-mobile-export-probe', + 'tools/mtp_mobile_export_probe.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + diff --git a/tui/src/mtp.c b/tui/src/mtp.c index 36ef336..ebfda39 100644 --- a/tui/src/mtp.c +++ b/tui/src/mtp.c @@ -777,3 +777,46 @@ TrainlogStatus trainlog_mtp_receive_file( return TRAINLOG_STATUS_OK; } + +TrainlogStatus trainlog_mtp_delete_object( + unsigned int bus_number, + unsigned int device_number, + uint32_t item_id +) +{ + LIBMTP_mtpdevice_t *device = NULL; + TrainlogStatus status; + int rc; + + if (item_id == 0U) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + status = + mtp_open_exact_device( + bus_number, + device_number, + &device + ); + + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + rc = + LIBMTP_Delete_Object( + device, + item_id + ); + + if (rc != 0) { + LIBMTP_Clear_Errorstack(device); + LIBMTP_Release_Device(device); + + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + LIBMTP_Release_Device(device); + + return TRAINLOG_STATUS_OK; +} diff --git a/tui/src/tui.c b/tui/src/tui.c index eed7e4d..a037397 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -39,6 +40,11 @@ /* TRAINLOG_TUI_V02_POLISH */ /* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */ +/* TRAINLOG_SYNC_RESPONSIVE_CACHE */ +/* TRAINLOG_SYNC_LARGE_LAYOUT_S_FIX */ +/* TRAINLOG_SYNC_HISTORY_BIDIRECTIONAL_V1 */ +/* TRAINLOG_SYNC_PC_TO_ANDROID_DIAGNOSTICS */ +/* TRAINLOG_SYNC_FULL_MTP_SILENCE */ typedef enum DashboardAction { DASHBOARD_NEW_SESSION = 0, @@ -97,6 +103,11 @@ static bool primary_top_nav_activate(int selected_page); static void section_ascii_header(const char *subtitle); +static void session_history_datetime( + const char *timestamp, + char output[17] +); + static void draw_shell(const char *heading, const char *footer) { @@ -7052,16 +7063,25 @@ static void screen_history( ); } - mvprintw( - item_row, - item_col, - " %-25s %-14s %2zu exercice(s) ", - sessions[absolute].started_at, - session_type_history_label( - sessions[absolute].session_type - ), - sessions[absolute].exercise_count - ); + { + char display_date[17]; + + session_history_datetime( + sessions[absolute].started_at, + display_date + ); + + mvprintw( + item_row, + item_col, + " %-16s %-14s %2zu exercice(s) ", + display_date, + session_type_history_label( + sessions[absolute].session_type + ), + sessions[absolute].exercise_count + ); + } if (focus == 1 && absolute == selected) { @@ -8573,6 +8593,638 @@ static double sync_bytes_to_gib( (1024.0 * 1024.0 * 1024.0); } +typedef struct TrainlogMobileImportReport { + size_t exercises_imported; + size_t exercises_reconciled; + size_t exercises_skipped; + size_t sessions_imported; + size_t sessions_skipped; + size_t body_imported; + size_t body_skipped; + size_t catalog_published; +} TrainlogMobileImportReport; + +static TrainlogStatus sync_find_mtp_child( + const TrainlogUsbDevice *device, + uint32_t storage_id, + uint32_t parent_id, + const char *name, + bool folder, + uint32_t *output_id, + uint64_t *output_size +) +{ + TrainlogMtpEntry entries[SYNC_ENTRY_CAPACITY]; + size_t count = 0U; + size_t index; + TrainlogStatus status; + + if (device == NULL || + name == NULL || + output_id == NULL || + output_size == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + *output_id = 0U; + *output_size = 0U; + + status = + trainlog_mtp_list_folder( + device->bus_number, + device->device_number, + storage_id, + parent_id, + entries, + SYNC_ENTRY_CAPACITY, + &count + ); + + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + for (index = 0U; + index < count; + ++index) { + if (entries[index].folder == folder && + strcmp( + entries[index].name, + name + ) == 0) { + *output_id = + entries[index].item_id; + + *output_size = + entries[index].size_bytes; + + return TRAINLOG_STATUS_OK; + } + } + + return TRAINLOG_STATUS_NOT_FOUND; +} + +static TrainlogStatus sync_find_mobile_export( + const TrainlogSyncOverview *overview, + uint32_t *output_item_id, + uint64_t *output_size +) +{ + uint32_t download_id = 0U; + uint32_t trainlog_id = 0U; + uint64_t ignored_size = 0U; + TrainlogStatus status; + + if (overview == NULL || + output_item_id == NULL || + output_size == NULL || + !overview->connected || + !overview->storage_ready) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + status = + sync_find_mtp_child( + &overview->device, + overview->storage.storage_id, + UINT32_MAX, + "Download", + true, + &download_id, + &ignored_size + ); + + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + status = + sync_find_mtp_child( + &overview->device, + overview->storage.storage_id, + download_id, + "Trainlog", + true, + &trainlog_id, + &ignored_size + ); + + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + return + sync_find_mtp_child( + &overview->device, + overview->storage.storage_id, + trainlog_id, + "trainlog-mobile-export-v1.json", + false, + output_item_id, + output_size + ); +} + +static TrainlogStatus sync_silenced_mobile_export_download( + const TrainlogSyncOverview *overview, + const char *local_path, + uint64_t *output_size +) +{ + int saved_stdout = -1; + int saved_stderr = -1; + int null_fd = -1; + uint32_t item_id = 0U; + uint64_t size_bytes = 0U; + TrainlogStatus status; + + if (overview == NULL || + local_path == NULL || + local_path[0] == '\0' || + output_size == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + saved_stdout = + dup(STDOUT_FILENO); + + saved_stderr = + dup(STDERR_FILENO); + + null_fd = + open( + "/dev/null", + O_WRONLY + ); + + if (saved_stdout < 0 || + saved_stderr < 0 || + null_fd < 0) { + if (saved_stdout >= 0) { + (void)close(saved_stdout); + } + + if (saved_stderr >= 0) { + (void)close(saved_stderr); + } + + if (null_fd >= 0) { + (void)close(null_fd); + } + + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + (void)fflush(stdout); + (void)fflush(stderr); + + if (dup2( + null_fd, + STDOUT_FILENO + ) < 0 || + dup2( + null_fd, + STDERR_FILENO + ) < 0) { + (void)dup2( + saved_stdout, + STDOUT_FILENO + ); + + (void)dup2( + saved_stderr, + STDERR_FILENO + ); + + (void)close(saved_stdout); + (void)close(saved_stderr); + (void)close(null_fd); + + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + status = + sync_find_mobile_export( + overview, + &item_id, + &size_bytes + ); + + if (status == TRAINLOG_STATUS_OK) { + status = + trainlog_mtp_receive_file( + overview->device.bus_number, + overview->device.device_number, + item_id, + local_path + ); + } + + (void)fflush(stdout); + (void)fflush(stderr); + + (void)dup2( + saved_stdout, + STDOUT_FILENO + ); + + (void)dup2( + saved_stderr, + STDERR_FILENO + ); + + (void)close(saved_stdout); + (void)close(saved_stderr); + (void)close(null_fd); + + if (status == + TRAINLOG_STATUS_OK) { + *output_size = + size_bytes; + } + + return status; +} + +static bool sync_resolve_importer_path( + char *output, + size_t output_size +) +{ + char executable[PATH_MAX + 1U]; + ssize_t length; + int level; + char *slash; + int written; + + if (output == NULL || + output_size == 0U) { + return false; + } + + length = + readlink( + "/proc/self/exe", + executable, + PATH_MAX + ); + + if (length <= 0 || + (size_t)length >= + sizeof(executable)) { + return false; + } + + executable[(size_t)length] = + '\0'; + + for (level = 0; + level < 3; + ++level) { + slash = + strrchr( + executable, + '/' + ); + + if (slash == NULL || + slash == executable) { + return false; + } + + *slash = '\0'; + } + + written = + snprintf( + output, + output_size, + "%s/tools/import_mobile_export.py", + executable + ); + + if (written < 0 || + (size_t)written >= + output_size) { + return false; + } + + return + access( + output, + R_OK + ) == 0; +} + +static size_t sync_report_value( + const char *text, + const char *name +) +{ + const char *position; + char *end = NULL; + unsigned long long value; + + if (text == NULL || + name == NULL) { + return 0U; + } + + position = + strstr( + text, + name + ); + + if (position == NULL) { + return 0U; + } + + position += + strlen(name); + + if (*position != '=') { + return 0U; + } + + ++position; + + value = + strtoull( + position, + &end, + 10 + ); + + if (end == position || + value > + (unsigned long long) + SIZE_MAX) { + return 0U; + } + + return (size_t)value; +} + +static bool sync_read_import_report( + const char *path, + TrainlogMobileImportReport *report, + char *raw_output, + size_t raw_output_size +) +{ + FILE *file; + size_t used; + + if (path == NULL || + report == NULL || + raw_output == NULL || + raw_output_size < 2U) { + return false; + } + + file = + fopen( + path, + "rb" + ); + + if (file == NULL) { + return false; + } + + used = + fread( + raw_output, + 1U, + raw_output_size - 1U, + file + ); + + if (ferror(file) != 0) { + (void)fclose(file); + return false; + } + + raw_output[used] = + '\0'; + + if (fclose(file) != 0) { + return false; + } + + if (strstr( + raw_output, + "MOBILE_IMPORT=PASS" + ) == NULL) { + return false; + } + + (void)memset( + report, + 0, + sizeof(*report) + ); + + report->exercises_imported = + sync_report_value( + raw_output, + "exercises_imported" + ); + + report->exercises_reconciled = + sync_report_value( + raw_output, + "exercises_reconciled" + ); + + report->exercises_skipped = + sync_report_value( + raw_output, + "exercises_skipped" + ); + + report->sessions_imported = + sync_report_value( + raw_output, + "sessions_imported" + ); + + report->sessions_skipped = + sync_report_value( + raw_output, + "sessions_skipped" + ); + + report->body_imported = + sync_report_value( + raw_output, + "body_imported" + ); + + report->body_skipped = + sync_report_value( + raw_output, + "body_skipped" + ); + + return true; +} + +static TrainlogStatus sync_run_mobile_importer( + TrainlogMobileImportReport *report, + char *raw_output, + size_t raw_output_size +) +{ + static const char *const LOCAL_EXPORT = + "/tmp/trainlog-mobile-export-v1.json"; + + static const char *const RESULT_PATH = + "/tmp/trainlog-mobile-import-result.txt"; + + char importer[PATH_MAX + 1U]; + pid_t child; + int child_status; + int result_fd; + + if (report == NULL || + raw_output == NULL || + raw_output_size == 0U) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + if (!sync_resolve_importer_path( + importer, + sizeof(importer) + )) { + return TRAINLOG_STATUS_NOT_FOUND; + } + + result_fd = + open( + RESULT_PATH, + O_WRONLY | + O_CREAT | + O_TRUNC, + 0600 + ); + + if (result_fd < 0) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + child = + fork(); + + if (child < (pid_t)0) { + (void)close(result_fd); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + if (child == (pid_t)0) { + if (dup2( + result_fd, + STDOUT_FILENO + ) < 0 || + dup2( + result_fd, + STDERR_FILENO + ) < 0) { + _exit(126); + } + + (void)close(result_fd); + + execlp( + "python3", + "python3", + importer, + LOCAL_EXPORT, + (char *)NULL + ); + + _exit(127); + } + + (void)close(result_fd); + + if (waitpid( + child, + &child_status, + 0 + ) < (pid_t)0) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + if (!WIFEXITED( + child_status + ) || + WEXITSTATUS( + child_status + ) != 0) { + (void)sync_read_import_report( + RESULT_PATH, + report, + raw_output, + raw_output_size + ); + + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (!sync_read_import_report( + RESULT_PATH, + report, + raw_output, + raw_output_size + )) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus sync_import_mobile_export( + const TrainlogSyncOverview *overview, + TrainlogMobileImportReport *report, + char *raw_output, + size_t raw_output_size, + uint64_t *output_download_size +) +{ + static const char *const LOCAL_EXPORT = + "/tmp/trainlog-mobile-export-v1.json"; + + TrainlogStatus status; + + if (overview == NULL || + report == NULL || + raw_output == NULL || + output_download_size == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + status = + sync_silenced_mobile_export_download( + overview, + LOCAL_EXPORT, + output_download_size + ); + + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + return + sync_run_mobile_importer( + report, + raw_output, + raw_output_size + ); +} + static void sync_load_overview( TrainlogDatabase *database, TrainlogSyncOverview *overview @@ -8669,6 +9321,939 @@ static void sync_load_overview( ++overview->remote_json_count; } } + + if (overview->connected && + overview->storage_ready) { + uint32_t mobile_item_id = 0U; + uint64_t mobile_size = 0U; + + if (sync_find_mobile_export( + overview, + &mobile_item_id, + &mobile_size + ) == TRAINLOG_STATUS_OK) { + ++overview->remote_json_count; + } + } +} + +#define SYNC_HISTORY_CAPACITY 32U +#define SYNC_HISTORY_TEXT_MAX 191U + +typedef struct TrainlogSyncHistoryEntry { + char timestamp[17]; + bool success; + char summary[ + SYNC_HISTORY_TEXT_MAX + 1U + ]; +} TrainlogSyncHistoryEntry; + +static void session_history_datetime( + const char *timestamp, + char output[17] +) +{ + if ( + timestamp == NULL || + strlen(timestamp) < 16U + ) { + (void)snprintf( + output, + 17U, + "%s", + "--/--/---- --:--" + ); + + return; + } + + (void)snprintf( + output, + 17U, + "%c%c/%c%c/%c%c%c%c %c%c:%c%c", + timestamp[8], + timestamp[9], + timestamp[5], + timestamp[6], + timestamp[0], + timestamp[1], + timestamp[2], + timestamp[3], + timestamp[11], + timestamp[12], + timestamp[14], + timestamp[15] + ); +} + +static bool sync_history_path( + char *output, + size_t output_size +) +{ + const char *data_home = + getenv("XDG_DATA_HOME"); + + const char *home = + getenv("HOME"); + + int written; + + if ( + output == NULL || + output_size == 0U + ) { + return false; + } + + if ( + data_home != NULL && + data_home[0] != '\0' + ) { + written = + snprintf( + output, + output_size, + "%s/trainlog/sync_history.log", + data_home + ); + } else if ( + home != NULL && + home[0] != '\0' + ) { + written = + snprintf( + output, + output_size, + "%s/.local/share/trainlog/sync_history.log", + home + ); + } else { + return false; + } + + return + written >= 0 && + (size_t)written < + output_size; +} + +static void sync_history_append( + bool success, + const char *summary +) +{ + char path[ + PATH_MAX + 1U + ]; + + char timestamp[17]; + time_t now; + struct tm local_time; + FILE *file; + + if ( + summary == NULL || + !sync_history_path( + path, + sizeof(path) + ) + ) { + return; + } + + now = time(NULL); + + if ( + now == (time_t)-1 || + localtime_r( + &now, + &local_time + ) == NULL + ) { + return; + } + + if ( + strftime( + timestamp, + sizeof(timestamp), + "%d/%m/%Y %H:%M", + &local_time + ) == 0U + ) { + return; + } + + file = + fopen( + path, + "ab" + ); + + if (file == NULL) { + return; + } + + (void)fprintf( + file, + "%s\t%d\t%.*s\n", + timestamp, + success + ? 1 + : 0, + (int)SYNC_HISTORY_TEXT_MAX, + summary + ); + + (void)fclose(file); +} + +static void sync_history_load( + TrainlogSyncHistoryEntry *output, + size_t capacity, + size_t *output_count +) +{ + char path[ + PATH_MAX + 1U + ]; + + TrainlogSyncHistoryEntry + ring[SYNC_HISTORY_CAPACITY]; + + size_t count = 0U; + size_t next = 0U; + size_t copied; + FILE *file; + char line[512]; + + if ( + output_count == NULL || + ( + capacity > 0U && + output == NULL + ) + ) { + return; + } + + *output_count = 0U; + + if ( + !sync_history_path( + path, + sizeof(path) + ) + ) { + return; + } + + file = + fopen( + path, + "rb" + ); + + if (file == NULL) { + return; + } + + (void)memset( + ring, + 0, + sizeof(ring) + ); + + while ( + fgets( + line, + sizeof(line), + file + ) != NULL + ) { + char *first_tab; + char *second_tab; + char *newline; + TrainlogSyncHistoryEntry *entry; + + first_tab = + strchr( + line, + '\t' + ); + + if (first_tab == NULL) { + continue; + } + + *first_tab = '\0'; + + second_tab = + strchr( + first_tab + 1, + '\t' + ); + + if (second_tab == NULL) { + continue; + } + + *second_tab = '\0'; + + newline = + strchr( + second_tab + 1, + '\n' + ); + + if (newline != NULL) { + *newline = '\0'; + } + + entry = + &ring[next]; + + (void)snprintf( + entry->timestamp, + sizeof(entry->timestamp), + "%s", + line + ); + + entry->success = + strcmp( + first_tab + 1, + "1" + ) == 0; + + (void)snprintf( + entry->summary, + sizeof(entry->summary), + "%s", + second_tab + 1 + ); + + next = + ( + next + 1U + ) % + SYNC_HISTORY_CAPACITY; + + if ( + count < + SYNC_HISTORY_CAPACITY + ) { + ++count; + } + } + + (void)fclose(file); + + copied = + count < capacity + ? count + : capacity; + + for ( + size_t index = 0U; + index < copied; + ++index + ) { + size_t source = + ( + next + + SYNC_HISTORY_CAPACITY - + 1U - + index + ) % + SYNC_HISTORY_CAPACITY; + + output[index] = + ring[source]; + } + + *output_count = + copied; +} + +static void sync_load_overview_silenced( + TrainlogDatabase *database, + TrainlogSyncOverview *overview +) +{ + int saved_stdout = + dup(STDOUT_FILENO); + + int saved_stderr = + dup(STDERR_FILENO); + + int null_fd = + open( + "/dev/null", + O_WRONLY | + O_CLOEXEC + ); + + (void)fflush(stdout); + (void)fflush(stderr); + + if ( + saved_stdout >= 0 && + saved_stderr >= 0 && + null_fd >= 0 + ) { + (void)dup2( + null_fd, + STDOUT_FILENO + ); + + (void)dup2( + null_fd, + STDERR_FILENO + ); + } + + sync_load_overview( + database, + overview + ); + + (void)fflush(stdout); + (void)fflush(stderr); + + if (saved_stdout >= 0) { + (void)dup2( + saved_stdout, + STDOUT_FILENO + ); + + (void)close( + saved_stdout + ); + } + + if (saved_stderr >= 0) { + (void)dup2( + saved_stderr, + STDERR_FILENO + ); + + (void)close( + saved_stderr + ); + } + + if (null_fd >= 0) { + (void)close( + null_fd + ); + } +} + +static bool sync_resolve_repo_tool( + const char *tool_name, + char *output, + size_t output_size +) +{ + char executable[ + PATH_MAX + 1U + ]; + + ssize_t length; + int level; + char *slash; + int written; + + if ( + tool_name == NULL || + output == NULL || + output_size == 0U + ) { + return false; + } + + length = + readlink( + "/proc/self/exe", + executable, + PATH_MAX + ); + + if ( + length <= 0 || + (size_t)length >= + sizeof(executable) + ) { + return false; + } + + executable[ + (size_t)length + ] = '\0'; + + for ( + level = 0; + level < 3; + ++level + ) { + slash = + strrchr( + executable, + '/' + ); + + if ( + slash == NULL || + slash == executable + ) { + return false; + } + + *slash = '\0'; + } + + written = + snprintf( + output, + output_size, + "%s/tools/%s", + executable, + tool_name + ); + + return + written >= 0 && + (size_t)written < + output_size && + access( + output, + R_OK + ) == 0; +} + +static TrainlogStatus sync_run_pc_catalog_export( + size_t *output_count +) +{ + static const char *const + OUTPUT_PATH = + "/tmp/trainlog-pc-catalog-v1.json"; + + static const char *const + RESULT_PATH = + "/tmp/trainlog-pc-catalog-result.txt"; + + char tool[ + PATH_MAX + 1U + ]; + + char result[512]; + int result_fd; + pid_t child; + int child_status; + FILE *file; + size_t used; + + if (output_count == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + *output_count = 0U; + + if ( + !sync_resolve_repo_tool( + "export_pc_catalog.py", + tool, + sizeof(tool) + ) + ) { + return TRAINLOG_STATUS_NOT_FOUND; + } + + result_fd = + open( + RESULT_PATH, + O_WRONLY | + O_CREAT | + O_TRUNC, + 0600 + ); + + if (result_fd < 0) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + child = fork(); + + if (child < (pid_t)0) { + (void)close(result_fd); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + if (child == (pid_t)0) { + if ( + dup2( + result_fd, + STDOUT_FILENO + ) < 0 || + dup2( + result_fd, + STDERR_FILENO + ) < 0 + ) { + _exit(126); + } + + (void)close(result_fd); + + execlp( + "python3", + "python3", + tool, + OUTPUT_PATH, + (char *)NULL + ); + + _exit(127); + } + + (void)close(result_fd); + + if ( + waitpid( + child, + &child_status, + 0 + ) < (pid_t)0 || + !WIFEXITED( + child_status + ) || + WEXITSTATUS( + child_status + ) != 0 + ) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + file = + fopen( + RESULT_PATH, + "rb" + ); + + if (file == NULL) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + used = + fread( + result, + 1U, + sizeof(result) - 1U, + file + ); + + result[used] = '\0'; + + (void)fclose(file); + + if ( + strstr( + result, + "PC_CATALOG_EXPORT=PASS" + ) == NULL + ) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + *output_count = + sync_report_value( + result, + "exercises" + ); + + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus sync_publish_pc_catalog( + const TrainlogSyncOverview *overview, + size_t *output_count, + char *error_text, + size_t error_text_size +) +{ + static const char *const LOCAL_PATH = + "/tmp/trainlog-pc-catalog-v1.json"; + + uint32_t download_id = 0U; + uint32_t trainlog_id = 0U; + uint32_t existing_id = 0U; + uint32_t uploaded_id = 0U; + uint64_t ignored_size = 0U; + TrainlogStatus status; + + if (overview == NULL || + output_count == NULL || + error_text == NULL || + error_text_size == 0U || + !overview->connected || + !overview->storage_ready) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + error_text[0] = '\0'; + + status = + sync_run_pc_catalog_export( + output_count + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf( + error_text, + error_text_size, + "%s", + "PC → Android : export catalogue échoué" + ); + + return status; + } + + status = + sync_find_mtp_child( + &overview->device, + overview->storage.storage_id, + UINT32_MAX, + "Download", + true, + &download_id, + &ignored_size + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf( + error_text, + error_text_size, + "%s", + "PC → Android : dossier Download introuvable" + ); + + return status; + } + + status = + sync_find_mtp_child( + &overview->device, + overview->storage.storage_id, + download_id, + "Trainlog", + true, + &trainlog_id, + &ignored_size + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf( + error_text, + error_text_size, + "%s", + "PC → Android : dossier Download/Trainlog introuvable" + ); + + return status; + } + + status = + sync_find_mtp_child( + &overview->device, + overview->storage.storage_id, + trainlog_id, + "trainlog-pc-catalog-v1.json", + false, + &existing_id, + &ignored_size + ); + + if (status == TRAINLOG_STATUS_OK) { + status = + trainlog_mtp_delete_object( + overview->device.bus_number, + overview->device.device_number, + existing_id + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf( + error_text, + error_text_size, + "%s", + "PC → Android : suppression ancien catalogue échouée" + ); + + return status; + } + } else if (status != TRAINLOG_STATUS_NOT_FOUND) { + (void)snprintf( + error_text, + error_text_size, + "%s", + "PC → Android : lecture du dossier Trainlog échouée" + ); + + return status; + } + + status = + trainlog_mtp_send_text_file( + overview->device.bus_number, + overview->device.device_number, + overview->storage.storage_id, + trainlog_id, + LOCAL_PATH, + "trainlog-pc-catalog-v1.json", + &uploaded_id + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf( + error_text, + error_text_size, + "%s", + "PC → Android : envoi MTP du catalogue échoué" + ); + + return status; + } + + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus sync_bidirectional( + const TrainlogSyncOverview *overview, + TrainlogMobileImportReport *report, + char *raw_output, + size_t raw_output_size, + uint64_t *output_download_size +) +{ + TrainlogStatus status; + size_t published = 0U; + char pc_error[256]; + int saved_stdout = -1; + int saved_stderr = -1; + int null_fd = -1; + + if (report == NULL || + raw_output == NULL || + raw_output_size == 0U) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + pc_error[0] = '\0'; + + (void)fflush(stdout); + (void)fflush(stderr); + + saved_stdout = + dup(STDOUT_FILENO); + + saved_stderr = + dup(STDERR_FILENO); + + null_fd = + open( + "/dev/null", + O_WRONLY | O_CLOEXEC + ); + + if (saved_stdout >= 0 && + saved_stderr >= 0 && + null_fd >= 0) { + (void)dup2( + null_fd, + STDOUT_FILENO + ); + + (void)dup2( + null_fd, + STDERR_FILENO + ); + } + + status = + sync_import_mobile_export( + overview, + report, + raw_output, + raw_output_size, + output_download_size + ); + + if (status == TRAINLOG_STATUS_OK) { + raw_output[0] = '\0'; + + status = + sync_publish_pc_catalog( + overview, + &published, + pc_error, + sizeof(pc_error) + ); + + if (status == TRAINLOG_STATUS_OK) { + report->catalog_published = + published; + } else { + (void)snprintf( + raw_output, + raw_output_size, + "%s", + pc_error[0] != '\0' + ? pc_error + : "PC → Android : échec inconnu" + ); + } + } + + (void)fflush(stdout); + (void)fflush(stderr); + + if (saved_stdout >= 0) { + (void)dup2( + saved_stdout, + STDOUT_FILENO + ); + + (void)close( + saved_stdout + ); + } + + if (saved_stderr >= 0) { + (void)dup2( + saved_stderr, + STDERR_FILENO + ); + + (void)close( + saved_stderr + ); + } + + if (null_fd >= 0) { + (void)close( + null_fd + ); + } + + clearok( + stdscr, + TRUE + ); + + return status; } static void screen_sync( @@ -8678,490 +10263,418 @@ static void screen_sync( size_t selected = 0U; int nav_selected = 5; int focus = 1; + TrainlogSyncOverview overview; + bool refresh_overview = true; + + (void)memset( + &overview, + 0, + sizeof(overview) + ); for (;;) { - TrainlogSyncOverview overview; + TrainlogSyncHistoryEntry + history[SYNC_HISTORY_CAPACITY]; + + size_t history_count = 0U; bool large_layout = COLS >= 100 && LINES >= 30; - const size_t item_count = 4U; - size_t top = 0U; - size_t index; - int list_top; - int list_bottom; - int first_row; - int visible_rows; int key; - int saved_stdout = -1; - int saved_stderr = -1; - int null_fd = -1; - - /* - * libmtp may print raw-device identification directly to stdout or - * stderr. Redirect both temporarily so backend diagnostics cannot - * corrupt ncurses' physical-screen state. - */ - (void)fflush(stdout); - (void)fflush(stderr); - - saved_stdout = - dup(STDOUT_FILENO); - - saved_stderr = - dup(STDERR_FILENO); - - null_fd = - open( - "/dev/null", - O_WRONLY | O_CLOEXEC + if (refresh_overview) { + sync_load_overview_silenced( + database, + &overview ); - if (saved_stdout >= 0 && - saved_stderr >= 0 && - null_fd >= 0) { - (void)dup2( - null_fd, - STDOUT_FILENO - ); - - (void)dup2( - null_fd, - STDERR_FILENO - ); + refresh_overview = false; } - sync_load_overview( - database, - &overview + sync_history_load( + history, + SYNC_HISTORY_CAPACITY, + &history_count ); - (void)fflush(stdout); - (void)fflush(stderr); - - if (saved_stdout >= 0) { - (void)dup2( - saved_stdout, - STDOUT_FILENO - ); - - (void)close( - saved_stdout - ); + if ( + history_count > 0U && + selected >= history_count + ) { + selected = + history_count - 1U; } - if (saved_stderr >= 0) { - (void)dup2( - saved_stderr, - STDERR_FILENO + erase(); + box(stdscr, 0, 0); + + if (large_layout) { + size_t index; + size_t top = 0U; + int history_top = 18; + int history_bottom = + LINES - 4; + + int visible_rows = + history_bottom - + history_top - + 2; + + section_ascii_header( + ":: S Y N C ::" ); - (void)close( - saved_stderr + primary_top_navbar( + 5, + nav_selected, + focus == 0 ); - } - if (null_fd >= 0) { - (void)close( - null_fd + focused_panel( + 11, + 2, + 16, + COLS - 3, + "APPAREIL CONNECTE", + false ); - } - /* - * Force a complete physical redraw. This also protects the page if a - * backend writes directly to the terminal despite stdio redirection. - */ - clear(); - clearok( - stdscr, - TRUE - ); + if (overview.connected) { + mvprintw( + 12, + 5, + "✓ MTP direct connecté" + ); - if (!large_layout) { + mvprintw( + 13, + 5, + "%s %s", + overview.device.vendor, + overview.device.model + ); + + if ( + overview.storage_ready + ) { + mvprintw( + 14, + 5, + "Stockage interne : %.2f GiB libres / %.2f GiB", + sync_bytes_to_gib( + overview.storage.free_space_bytes + ), + sync_bytes_to_gib( + overview.storage.max_capacity_bytes + ) + ); + } + } else { + mvprintw( + 13, + 5, + "Aucun appareil MTP Trainlog détecté." + ); + } + + focused_panel( + history_top, + 2, + history_bottom, + COLS - 3, + "HISTORIQUE DES SYNCHRONISATIONS", + focus == 1 + ); + + if (history_count == 0U) { + mvprintw( + history_top + 2, + 5, + "Aucune synchronisation enregistrée." + ); + } else { + if (visible_rows < 1) { + visible_rows = 1; + } + + if ( + selected >= + (size_t)visible_rows + ) { + top = + selected - + (size_t)visible_rows + + 1U; + } + + for ( + index = 0U; + index < (size_t)visible_rows && + top + index < history_count; + ++index + ) { + size_t absolute = + top + index; + + int row = + history_top + + 2 + + (int)index; + + if ( + focus == 1 && + absolute == selected + ) { + attron( + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + + mvprintw( + row, + 5, + " %-16s %c %-*.*s ", + history[absolute].timestamp, + history[absolute].success + ? '+' + : '!', + COLS - 28, + COLS - 28, + history[absolute].summary + ); + + if ( + focus == 1 && + absolute == selected + ) { + attroff( + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + } + } + + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + mvprintw( + LINES - 2, + 2, + "%.*s", + COLS - 4, + "Tab zone ←→ menu ↑↓ historique s synchroniser les 2 sens r actualiser b/Échap retour" + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + } else { draw_shell( "TRAINLOG — Sync", - "↑↓ parcourir r actualiser b/Échap retour" + "s synchroniser les 2 sens r actualiser b/Échap retour" ); - if (!overview.connected) { + if (overview.connected) { mvprintw( 4, 4, - "Aucun appareil Android MTP connecté." + "✓ %s %s", + overview.device.vendor, + overview.device.model ); } else { mvprintw( 4, 4, - "Appareil : %.*s", - COLS - 16, - overview.device.model - ); - - mvprintw( - 6, - 4, - "Séances : %zu JSON candidat(s)", - overview.remote_json_count + "Aucun appareil MTP." ); + } + if (history_count == 0U) { mvprintw( 7, 4, - "Catalogue PC : %zu exercice(s)", - overview.local_exercise_count + "Aucune synchronisation." ); - } + } else { + size_t index; + size_t limit = + history_count < 8U + ? history_count + : 8U; - refresh(); - key = getch(); - - if (key == 'b' || - key == 'B' || - key == 27) { - return; - } - - continue; - } - - list_top = 17; - list_bottom = LINES - 4; - first_row = list_top + 2; - visible_rows = - list_bottom - - first_row; - - if (visible_rows < 1) { - return; - } - - if (selected >= item_count) { - selected = - item_count - 1U; - } - - if (selected >= - (size_t)visible_rows) { - top = - selected - - (size_t)visible_rows + - 1U; - } - - box( - stdscr, - 0, - 0 - ); - - section_ascii_header( - ":: S Y N C ::" - ); - - primary_top_navbar( - 5, - nav_selected, - focus == 0 - ); - - dashboard_panel( - 11, - 2, - 16, - COLS - 3, - "APPAREIL CONNECTE" - ); - - focused_panel( - list_top, - 2, - list_bottom, - COLS - 3, - "SYNCHRONISATION", - focus == 1 - ); - - if (!overview.connected) { - attron( - A_BOLD | - trainlog_theme_attribute( - TRAINLOG_COLOR_WARNING - ) - ); - - mvprintw( - 13, - 5, - "Aucun appareil Android en mode partage de fichiers." - ); - - attroff( - A_BOLD | - trainlog_theme_attribute( - TRAINLOG_COLOR_WARNING - ) - ); - - mvprintw( - 14, - 5, - "%.*s", - COLS - 10, - "Branchez et déverrouillez le téléphone puis choisissez Transfert de fichiers." - ); - } else { - attron( - A_BOLD | - trainlog_theme_attribute( - TRAINLOG_COLOR_SUCCESS - ) - ); - - mvprintw( - 12, - 5, - "✓ MTP direct connecté" - ); - - attroff( - A_BOLD | - trainlog_theme_attribute( - TRAINLOG_COLOR_SUCCESS - ) - ); - - mvprintw( - 13, - 5, - "%.*s", - COLS - 10, - overview.device.model[0] != '\0' - ? overview.device.model - : overview.device.vendor - ); - - mvprintw( - 14, - 5, - "USB %03u:%03u %04x:%04x série %.*s", - overview.device.bus_number, - overview.device.device_number, - overview.device.vendor_id, - overview.device.product_id, - COLS - 48, - overview.device.serial[0] != '\0' - ? overview.device.serial - : "—" - ); - - if (overview.storage_ready) { - mvprintw( - 15, - 5, - "%.*s · %.2f GiB libres / %.2f GiB", - 24, - overview.storage.description[0] != '\0' - ? overview.storage.description - : "Stockage MTP", - sync_bytes_to_gib( - overview.storage.free_space_bytes - ), - sync_bytes_to_gib( - overview.storage.max_capacity_bytes - ) - ); + for ( + index = 0U; + index < limit; + ++index + ) { + mvprintw( + 7 + (int)index, + 4, + "%-16s %c %.*s", + history[index].timestamp, + history[index].success + ? '+' + : '!', + COLS - 25, + history[index].summary + ); + } } } - for (index = 0U; - index < (size_t)visible_rows && - top + index < item_count; - ++index) { - size_t absolute = - top + index; - - int row = - first_row + - (int)index; - - const char *direction; - const char *category; - char status[128]; - - switch (absolute) { - case 0U: - direction = "↓"; - category = - "Séances Android -> PC"; - - (void)snprintf( - status, - sizeof(status), - "%zu JSON candidat(s) à analyser", - overview.remote_json_count - ); - break; - - case 1U: - direction = "↓"; - category = - "Exercices Android -> PC"; - - (void)snprintf( - status, - sizeof(status), - "%s", - "import automatique avec séance valide" - ); - break; - - case 2U: - direction = "↓"; - category = - "Mensurations Android -> PC"; - - (void)snprintf( - status, - sizeof(status), - "%s", - "import automatique avec séance valide" - ); - break; - - case 3U: - default: - direction = "↑"; - category = - "Catalogue PC -> Android"; - - (void)snprintf( - status, - sizeof(status), - "%zu exercice(s) locaux à publier", - overview.local_exercise_count - ); - break; - } - - if (focus == 1 && - absolute == selected) { - attron( - A_REVERSE | - trainlog_theme_attribute( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - mvprintw( - row, - 5, - " %s %-30.30s %-.*s ", - direction, - category, - COLS - 45, - status - ); - - if (focus == 1 && - absolute == selected) { - attroff( - A_REVERSE | - trainlog_theme_attribute( - TRAINLOG_COLOR_ACCENT - ) - ); - } - } - - if (overview.exchange_ready && - LINES > 31) { - attron( - trainlog_theme_attribute( - TRAINLOG_COLOR_MUTED - ) - ); - - mvprintw( - list_bottom - 2, - 5, - "Zone Trainlog : %zu objet(s) · JSON séance v1 gelé · snapshot catalogue séparé", - overview.remote_entry_count - ); - - attroff( - trainlog_theme_attribute( - TRAINLOG_COLOR_MUTED - ) - ); - } - - section_scrollbar( - first_row, - list_bottom - 1, - COLS - 5, - selected, - item_count, - (size_t)visible_rows - ); - - attron( - trainlog_theme_attribute( - TRAINLOG_COLOR_MUTED - ) - ); - - mvprintw( - LINES - 2, - 2, - "%.*s", - COLS - 4, - "Tab zone ←→ menu ↑↓/PgUp/PgDn parcourir Entrée ouvrir r actualiser 0-5/F1-F5 direct b/Échap retour" - ); - - attroff( - trainlog_theme_attribute( - TRAINLOG_COLOR_MUTED - ) - ); - - touchwin( - stdscr - ); - refresh(); key = getch(); - if (key == '\t' || - key == KEY_BTAB) { + if ( + key == 's' || + key == 'S' + ) { + TrainlogMobileImportReport report; + char import_output[2048]; + char message[256]; + uint64_t download_size = 0U; + TrainlogStatus status; + + (void)memset( + &report, + 0, + sizeof(report) + ); + + (void)memset( + import_output, + 0, + sizeof(import_output) + ); + + status_line( + "Synchronisation bidirectionnelle en cours...", + TRAINLOG_COLOR_WARNING + ); + + refresh(); + + status = + sync_bidirectional( + &overview, + &report, + import_output, + sizeof(import_output), + &download_size + ); + + if ( + status == + TRAINLOG_STATUS_OK + ) { + (void)snprintf( + message, + sizeof(message), + "Android→PC +%zu séance(s), +%zu exercice(s), +%zu mesure(s) · PC→Android catalogue %zu exercice(s)", + report.sessions_imported, + report.exercises_imported + + report.exercises_reconciled, + report.body_imported, + report.catalog_published + ); + + sync_history_append( + true, + message + ); + + status_line( + message, + TRAINLOG_COLOR_SUCCESS + ); + } else { + const char *failure = + import_output[0] != '\0' + ? import_output + : "Synchronisation bidirectionnelle échouée."; + + sync_history_append( + false, + failure + ); + + (void)snprintf( + message, + sizeof(message), + "%.*s", + (int)sizeof(message) - 1, + failure + ); + + status_line( + message, + TRAINLOG_COLOR_ERROR + ); + } + + refresh(); + (void)getch(); + + refresh_overview = true; + continue; + } + + if ( + key == 'r' || + key == 'R' + ) { + refresh_overview = true; + continue; + } + + if ( + key == 'b' || + key == 'B' || + key == 27 + ) { + return; + } + + if ( + large_layout && + ( + key == '\t' || + key == KEY_BTAB + ) + ) { focus = focus == 0 ? 1 : 0; + continue; } - if (primary_top_nav_forward( - key - )) { - return; - } - - if (key == 'b' || - key == 'B' || - key == 27) { - return; - } - - if (focus == 0) { + if ( + large_layout && + focus == 0 + ) { if (key == KEY_LEFT) { nav_selected = nav_selected > 0 ? nav_selected - 1 : 5; - } else if (key == KEY_RIGHT) { + } else if ( + key == KEY_RIGHT + ) { nav_selected = nav_selected < 5 ? nav_selected + 1 @@ -9170,7 +10683,9 @@ static void screen_sync( key == '\n' || key == KEY_ENTER ) { - if (nav_selected == 5) { + if ( + nav_selected == 5 + ) { focus = 1; } else if ( primary_top_nav_activate( @@ -9184,48 +10699,46 @@ static void screen_sync( continue; } - if (key == KEY_UP) { + if ( + key == KEY_UP && + history_count > 0U + ) { selected = selected > 0U ? selected - 1U - : 0U; - continue; - } - - if (key == KEY_DOWN) { + : history_count - 1U; + } else if ( + key == KEY_DOWN && + history_count > 0U + ) { selected = - selected + 1U < item_count + selected + 1U < + history_count ? selected + 1U - : item_count - 1U; - continue; - } - - if (key == KEY_PPAGE) { - size_t jump = - (size_t)visible_rows; - - selected = - selected > jump - ? selected - jump : 0U; - continue; + } else if ( + key == '0' || + key == KEY_HOME + ) { + return; + } else if ( + key == '1' || + key == KEY_F(1) || + key == '2' || + key == KEY_F(2) || + key == '3' || + key == KEY_F(3) || + key == '4' || + key == KEY_F(4) + ) { + if ( + primary_top_nav_forward( + key + ) + ) { + return; + } } - - if (key == KEY_NPAGE) { - size_t jump = - (size_t)visible_rows; - - selected = - selected + jump < item_count - ? selected + jump - : item_count - 1U; - continue; - } - - /* - * 'r' intentionally reaches the next loop iteration. Every iteration - * performs a fresh USB/MTP scan before repainting the page. - */ } } diff --git a/tui/tools/mtp_mobile_export_probe.c b/tui/tools/mtp_mobile_export_probe.c new file mode 100644 index 0000000..d4e31de --- /dev/null +++ b/tui/tools/mtp_mobile_export_probe.c @@ -0,0 +1,348 @@ +#include +#include +#include +#include + +#include "trainlog/mtp.h" +#include "trainlog/status.h" +#include "trainlog/usb.h" + +#define MAX_DEVICES 8U +#define MAX_STORAGES 8U +#define MAX_ENTRIES 512U + +static TrainlogStatus find_child_folder( + const TrainlogUsbDevice *device, + uint32_t storage_id, + uint32_t parent_id, + const char *name, + uint32_t *output_id +) +{ + TrainlogMtpEntry + entries[MAX_ENTRIES]; + + size_t count = 0U; + size_t index; + TrainlogStatus status; + + if (device == NULL || + name == NULL || + output_id == NULL) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + status = + trainlog_mtp_list_folder( + device->bus_number, + device->device_number, + storage_id, + parent_id, + entries, + MAX_ENTRIES, + &count + ); + + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + for (index = 0U; + index < count; + ++index) { + if (entries[index].folder && + strcmp( + entries[index].name, + name + ) == 0) { + *output_id = + entries[index].item_id; + + return + TRAINLOG_STATUS_OK; + } + } + + return TRAINLOG_STATUS_NOT_FOUND; +} + +static TrainlogStatus find_child_file( + const TrainlogUsbDevice *device, + uint32_t storage_id, + uint32_t parent_id, + const char *name, + uint32_t *output_id, + uint64_t *output_size +) +{ + TrainlogMtpEntry + entries[MAX_ENTRIES]; + + size_t count = 0U; + size_t index; + TrainlogStatus status; + + if (device == NULL || + name == NULL || + output_id == NULL || + output_size == NULL) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + status = + trainlog_mtp_list_folder( + device->bus_number, + device->device_number, + storage_id, + parent_id, + entries, + MAX_ENTRIES, + &count + ); + + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + for (index = 0U; + index < count; + ++index) { + if (!entries[index].folder && + strcmp( + entries[index].name, + name + ) == 0) { + *output_id = + entries[index].item_id; + + *output_size = + entries[index].size_bytes; + + return + TRAINLOG_STATUS_OK; + } + } + + return TRAINLOG_STATUS_NOT_FOUND; +} + +static int validate_download( + const char *path +) +{ + FILE *file; + char buffer[4096]; + size_t used; + + file = + fopen( + path, + "rb" + ); + + if (file == NULL) { + return 1; + } + + used = + fread( + buffer, + 1U, + sizeof(buffer) - 1U, + file + ); + + if (ferror(file) != 0) { + (void)fclose(file); + return 1; + } + + buffer[used] = '\0'; + + if (fclose(file) != 0) { + return 1; + } + + if (strstr( + buffer, + "\"format\":\"trainlog-mobile-export\"" + ) == NULL || + strstr( + buffer, + "\"version\":1" + ) == NULL) { + return 1; + } + + return 0; +} + +int main(void) +{ + TrainlogUsbDevice + devices[MAX_DEVICES]; + + TrainlogMtpStorage + storages[MAX_STORAGES]; + + size_t device_count = 0U; + size_t storage_count = 0U; + uint32_t download_id = 0U; + uint32_t trainlog_id = 0U; + uint32_t export_id = 0U; + uint64_t export_size = 0U; + + const char *local_path = + "/tmp/trainlog-mobile-export-v1.json"; + + TrainlogStatus status; + + status = + trainlog_usb_list_mtp_devices( + devices, + MAX_DEVICES, + &device_count + ); + + if (status != TRAINLOG_STATUS_OK || + device_count == 0U) { + (void)fprintf( + stderr, + "MTP device not found\n" + ); + + return 1; + } + + status = + trainlog_mtp_list_storages( + devices[0].bus_number, + devices[0].device_number, + storages, + MAX_STORAGES, + &storage_count + ); + + if (status != TRAINLOG_STATUS_OK || + storage_count == 0U) { + (void)fprintf( + stderr, + "MTP storage not found\n" + ); + + return 1; + } + + status = + find_child_folder( + &devices[0], + storages[0].storage_id, + UINT32_MAX, + "Download", + &download_id + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)fprintf( + stderr, + "Download folder not found\n" + ); + + return 1; + } + + status = + find_child_folder( + &devices[0], + storages[0].storage_id, + download_id, + "Trainlog", + &trainlog_id + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)fprintf( + stderr, + "Download/Trainlog not found\n" + ); + + return 1; + } + + status = + find_child_file( + &devices[0], + storages[0].storage_id, + trainlog_id, + "trainlog-mobile-export-v1.json", + &export_id, + &export_size + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)fprintf( + stderr, + "mobile export not found\n" + ); + + return 1; + } + + status = + trainlog_mtp_receive_file( + devices[0].bus_number, + devices[0].device_number, + export_id, + local_path + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)fprintf( + stderr, + "MTP receive failed\n" + ); + + return 1; + } + + if (validate_download( + local_path + ) != 0) { + (void)fprintf( + stderr, + "export validation failed\n" + ); + + return 1; + } + + (void)printf( + "MOBILE_EXPORT_MTP=PASS\n" + ); + + (void)printf( + "device=%s %s\n", + devices[0].vendor, + devices[0].model + ); + + (void)printf( + "remote=Download/Trainlog/" + "trainlog-mobile-export-v1.json\n" + ); + + (void)printf( + "size=%llu\n", + (unsigned long long) + export_size + ); + + (void)printf( + "local=%s\n", + local_path + ); + + return 0; +}